diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 80291c73e61..87fed908c6e 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -1,5 +1,6 @@
name: Report an issue with Home Assistant Core
description: Report an issue with Home Assistant Core.
+type: Bug
body:
- type: markdown
attributes:
diff --git a/.github/assets/screenshot-integrations.png b/.github/assets/screenshot-integrations.png
index 8d71bf538d6..abbc0f76ff0 100644
Binary files a/.github/assets/screenshot-integrations.png and b/.github/assets/screenshot-integrations.png differ
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000000..06499d62b9e
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,100 @@
+# Instructions for GitHub Copilot
+
+This repository holds the core of Home Assistant, a Python 3 based home
+automation application.
+
+- Python code must be compatible with Python 3.13
+- Use the newest Python language features if possible:
+ - Pattern matching
+ - Type hints
+ - f-strings for string formatting over `%` or `.format()`
+ - Dataclasses
+ - Walrus operator
+- Code quality tools:
+ - Formatting: Ruff
+ - Linting: PyLint and Ruff
+ - Type checking: MyPy
+ - Testing: pytest with plain functions and fixtures
+- Inline code documentation:
+ - File headers should be short and concise:
+ ```python
+ """Integration for Peblar EV chargers."""
+ ```
+ - Every method and function needs a docstring:
+ ```python
+ async def async_setup_entry(hass: HomeAssistant, entry: PeblarConfigEntry) -> bool:
+ """Set up Peblar from a config entry."""
+ ...
+ ```
+- All code and comments and other text are written in American English
+- Follow existing code style patterns as much as possible
+- Core locations:
+ - Shared constants: `homeassistant/const.py`, use them instead of hardcoding
+ strings or creating duplicate integration constants.
+ - Integration files:
+ - Constants: `homeassistant/components/{domain}/const.py`
+ - Models: `homeassistant/components/{domain}/models.py`
+ - Coordinator: `homeassistant/components/{domain}/coordinator.py`
+ - Config flow: `homeassistant/components/{domain}/config_flow.py`
+ - Platform code: `homeassistant/components/{domain}/{platform}.py`
+- All external I/O operations must be async
+- Async patterns:
+ - Avoid sleeping in loops
+ - Avoid awaiting in loops, gather instead
+ - No blocking calls
+- Polling:
+ - Follow update coordinator pattern, when possible
+ - Polling interval may not be configurable by the user
+ - For local network polling, the minimum interval is 5 seconds
+ - For cloud polling, the minimum interval is 60 seconds
+- Error handling:
+ - Use specific exceptions from `homeassistant.exceptions`
+ - Setup failures:
+ - Temporary: Raise `ConfigEntryNotReady`
+ - Permanent: Use `ConfigEntryError`
+- Logging:
+ - Message format:
+ - No periods at end
+ - No integration names or domains (added automatically)
+ - No sensitive data (keys, tokens, passwords), even when those are incorrect.
+ - Be very restrictive on the use of logging info messages, use debug for
+ anything which is not targeting the user.
+ - Use lazy logging (no f-strings):
+ ```python
+ _LOGGER.debug("This is a log message with %s", variable)
+ ```
+- Entities:
+ - Ensure unique IDs for state persistence:
+ - Unique IDs should not contain values that are subject to user or network change.
+ - An ID needs to be unique per platform, not per integration.
+ - The ID does not have to contain the integration domain or platform.
+ - Acceptable examples:
+ - Serial number of a device
+ - MAC address of a device formatted using `homeassistant.helpers.device_registry.format_mac`
+ Do not obtain the MAC address through arp cache of local network access,
+ only use the MAC address provided by discovery or the device itself.
+ - Unique identifier that is physically printed on the device or burned into an EEPROM
+ - Not acceptable examples:
+ - IP Address
+ - Device name
+ - Hostname
+ - URL
+ - Email address
+ - Username
+ - For entities that are setup by a config entry, the config entry ID
+ can be used as a last resort if no other Unique ID is available.
+ For example: `f"{entry.entry_id}-battery"`
+ - If the state value is unknown, use `None`
+ - Do not use the `unavailable` string as a state value,
+ implement the `available()` property method instead
+ - Do not use the `unknown` string as a state value, use `None` instead
+- Extra entity state attributes:
+ - The keys of all state attributes should always be present
+ - If the value is unknown, use `None`
+ - Provide descriptive state attributes
+- Testing:
+ - Test location: `tests/components/{domain}/`
+ - Use pytest fixtures from `tests.common`
+ - Mock external dependencies
+ - Use snapshots for complex data
+ - Follow existing test patterns
diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml
index cdffcbe4d5b..ce89d8c2b10 100644
--- a/.github/workflows/builder.yml
+++ b/.github/workflows/builder.yml
@@ -32,7 +32,7 @@ jobs:
fetch-depth: 0
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: translations
path: translations.tar.gz
@@ -94,7 +94,7 @@ jobs:
- name: Download nightly wheels of frontend
if: needs.init.outputs.channel == 'dev'
- uses: dawidd6/action-download-artifact@v8
+ uses: dawidd6/action-download-artifact@v9
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/frontend
@@ -105,7 +105,7 @@ jobs:
- name: Download nightly wheels of intents
if: needs.init.outputs.channel == 'dev'
- uses: dawidd6/action-download-artifact@v8
+ uses: dawidd6/action-download-artifact@v9
with:
github_token: ${{secrets.GITHUB_TOKEN}}
repo: home-assistant/intents-package
@@ -116,7 +116,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.channel == 'dev'
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
@@ -175,7 +175,7 @@ jobs:
sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt
- name: Download translations
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
name: translations
@@ -190,14 +190,14 @@ jobs:
echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3.3.0
+ uses: docker/login-action@v3.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
- uses: home-assistant/builder@2024.08.2
+ uses: home-assistant/builder@2025.03.0
with:
args: |
$BUILD_ARGS \
@@ -256,14 +256,14 @@ jobs:
fi
- name: Login to GitHub Container Registry
- uses: docker/login-action@v3.3.0
+ uses: docker/login-action@v3.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
- uses: home-assistant/builder@2024.08.2
+ uses: home-assistant/builder@2025.03.0
with:
args: |
$BUILD_ARGS \
@@ -324,20 +324,20 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Install Cosign
- uses: sigstore/cosign-installer@v3.8.0
+ uses: sigstore/cosign-installer@v3.8.1
with:
cosign-release: "v2.2.3"
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
- uses: docker/login-action@v3.3.0
+ uses: docker/login-action@v3.4.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
if: matrix.registry == 'ghcr.io/home-assistant'
- uses: docker/login-action@v3.3.0
+ uses: docker/login-action@v3.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -448,18 +448,21 @@ jobs:
environment: ${{ needs.init.outputs.channel }}
needs: ["init", "build_base"]
runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true'
steps:
- name: Checkout the repository
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Download translations
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
name: translations
@@ -473,16 +476,13 @@ jobs:
run: |
# Remove dist, build, and homeassistant.egg-info
# when build locally for testing!
- pip install twine build
+ pip install build
python -m build
- - name: Upload package
- shell: bash
- run: |
- export TWINE_USERNAME="__token__"
- export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}"
-
- twine upload dist/* --skip-existing
+ - name: Upload package to PyPI
+ uses: pypa/gh-action-pypi-publish@v1.12.4
+ with:
+ skip-existing: true
hassfest-image:
name: Build and test hassfest image
@@ -502,14 +502,14 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Login to GitHub Container Registry
- uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
+ uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
- uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
+ uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -522,7 +522,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
- uses: docker/build-push-action@ca877d9245402d1537745e0e356eab47c3520991 # v6.13.0
+ uses: docker/build-push-action@471d1dc4e07e5cdedd4c2171150001c434f0b7a4 # v6.15.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -531,7 +531,7 @@ jobs:
- name: Generate artifact attestation
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
- uses: actions/attest-build-provenance@520d128f165991a6c774bcb264f323e3d70747f4 # v2.2.0
+ uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3
with:
subject-name: ${{ env.HASSFEST_IMAGE_NAME }}
subject-digest: ${{ steps.push.outputs.digest }}
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 2a9f1571830..d8fdda601dd 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -37,10 +37,10 @@ on:
type: boolean
env:
- CACHE_VERSION: 11
+ CACHE_VERSION: 12
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 9
- HA_SHORT_VERSION: "2025.3"
+ HA_SHORT_VERSION: "2025.5"
DEFAULT_PYTHON: "3.13"
ALL_PYTHON_VERSIONS: "['3.13']"
# 10.3 is the oldest supported version
@@ -89,6 +89,7 @@ jobs:
test_groups: ${{ steps.info.outputs.test_groups }}
tests_glob: ${{ steps.info.outputs.tests_glob }}
tests: ${{ steps.info.outputs.tests }}
+ lint_only: ${{ steps.info.outputs.lint_only }}
skip_coverage: ${{ steps.info.outputs.skip_coverage }}
runs-on: ubuntu-24.04
steps:
@@ -142,6 +143,7 @@ jobs:
test_group_count=10
tests="[]"
tests_glob=""
+ lint_only=""
skip_coverage=""
if [[ "${{ steps.integrations.outputs.changes }}" != "[]" ]];
@@ -192,6 +194,17 @@ jobs:
test_full_suite="true"
fi
+ if [[ "${{ github.event.inputs.lint-only }}" == "true" ]] \
+ || [[ "${{ github.event.inputs.pylint-only }}" == "true" ]] \
+ || [[ "${{ github.event.inputs.mypy-only }}" == "true" ]] \
+ || [[ "${{ github.event.inputs.audit-licenses-only }}" == "true" ]] \
+ || [[ "${{ github.event_name }}" == "push" \
+ && "${{ github.event.repository.full_name }}" != "home-assistant/core" ]];
+ then
+ lint_only="true"
+ skip_coverage="true"
+ fi
+
if [[ "${{ github.event.inputs.skip-coverage }}" == "true" ]] \
|| [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-skip-coverage') }}" == "true" ]];
then
@@ -217,6 +230,8 @@ jobs:
echo "tests=${tests}" >> $GITHUB_OUTPUT
echo "tests_glob: ${tests_glob}"
echo "tests_glob=${tests_glob}" >> $GITHUB_OUTPUT
+ echo "lint_only": ${lint_only}
+ echo "lint_only=${lint_only}" >> $GITHUB_OUTPUT
echo "skip_coverage: ${skip_coverage}"
echo "skip_coverage=${skip_coverage}" >> $GITHUB_OUTPUT
@@ -234,13 +249,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache@v4.2.0
+ uses: actions/cache@v4.2.3
with:
path: venv
key: >-
@@ -256,7 +271,7 @@ jobs:
uv pip install "$(cat requirements_test.txt | grep pre-commit)"
- name: Restore pre-commit environment from cache
id: cache-precommit
- uses: actions/cache@v4.2.0
+ uses: actions/cache@v4.2.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
@@ -279,14 +294,14 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -295,7 +310,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -319,14 +334,14 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -335,7 +350,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -359,14 +374,14 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
id: python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -375,7 +390,7 @@ jobs:
needs.info.outputs.pre-commit_cache_key }}
- name: Restore pre-commit environment from cache
id: cache-precommit
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: ${{ env.PRE_COMMIT_CACHE }}
fail-on-cache-miss: true
@@ -469,7 +484,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
@@ -482,7 +497,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache@v4.2.0
+ uses: actions/cache@v4.2.3
with:
path: venv
key: >-
@@ -490,7 +505,7 @@ jobs:
needs.info.outputs.python_cache_key }}
- name: Restore uv wheel cache
if: steps.cache-venv.outputs.cache-hit != 'true'
- uses: actions/cache@v4.2.0
+ uses: actions/cache@v4.2.3
with:
path: ${{ env.UV_CACHE_DIR }}
key: >-
@@ -537,7 +552,7 @@ jobs:
python --version
uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt
@@ -572,13 +587,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -605,13 +620,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -623,6 +638,25 @@ jobs:
. venv/bin/activate
python -m script.gen_requirements_all validate
+ dependency-review:
+ name: Dependency review
+ runs-on: ubuntu-24.04
+ needs:
+ - info
+ - base
+ if: |
+ github.event.inputs.pylint-only != 'true'
+ && github.event.inputs.mypy-only != 'true'
+ && needs.info.outputs.requirements == 'true'
+ && github.event_name == 'pull_request'
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@v4.2.2
+ - name: Dependency review
+ uses: actions/dependency-review-action@v4.6.0
+ with:
+ license-check: false # We use our own license audit checks
+
audit-licenses:
name: Audit licenses
runs-on: ubuntu-24.04
@@ -643,13 +677,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -661,7 +695,7 @@ jobs:
. venv/bin/activate
python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json
- name: Upload licenses
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
path: licenses-${{ matrix.python-version }}.json
@@ -686,13 +720,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -733,13 +767,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -778,7 +812,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -791,7 +825,7 @@ jobs:
env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -799,7 +833,7 @@ jobs:
${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{
needs.info.outputs.python_cache_key }}
- name: Restore mypy cache
- uses: actions/cache@v4.2.0
+ uses: actions/cache@v4.2.3
with:
path: .mypy_cache
key: >-
@@ -829,11 +863,7 @@ jobs:
prepare-pytest-full:
runs-on: ubuntu-24.04
if: |
- (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
- && github.event.inputs.lint-only != 'true'
- && github.event.inputs.pylint-only != 'true'
- && github.event.inputs.mypy-only != 'true'
- && github.event.inputs.audit-licenses-only != 'true'
+ needs.info.outputs.lint_only != 'true'
&& needs.info.outputs.test_full_suite == 'true'
needs:
- info
@@ -859,13 +889,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -877,7 +907,7 @@ jobs:
. venv/bin/activate
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
- name: Upload pytest_buckets
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: pytest_buckets
path: pytest_buckets.txt
@@ -886,11 +916,7 @@ jobs:
pytest-full:
runs-on: ubuntu-24.04
if: |
- (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
- && github.event.inputs.lint-only != 'true'
- && github.event.inputs.pylint-only != 'true'
- && github.event.inputs.mypy-only != 'true'
- && github.event.inputs.audit-licenses-only != 'true'
+ needs.info.outputs.lint_only != 'true'
&& needs.info.outputs.test_full_suite == 'true'
needs:
- info
@@ -923,13 +949,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -942,7 +968,7 @@ jobs:
run: |
echo "::add-matcher::.github/workflows/matchers/pytest-slow.json"
- name: Download pytest_buckets
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
name: pytest_buckets
- name: Compile English translations
@@ -962,6 +988,7 @@ jobs:
if [[ "${{ needs.info.outputs.skip_coverage }}" != "true" ]]; then
cov_params+=(--cov="homeassistant")
cov_params+=(--cov-report=xml)
+ cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
fi
echo "Test group ${{ matrix.group }}: $(sed -n "${{ matrix.group }},1p" pytest_buckets.txt)"
@@ -980,18 +1007,24 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
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@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
overwrite: true
+ - name: Upload test results artifact
+ if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
+ uses: actions/upload-artifact@v4.6.2
+ with:
+ name: test-results-full-${{ matrix.python-version }}-${{ matrix.group }}
+ path: junit.xml
- name: Remove pytest_buckets
run: rm pytest_buckets.txt
- name: Check dirty
@@ -1009,11 +1042,7 @@ jobs:
MYSQL_ROOT_PASSWORD: password
options: --health-cmd="mysqladmin ping -uroot -ppassword" --health-interval=5s --health-timeout=2s --health-retries=3
if: |
- (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
- && github.event.inputs.lint-only != 'true'
- && github.event.inputs.pylint-only != 'true'
- && github.event.inputs.mypy-only != 'true'
- && github.event.inputs.audit-licenses-only != 'true'
+ needs.info.outputs.lint_only != 'true'
&& needs.info.outputs.mariadb_groups != '[]'
needs:
- info
@@ -1045,13 +1074,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -1088,6 +1117,7 @@ jobs:
cov_params+=(--cov="homeassistant.components.recorder")
cov_params+=(--cov-report=xml)
cov_params+=(--cov-report=term-missing)
+ cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
fi
python3 -b -X dev -m pytest \
@@ -1108,7 +1138,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
@@ -1116,12 +1146,19 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }}
path: coverage.xml
overwrite: true
+ - name: Upload test results artifact
+ if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
+ uses: actions/upload-artifact@v4.6.2
+ with:
+ name: test-results-mariadb-${{ matrix.python-version }}-${{
+ steps.pytest-partial.outputs.mariadb }}
+ path: junit.xml
- name: Check dirty
run: |
./script/check_dirty
@@ -1137,11 +1174,7 @@ jobs:
POSTGRES_PASSWORD: password
options: --health-cmd="pg_isready -hlocalhost -Upostgres" --health-interval=5s --health-timeout=2s --health-retries=3
if: |
- (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
- && github.event.inputs.lint-only != 'true'
- && github.event.inputs.pylint-only != 'true'
- && github.event.inputs.mypy-only != 'true'
- && github.event.inputs.audit-licenses-only != 'true'
+ needs.info.outputs.lint_only != 'true'
&& needs.info.outputs.postgresql_groups != '[]'
needs:
- info
@@ -1175,13 +1208,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -1218,6 +1251,7 @@ jobs:
cov_params+=(--cov="homeassistant.components.recorder")
cov_params+=(--cov-report=xml)
cov_params+=(--cov-report=term-missing)
+ cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
fi
python3 -b -X dev -m pytest \
@@ -1239,7 +1273,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
@@ -1247,12 +1281,19 @@ jobs:
overwrite: true
- name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true'
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }}
path: coverage.xml
overwrite: true
+ - name: Upload test results artifact
+ if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
+ uses: actions/upload-artifact@v4.6.2
+ with:
+ name: test-results-postgres-${{ matrix.python-version }}-${{
+ steps.pytest-partial.outputs.postgresql }}
+ path: junit.xml
- name: Check dirty
run: |
./script/check_dirty
@@ -1271,12 +1312,12 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'true'
- uses: codecov/codecov-action@v5.3.1
+ uses: codecov/codecov-action@v5.4.2
with:
fail_ci_if_error: true
flags: full-suite
@@ -1285,11 +1326,7 @@ jobs:
pytest-partial:
runs-on: ubuntu-24.04
if: |
- (github.event_name != 'push' || github.event.repository.full_name == 'home-assistant/core')
- && github.event.inputs.lint-only != 'true'
- && github.event.inputs.pylint-only != 'true'
- && github.event.inputs.mypy-only != 'true'
- && github.event.inputs.audit-licenses-only != 'true'
+ needs.info.outputs.lint_only != 'true'
&& needs.info.outputs.tests_glob
&& needs.info.outputs.test_full_suite == 'false'
needs:
@@ -1322,13 +1359,13 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ matrix.python-version }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ matrix.python-version }}
check-latest: true
- name: Restore full Python ${{ matrix.python-version }} virtual environment
id: cache-venv
- uses: actions/cache/restore@v4.2.0
+ uses: actions/cache/restore@v4.2.3
with:
path: venv
fail-on-cache-miss: true
@@ -1365,6 +1402,7 @@ jobs:
cov_params+=(--cov="homeassistant.components.${{ matrix.group }}")
cov_params+=(--cov-report=xml)
cov_params+=(--cov-report=term-missing)
+ cov_params+=(--junitxml=junit.xml -o junit_family=legacy)
fi
python3 -b -X dev -m pytest \
@@ -1382,18 +1420,24 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
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@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml
overwrite: true
+ - name: Upload test results artifact
+ if: needs.info.outputs.skip_coverage != 'true' && !cancelled()
+ uses: actions/upload-artifact@v4.6.2
+ with:
+ name: test-results-partial-${{ matrix.python-version }}-${{ matrix.group }}
+ path: junit.xml
- name: Check dirty
run: |
./script/check_dirty
@@ -1410,12 +1454,37 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Download all coverage artifacts
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
pattern: coverage-*
- name: Upload coverage to Codecov
if: needs.info.outputs.test_full_suite == 'false'
- uses: codecov/codecov-action@v5.3.1
+ uses: codecov/codecov-action@v5.4.2
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
+
+ upload-test-results:
+ name: Upload test results to Codecov
+ # codecov/test-results-action currently doesn't support tokenless uploads
+ # therefore we can't run it on forks
+ if: ${{ (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.info.outputs.skip_coverage != 'true' && !cancelled() }}
+ runs-on: ubuntu-24.04
+ needs:
+ - info
+ - pytest-partial
+ - pytest-full
+ - pytest-postgres
+ - pytest-mariadb
+ timeout-minutes: 10
+ steps:
+ - name: Download all coverage artifacts
+ uses: actions/download-artifact@v4.2.1
+ with:
+ pattern: test-results-*
+ - name: Upload test results to Codecov
+ uses: codecov/test-results-action@v1
+ with:
+ fail_ci_if_error: true
+ verbose: true
+ token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index c1272759acc..9a926c18d76 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Initialize CodeQL
- uses: github/codeql-action/init@v3.28.8
+ uses: github/codeql-action/init@v3.28.15
with:
languages: python
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v3.28.8
+ uses: github/codeql-action/analyze@v3.28.15
with:
category: "/language:python"
diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml
index 619d83aef51..0b6abe8fe2c 100644
--- a/.github/workflows/translations.yml
+++ b/.github/workflows/translations.yml
@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml
index 41e7b351184..d27a62bab80 100644
--- a/.github/workflows/wheels.yml
+++ b/.github/workflows/wheels.yml
@@ -36,7 +36,7 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
- uses: actions/setup-python@v5.4.0
+ uses: actions/setup-python@v5.5.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
check-latest: true
@@ -91,7 +91,7 @@ jobs:
) > build_constraints.txt
- name: Upload env_file
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: env_file
path: ./.env_file
@@ -99,14 +99,14 @@ jobs:
overwrite: true
- name: Upload build_constraints
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: build_constraints
path: ./build_constraints.txt
overwrite: true
- name: Upload requirements_diff
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: requirements_diff
path: ./requirements_diff.txt
@@ -118,7 +118,7 @@ jobs:
python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels
- uses: actions/upload-artifact@v4.6.0
+ uses: actions/upload-artifact@v4.6.2
with:
name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt
@@ -138,17 +138,17 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Download env_file
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
name: env_file
- name: Download build_constraints
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
name: build_constraints
- name: Download requirements_diff
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
name: requirements_diff
@@ -159,7 +159,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
- uses: home-assistant/wheels@2024.11.0
+ uses: home-assistant/wheels@2025.03.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -187,22 +187,22 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Download env_file
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
name: env_file
- name: Download build_constraints
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
name: build_constraints
- name: Download requirements_diff
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
name: requirements_diff
- name: Download requirements_all_wheels
- uses: actions/download-artifact@v4.1.8
+ uses: actions/download-artifact@v4.2.1
with:
name: requirements_all_wheels
@@ -218,16 +218,8 @@ jobs:
sed -i "/uv/d" requirements.txt
sed -i "/uv/d" requirements_diff.txt
- - name: Split requirements all
- run: |
- # We split requirements all into multiple files.
- # This is to prevent the build from running out of memory when
- # resolving packages on 32-bits systems (like armhf, armv7).
-
- split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all_wheels_${{ matrix.arch }}.txt requirements_all.txt
-
- - name: Build wheels (part 1)
- uses: home-assistant/wheels@2024.11.0
+ - name: Build wheels
+ uses: home-assistant/wheels@2025.03.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2
@@ -238,32 +230,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.txtaa"
-
- - name: Build wheels (part 2)
- uses: home-assistant/wheels@2024.11.0
- with:
- abi: ${{ matrix.abi }}
- tag: musllinux_1_2
- arch: ${{ matrix.arch }}
- wheels-key: ${{ secrets.WHEELS_KEY }}
- env-file: true
- apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
- 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.txtab"
-
- - name: Build wheels (part 3)
- uses: home-assistant/wheels@2024.11.0
- with:
- abi: ${{ matrix.abi }}
- tag: musllinux_1_2
- arch: ${{ matrix.arch }}
- wheels-key: ${{ secrets.WHEELS_KEY }}
- env-file: true
- apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm;zlib-ng-dev"
- 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.txtac"
+ requirements: "requirements_all.txt"
diff --git a/.gitignore b/.gitignore
index 241255253c5..5aa51c9d762 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,6 +69,7 @@ test-reports/
test-results.xml
test-output.xml
pytest-*.txt
+junit.xml
# Translations
*.mo
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a059710d3d7..42e05a869c3 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
- rev: v0.9.1
+ rev: v0.11.0
hooks:
- id: ruff
args:
diff --git a/.strict-typing b/.strict-typing
index 1e3187980cc..69d46958882 100644
--- a/.strict-typing
+++ b/.strict-typing
@@ -103,6 +103,7 @@ homeassistant.components.auth.*
homeassistant.components.automation.*
homeassistant.components.awair.*
homeassistant.components.axis.*
+homeassistant.components.azure_storage.*
homeassistant.components.backup.*
homeassistant.components.baf.*
homeassistant.components.bang_olufsen.*
@@ -118,6 +119,7 @@ homeassistant.components.bluetooth_adapters.*
homeassistant.components.bluetooth_tracker.*
homeassistant.components.bmw_connected_drive.*
homeassistant.components.bond.*
+homeassistant.components.bosch_alarm.*
homeassistant.components.braviatv.*
homeassistant.components.bring.*
homeassistant.components.brother.*
@@ -135,6 +137,7 @@ homeassistant.components.clicksend.*
homeassistant.components.climate.*
homeassistant.components.cloud.*
homeassistant.components.co2signal.*
+homeassistant.components.comelit.*
homeassistant.components.command_line.*
homeassistant.components.config.*
homeassistant.components.configurator.*
@@ -234,6 +237,7 @@ homeassistant.components.here_travel_time.*
homeassistant.components.history.*
homeassistant.components.history_stats.*
homeassistant.components.holiday.*
+homeassistant.components.home_connect.*
homeassistant.components.homeassistant.*
homeassistant.components.homeassistant_alerts.*
homeassistant.components.homeassistant_green.*
@@ -287,6 +291,7 @@ homeassistant.components.kaleidescape.*
homeassistant.components.knocki.*
homeassistant.components.knx.*
homeassistant.components.kraken.*
+homeassistant.components.kulersky.*
homeassistant.components.lacrosse.*
homeassistant.components.lacrosse_view.*
homeassistant.components.lamarzocco.*
@@ -360,6 +365,7 @@ homeassistant.components.notify.*
homeassistant.components.notion.*
homeassistant.components.number.*
homeassistant.components.nut.*
+homeassistant.components.ohme.*
homeassistant.components.onboarding.*
homeassistant.components.oncue.*
homeassistant.components.onedrive.*
@@ -394,6 +400,7 @@ homeassistant.components.pure_energie.*
homeassistant.components.purpleair.*
homeassistant.components.pushbullet.*
homeassistant.components.pvoutput.*
+homeassistant.components.pyload.*
homeassistant.components.python_script.*
homeassistant.components.qbus.*
homeassistant.components.qnap_qsw.*
@@ -406,7 +413,9 @@ homeassistant.components.raspberry_pi.*
homeassistant.components.rdw.*
homeassistant.components.recollect_waste.*
homeassistant.components.recorder.*
+homeassistant.components.remember_the_milk.*
homeassistant.components.remote.*
+homeassistant.components.remote_calendar.*
homeassistant.components.renault.*
homeassistant.components.reolink.*
homeassistant.components.repairs.*
@@ -437,6 +446,7 @@ homeassistant.components.select.*
homeassistant.components.sensibo.*
homeassistant.components.sensirion_ble.*
homeassistant.components.sensor.*
+homeassistant.components.sensorpush_cloud.*
homeassistant.components.sensoterra.*
homeassistant.components.senz.*
homeassistant.components.sfr_box.*
@@ -524,6 +534,7 @@ homeassistant.components.vallox.*
homeassistant.components.valve.*
homeassistant.components.velbus.*
homeassistant.components.vlc_telnet.*
+homeassistant.components.vodafone_station.*
homeassistant.components.wake_on_lan.*
homeassistant.components.wake_word.*
homeassistant.components.wallbox.*
diff --git a/.vscode/launch.json b/.vscode/launch.json
index 7b77a1c9bfd..459a9e6acc5 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -38,10 +38,17 @@
"module": "pytest",
"justMyCode": false,
"args": [
- "--timeout=10",
"--picked"
],
},
+ {
+ "name": "Home Assistant: Debug Current Test File",
+ "type": "debugpy",
+ "request": "launch",
+ "module": "pytest",
+ "console": "integratedTerminal",
+ "args": ["-vv", "${file}"]
+ },
{
// Debug by attaching to local Home Assistant server using Remote Python Debugger.
// See https://www.home-assistant.io/integrations/debugpy/
@@ -77,4 +84,4 @@
]
}
]
-}
\ No newline at end of file
+}
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index 7425e7a2533..09c1d374299 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -4,7 +4,7 @@
{
"label": "Run Home Assistant Core",
"type": "shell",
- "command": "hass -c ./config",
+ "command": "${command:python.interpreterPath} -m homeassistant -c ./config",
"group": "test",
"presentation": {
"reveal": "always",
@@ -148,7 +148,7 @@
{
"label": "Install all Test Requirements",
"type": "shell",
- "command": "uv pip install -r requirements_test_all.txt",
+ "command": "uv pip install -r requirements.txt -r requirements_test_all.txt",
"group": {
"kind": "build",
"isDefault": true
diff --git a/CODEOWNERS b/CODEOWNERS
index e510eec6dfa..1ac564a6991 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -180,6 +180,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/azure_event_hub/ @eavanvalkenburg
/tests/components/azure_event_hub/ @eavanvalkenburg
/homeassistant/components/azure_service_bus/ @hfurubotten
+/homeassistant/components/azure_storage/ @zweckj
+/tests/components/azure_storage/ @zweckj
/homeassistant/components/backup/ @home-assistant/core
/tests/components/backup/ @home-assistant/core
/homeassistant/components/baf/ @bdraco @jfroy
@@ -214,6 +216,8 @@ build.json @home-assistant/supervisor
/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
+/tests/components/bosch_alarm/ @mag1024 @sanjay900
/homeassistant/components/bosch_shc/ @tschamm
/tests/components/bosch_shc/ @tschamm
/homeassistant/components/braviatv/ @bieniu @Drafteed
@@ -428,7 +432,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/entur_public_transport/ @hfurubotten
/homeassistant/components/environment_canada/ @gwww @michaeldavie
/tests/components/environment_canada/ @gwww @michaeldavie
-/homeassistant/components/ephember/ @ttroy50
+/homeassistant/components/ephember/ @ttroy50 @roberty99
/homeassistant/components/epic_games_store/ @hacf-fr @Quentame
/tests/components/epic_games_store/ @hacf-fr @Quentame
/homeassistant/components/epion/ @lhgravendeel
@@ -568,8 +572,8 @@ build.json @home-assistant/supervisor
/tests/components/google_cloud/ @lufton @tronikos
/homeassistant/components/google_drive/ @tronikos
/tests/components/google_drive/ @tronikos
-/homeassistant/components/google_generative_ai_conversation/ @tronikos
-/tests/components/google_generative_ai_conversation/ @tronikos
+/homeassistant/components/google_generative_ai_conversation/ @tronikos @ivanlh
+/tests/components/google_generative_ai_conversation/ @tronikos @ivanlh
/homeassistant/components/google_mail/ @tkdrob
/tests/components/google_mail/ @tkdrob
/homeassistant/components/google_photos/ @allenporter
@@ -700,6 +704,8 @@ build.json @home-assistant/supervisor
/tests/components/image_upload/ @home-assistant/core
/homeassistant/components/imap/ @jbouwh
/tests/components/imap/ @jbouwh
+/homeassistant/components/imeon_inverter/ @Imeon-Energy
+/tests/components/imeon_inverter/ @Imeon-Energy
/homeassistant/components/imgw_pib/ @bieniu
/tests/components/imgw_pib/ @bieniu
/homeassistant/components/improv_ble/ @emontnemery
@@ -931,6 +937,8 @@ build.json @home-assistant/supervisor
/tests/components/metoffice/ @MrHarcombe @avee87
/homeassistant/components/microbees/ @microBeesTech
/tests/components/microbees/ @microBeesTech
+/homeassistant/components/miele/ @astrandb
+/tests/components/miele/ @astrandb
/homeassistant/components/mikrotik/ @engrbm87
/tests/components/mikrotik/ @engrbm87
/homeassistant/components/mill/ @danielhiversen
@@ -967,8 +975,8 @@ build.json @home-assistant/supervisor
/tests/components/motionblinds_ble/ @LennP @jerrybboy
/homeassistant/components/motioneye/ @dermotduffy
/tests/components/motioneye/ @dermotduffy
-/homeassistant/components/motionmount/ @RJPoelstra
-/tests/components/motionmount/ @RJPoelstra
+/homeassistant/components/motionmount/ @laiho-vogels
+/tests/components/motionmount/ @laiho-vogels
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
/homeassistant/components/msteams/ @peroyvind
@@ -1051,8 +1059,8 @@ build.json @home-assistant/supervisor
/tests/components/numato/ @clssn
/homeassistant/components/number/ @home-assistant/core @Shulyaka
/tests/components/number/ @home-assistant/core @Shulyaka
-/homeassistant/components/nut/ @bdraco @ollo69 @pestevez
-/tests/components/nut/ @bdraco @ollo69 @pestevez
+/homeassistant/components/nut/ @bdraco @ollo69 @pestevez @tdfountain
+/tests/components/nut/ @bdraco @ollo69 @pestevez @tdfountain
/homeassistant/components/nws/ @MatthewFlamm @kamiyo
/tests/components/nws/ @MatthewFlamm @kamiyo
/homeassistant/components/nyt_games/ @joostlek
@@ -1138,12 +1146,14 @@ build.json @home-assistant/supervisor
/tests/components/permobil/ @IsakNyberg
/homeassistant/components/persistent_notification/ @home-assistant/core
/tests/components/persistent_notification/ @home-assistant/core
+/homeassistant/components/pglab/ @pglab-electronics
+/tests/components/pglab/ @pglab-electronics
/homeassistant/components/philips_js/ @elupus
/tests/components/philips_js/ @elupus
/homeassistant/components/pi_hole/ @shenxn
/tests/components/pi_hole/ @shenxn
-/homeassistant/components/picnic/ @corneyl
-/tests/components/picnic/ @corneyl
+/homeassistant/components/picnic/ @corneyl @codesalatdev
+/tests/components/picnic/ @corneyl @codesalatdev
/homeassistant/components/ping/ @jpbede
/tests/components/ping/ @jpbede
/homeassistant/components/plaato/ @JohNan
@@ -1179,6 +1189,8 @@ build.json @home-assistant/supervisor
/tests/components/prusalink/ @balloob
/homeassistant/components/ps4/ @ktnrg45
/tests/components/ps4/ @ktnrg45
+/homeassistant/components/pterodactyl/ @elmurato
+/tests/components/pterodactyl/ @elmurato
/homeassistant/components/pure_energie/ @klaasnicolaas
/tests/components/pure_energie/ @klaasnicolaas
/homeassistant/components/purpleair/ @bachya
@@ -1248,6 +1260,8 @@ build.json @home-assistant/supervisor
/tests/components/refoss/ @ashionky
/homeassistant/components/remote/ @home-assistant/core
/tests/components/remote/ @home-assistant/core
+/homeassistant/components/remote_calendar/ @Thomas55555
+/tests/components/remote_calendar/ @Thomas55555
/homeassistant/components/renault/ @epenet
/tests/components/renault/ @epenet
/homeassistant/components/renson/ @jimmyd-be
@@ -1340,6 +1354,8 @@ build.json @home-assistant/supervisor
/tests/components/sensorpro/ @bdraco
/homeassistant/components/sensorpush/ @bdraco
/tests/components/sensorpush/ @bdraco
+/homeassistant/components/sensorpush_cloud/ @sstallion
+/tests/components/sensorpush_cloud/ @sstallion
/homeassistant/components/sensoterra/ @markruys
/tests/components/sensoterra/ @markruys
/homeassistant/components/sentry/ @dcramer @frenck
@@ -1375,7 +1391,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/siren/ @home-assistant/core @raman325
/tests/components/siren/ @home-assistant/core @raman325
/homeassistant/components/sisyphus/ @jkeljo
-/homeassistant/components/sky_hub/ @rogerselwyn
/homeassistant/components/sky_remote/ @dunnmj @saty9
/tests/components/sky_remote/ @dunnmj @saty9
/homeassistant/components/skybell/ @tkdrob
@@ -1395,6 +1410,8 @@ build.json @home-assistant/supervisor
/tests/components/smappee/ @bsmappee
/homeassistant/components/smart_meter_texas/ @grahamwetzler
/tests/components/smart_meter_texas/ @grahamwetzler
+/homeassistant/components/smartthings/ @joostlek
+/tests/components/smartthings/ @joostlek
/homeassistant/components/smarttub/ @mdz
/tests/components/smarttub/ @mdz
/homeassistant/components/smarty/ @z0mbieprocess
@@ -1409,6 +1426,8 @@ build.json @home-assistant/supervisor
/tests/components/snapcast/ @luar123
/homeassistant/components/snmp/ @nmaggioni
/tests/components/snmp/ @nmaggioni
+/homeassistant/components/snoo/ @Lash-L
+/tests/components/snoo/ @Lash-L
/homeassistant/components/snooz/ @AustinBrunkhorst
/tests/components/snooz/ @AustinBrunkhorst
/homeassistant/components/solaredge/ @frenck @bdraco
@@ -1464,8 +1483,6 @@ build.json @home-assistant/supervisor
/tests/components/suez_water/ @ooii @jb101010-2
/homeassistant/components/sun/ @Swamp-Ig
/tests/components/sun/ @Swamp-Ig
-/homeassistant/components/sunweg/ @rokam
-/tests/components/sunweg/ @rokam
/homeassistant/components/supla/ @mwegrzynek
/homeassistant/components/surepetcare/ @benleb @danielhiversen
/tests/components/surepetcare/ @benleb @danielhiversen
@@ -1519,8 +1536,8 @@ build.json @home-assistant/supervisor
/tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike
-/homeassistant/components/template/ @PhracturedBlue @home-assistant/core
-/tests/components/template/ @PhracturedBlue @home-assistant/core
+/homeassistant/components/template/ @Petro31 @PhracturedBlue @home-assistant/core
+/tests/components/template/ @Petro31 @PhracturedBlue @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks
@@ -1689,6 +1706,8 @@ build.json @home-assistant/supervisor
/tests/components/weatherflow_cloud/ @jeeftor
/homeassistant/components/weatherkit/ @tjhorner
/tests/components/weatherkit/ @tjhorner
+/homeassistant/components/webdav/ @jpbede
+/tests/components/webdav/ @jpbede
/homeassistant/components/webhook/ @home-assistant/core
/tests/components/webhook/ @home-assistant/core
/homeassistant/components/webmin/ @autinerd
diff --git a/Dockerfile b/Dockerfile
index 19b2c97b181..0a74e0a3aac 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,8 +12,26 @@ ENV \
ARG QEMU_CPU
+# Home Assistant S6-Overlay
+COPY rootfs /
+
+# Needs to be redefined inside the FROM statement to be set for RUN commands
+ARG BUILD_ARCH
+# Get go2rtc binary
+RUN \
+ case "${BUILD_ARCH}" in \
+ "aarch64") go2rtc_suffix='arm64' ;; \
+ "armhf") go2rtc_suffix='armv6' ;; \
+ "armv7") go2rtc_suffix='arm' ;; \
+ *) go2rtc_suffix=${BUILD_ARCH} ;; \
+ esac \
+ && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.9/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
+ && chmod +x /bin/go2rtc \
+ # Verify go2rtc can be executed
+ && go2rtc --version
+
# Install uv
-RUN pip3 install uv==0.5.27
+RUN pip3 install uv==0.6.10
WORKDIR /usr/src
@@ -42,22 +60,4 @@ RUN \
&& python3 -m compileall \
homeassistant/homeassistant
-# Home Assistant S6-Overlay
-COPY rootfs /
-
-# Needs to be redefined inside the FROM statement to be set for RUN commands
-ARG BUILD_ARCH
-# Get go2rtc binary
-RUN \
- case "${BUILD_ARCH}" in \
- "aarch64") go2rtc_suffix='arm64' ;; \
- "armhf") go2rtc_suffix='armv6' ;; \
- "armv7") go2rtc_suffix='arm' ;; \
- *) go2rtc_suffix=${BUILD_ARCH} ;; \
- esac \
- && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
- && chmod +x /bin/go2rtc \
- # Verify go2rtc can be executed
- && go2rtc --version
-
WORKDIR /config
diff --git a/build.yaml b/build.yaml
index e6e149cf700..87dad1bf5ef 100644
--- a/build.yaml
+++ b/build.yaml
@@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant
build_from:
- aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.12.0
- armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.12.0
- armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.12.0
- amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.12.0
- i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.12.0
+ aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.02.1
+ armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.02.1
+ armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.02.1
+ amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.02.1
+ i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.02.1
codenotary:
signer: notary@home-assistant.io
base_image: notary@home-assistant.io
@@ -19,4 +19,4 @@ labels:
org.opencontainers.image.authors: The Home Assistant Authors
org.opencontainers.image.url: https://www.home-assistant.io/
org.opencontainers.image.documentation: https://www.home-assistant.io/docs/
- org.opencontainers.image.licenses: Apache License 2.0
+ org.opencontainers.image.licenses: Apache-2.0
diff --git a/homeassistant/block_async_io.py b/homeassistant/block_async_io.py
index d224b0b151d..eb81268434b 100644
--- a/homeassistant/block_async_io.py
+++ b/homeassistant/block_async_io.py
@@ -178,6 +178,15 @@ _BLOCKING_CALLS: tuple[BlockingCall, ...] = (
strict_core=False,
skip_for_tests=True,
),
+ BlockingCall(
+ original_func=SSLContext.set_default_verify_paths,
+ object=SSLContext,
+ function="set_default_verify_paths",
+ check_allowed=None,
+ strict=False,
+ strict_core=False,
+ skip_for_tests=True,
+ ),
BlockingCall(
original_func=Path.open,
object=Path,
diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py
index 58150ae7926..f88912478a7 100644
--- a/homeassistant/bootstrap.py
+++ b/homeassistant/bootstrap.py
@@ -53,6 +53,7 @@ from .components import (
logbook as logbook_pre_import, # noqa: F401
lovelace as lovelace_pre_import, # noqa: F401
onboarding as onboarding_pre_import, # noqa: F401
+ person as person_pre_import, # noqa: F401
recorder as recorder_import, # noqa: F401 - not named pre_import since it has requirements
repairs as repairs_pre_import, # noqa: F401
search as search_pre_import, # noqa: F401
@@ -74,12 +75,14 @@ from .core_config import async_process_ha_core_config
from .exceptions import HomeAssistantError
from .helpers import (
area_registry,
+ backup,
category_registry,
config_validation as cv,
device_registry,
entity,
entity_registry,
floor_registry,
+ frame,
issue_registry,
label_registry,
recorder,
@@ -91,6 +94,7 @@ from .helpers.dispatcher import async_dispatcher_send_internal
from .helpers.storage import get_internal_store_manager
from .helpers.system_info import async_get_system_info
from .helpers.typing import ConfigType
+from .loader import Integration
from .setup import (
# _setup_started is marked as protected to make it clear
# that it is not part of the public API and should not be used
@@ -134,14 +138,12 @@ DATA_REGISTRIES_LOADED: HassKey[None] = HassKey("bootstrap_registries_loaded")
LOG_SLOW_STARTUP_INTERVAL = 60
SLOW_STARTUP_CHECK_INTERVAL = 1
+STAGE_0_SUBSTAGE_TIMEOUT = 60
STAGE_1_TIMEOUT = 120
STAGE_2_TIMEOUT = 300
WRAP_UP_TIMEOUT = 300
COOLDOWN_TIME = 60
-
-DEBUGGER_INTEGRATIONS = {"debugpy"}
-
# Core integrations are unconditionally loaded
CORE_INTEGRATIONS = {"homeassistant", "persistent_notification"}
@@ -152,6 +154,10 @@ LOGGING_AND_HTTP_DEPS_INTEGRATIONS = {
"isal",
# Set log levels
"logger",
+ # Ensure network config is available
+ # before hassio or any other integration is
+ # loaded that might create an aiohttp client session
+ "network",
# Error logging
"system_log",
"sentry",
@@ -161,23 +167,28 @@ FRONTEND_INTEGRATIONS = {
# integrations can be removed and database migration status is
# visible in frontend
"frontend",
- # Hassio is an after dependency of backup, after dependencies
- # are not promoted from stage 2 to earlier stages, so we need to
- # add it here. Hassio needs to be setup before backup, otherwise
- # the backup integration will think we are a container/core install
- # when using HAOS or Supervised install.
- "hassio",
- # Backup is an after dependency of frontend, after dependencies
- # are not promoted from stage 2 to earlier stages, so we need to
- # add it here.
- "backup",
}
-RECORDER_INTEGRATIONS = {
- # Setup after frontend
- # To record data
- "recorder",
-}
-DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb", "zeroconf")
+# Stage 0 is divided into substages. Each substage has a name, a set of integrations and a timeout.
+# The substage containing recorder should have no timeout, as it could cancel a database migration.
+# Recorder freezes "recorder" timeout during a migration, but it does not freeze other timeouts.
+# The substages preceding it should also have no timeout, until we ensure that the recorder
+# is not accidentally promoted as a dependency of any of the integrations in them.
+# If we add timeouts to the frontend substages, we should make sure they don't apply in recovery mode.
+STAGE_0_INTEGRATIONS = (
+ # Load logging and http deps as soon as possible
+ ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS, None),
+ # Setup frontend
+ ("frontend", FRONTEND_INTEGRATIONS, None),
+ # Setup recorder
+ ("recorder", {"recorder"}, None),
+ # Start up debuggers. Start these first in case they want to wait.
+ ("debugger", {"debugpy"}, STAGE_0_SUBSTAGE_TIMEOUT),
+ # Zeroconf is used for mdns resolution in aiohttp client helper.
+ ("zeroconf", {"zeroconf"}, STAGE_0_SUBSTAGE_TIMEOUT),
+)
+
+DISCOVERY_INTEGRATIONS = ("bluetooth", "dhcp", "ssdp", "usb")
+# Stage 1 integrations are not to be preimported in bootstrap.
STAGE_1_INTEGRATIONS = {
# We need to make sure discovery integrations
# update their deps before stage 2 integrations
@@ -192,6 +203,7 @@ STAGE_1_INTEGRATIONS = {
# Ensure supervisor is available
"hassio",
}
+
DEFAULT_INTEGRATIONS = {
# These integrations are set up unless recovery mode is activated.
#
@@ -232,22 +244,12 @@ DEFAULT_INTEGRATIONS_SUPERVISOR = {
# These integrations are set up if using the Supervisor
"hassio",
}
+
CRITICAL_INTEGRATIONS = {
# Recovery mode is activated if these integrations fail to set up
"frontend",
}
-SETUP_ORDER = (
- # Load logging and http deps as soon as possible
- ("logging, http deps", LOGGING_AND_HTTP_DEPS_INTEGRATIONS),
- # Setup frontend
- ("frontend", FRONTEND_INTEGRATIONS),
- # Setup recorder
- ("recorder", RECORDER_INTEGRATIONS),
- # Start up debuggers. Start these first in case they want to wait.
- ("debugger", DEBUGGER_INTEGRATIONS),
-)
-
#
# Storage keys we are likely to load during startup
# in order of when we expect to load them.
@@ -299,14 +301,6 @@ async def async_setup_hass(
return hass
- async def stop_hass(hass: core.HomeAssistant) -> None:
- """Stop hass."""
- # Ask integrations to shut down. It's messy but we can't
- # do a clean stop without knowing what is broken
- with contextlib.suppress(TimeoutError):
- async with hass.timeout.async_timeout(10):
- await hass.async_stop()
-
hass = await create_hass()
if runtime_config.skip_pip or runtime_config.skip_pip_packages:
@@ -322,10 +316,10 @@ async def async_setup_hass(
block_async_io.enable()
- config_dict = None
- basic_setup_success = False
-
if not (recovery_mode := runtime_config.recovery_mode):
+ config_dict = None
+ basic_setup_success = False
+
await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass)
try:
@@ -343,39 +337,43 @@ async def async_setup_hass(
await async_from_config_dict(config_dict, hass) is not None
)
- if config_dict is None:
- recovery_mode = True
- await stop_hass(hass)
- hass = await create_hass()
+ if config_dict is None:
+ recovery_mode = True
+ await hass.async_stop(force=True)
+ hass = await create_hass()
- elif not basic_setup_success:
- _LOGGER.warning("Unable to set up core integrations. Activating recovery mode")
- recovery_mode = True
- await stop_hass(hass)
- hass = await create_hass()
+ elif not basic_setup_success:
+ _LOGGER.warning(
+ "Unable to set up core integrations. Activating recovery mode"
+ )
+ recovery_mode = True
+ await hass.async_stop(force=True)
+ hass = await create_hass()
- elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS):
- _LOGGER.warning(
- "Detected that %s did not load. Activating recovery mode",
- ",".join(CRITICAL_INTEGRATIONS),
- )
+ elif any(
+ domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS
+ ):
+ _LOGGER.warning(
+ "Detected that %s did not load. Activating recovery mode",
+ ",".join(CRITICAL_INTEGRATIONS),
+ )
- old_config = hass.config
- old_logging = hass.data.get(DATA_LOGGING)
+ old_config = hass.config
+ old_logging = hass.data.get(DATA_LOGGING)
- recovery_mode = True
- await stop_hass(hass)
- hass = await create_hass()
+ recovery_mode = True
+ await hass.async_stop(force=True)
+ hass = await create_hass()
- if old_logging:
- hass.data[DATA_LOGGING] = old_logging
- hass.config.debug = old_config.debug
- hass.config.skip_pip = old_config.skip_pip
- hass.config.skip_pip_packages = old_config.skip_pip_packages
- hass.config.internal_url = old_config.internal_url
- hass.config.external_url = old_config.external_url
- # Setup loader cache after the config dir has been set
- loader.async_setup(hass)
+ if old_logging:
+ hass.data[DATA_LOGGING] = old_logging
+ hass.config.debug = old_config.debug
+ hass.config.skip_pip = old_config.skip_pip
+ hass.config.skip_pip_packages = old_config.skip_pip_packages
+ hass.config.internal_url = old_config.internal_url
+ hass.config.external_url = old_config.external_url
+ # Setup loader cache after the config dir has been set
+ loader.async_setup(hass)
if recovery_mode:
_LOGGER.info("Starting in recovery mode")
@@ -438,9 +436,10 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
if DATA_REGISTRIES_LOADED in hass.data:
return
hass.data[DATA_REGISTRIES_LOADED] = None
- translation.async_setup(hass)
entity.async_setup(hass)
+ frame.async_setup(hass)
template.async_setup(hass)
+ translation.async_setup(hass)
await asyncio.gather(
create_eager_task(get_internal_store_manager(hass).async_initialize()),
create_eager_task(area_registry.async_load(hass)),
@@ -661,11 +660,10 @@ def _create_log_file(
err_handler = _RotatingFileHandlerWithoutShouldRollOver(
err_log_path, backupCount=1
)
-
- try:
- err_handler.doRollover()
- except OSError as err:
- _LOGGER.error("Error rolling over log file: %s", err)
+ try:
+ err_handler.doRollover()
+ except OSError as err:
+ _LOGGER.error("Error rolling over log file: %s", err)
return err_handler
@@ -694,7 +692,6 @@ async def async_mount_local_lib_path(config_dir: str) -> str:
return deps_dir
-@core.callback
def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
"""Get domains of components to set up."""
# Filter out the repeating and common config section [homeassistant]
@@ -716,20 +713,25 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]:
return domains
-async def _async_resolve_domains_to_setup(
+async def _async_resolve_domains_and_preload(
hass: core.HomeAssistant, config: dict[str, Any]
-) -> tuple[set[str], dict[str, loader.Integration]]:
- """Resolve all dependencies and return list of domains to set up."""
+) -> tuple[dict[str, Integration], dict[str, Integration]]:
+ """Resolve all dependencies and return integrations to set up.
+
+ The return value is a tuple of two dictionaries:
+ - The first dictionary contains integrations
+ specified by the configuration (including config entries).
+ - The second dictionary contains the same integrations as the first dictionary
+ together with all their dependencies.
+ """
domains_to_setup = _get_domains(hass, config)
- needed_requirements: set[str] = set()
platform_integrations = conf_util.extract_platform_integrations(
config, BASE_PLATFORMS
)
- # Ensure base platforms that have platform integrations are added to
- # to `domains_to_setup so they can be setup first instead of
- # discovering them when later when a config entry setup task
- # notices its needed and there is already a long line to use
- # the import executor.
+ # Ensure base platforms that have platform integrations are added to `domains`,
+ # so they can be setup first instead of discovering them later when a config
+ # entry setup task notices that it's needed and there is already a long line
+ # to use the import executor.
#
# For example if we have
# sensor:
@@ -745,111 +747,78 @@ async def _async_resolve_domains_to_setup(
# so this will be less of a problem in the future.
domains_to_setup.update(platform_integrations)
- # Load manifests for base platforms and platform based integrations
- # that are defined under base platforms right away since we do not require
- # the manifest to list them as dependencies and we want to avoid the lock
- # contention when multiple integrations try to load them at once
- additional_manifests_to_load = {
+ # Additionally process base platforms since we do not require the manifest
+ # to list them as dependencies.
+ # We want to later avoid lock contention when multiple integrations try to load
+ # their manifests at once.
+ # Also process integrations that are defined under base platforms
+ # to speed things up.
+ additional_domains_to_process = {
*BASE_PLATFORMS,
*chain.from_iterable(platform_integrations.values()),
}
- translations_to_load = additional_manifests_to_load.copy()
-
# Resolve all dependencies so we know all integrations
# that will have to be loaded and start right-away
- integration_cache: dict[str, loader.Integration] = {}
- to_resolve: set[str] = domains_to_setup
- while to_resolve or additional_manifests_to_load:
- old_to_resolve: set[str] = to_resolve
- to_resolve = set()
+ integrations_or_excs = await loader.async_get_integrations(
+ hass, {*domains_to_setup, *additional_domains_to_process}
+ )
+ # Eliminate those missing or with invalid manifest
+ integrations_to_process = {
+ domain: itg
+ for domain, itg in integrations_or_excs.items()
+ if isinstance(itg, Integration)
+ }
+ integrations_dependencies = await loader.resolve_integrations_dependencies(
+ hass, integrations_to_process.values()
+ )
+ # Eliminate those without valid dependencies
+ integrations_to_process = {
+ domain: integrations_to_process[domain] for domain in integrations_dependencies
+ }
- if additional_manifests_to_load:
- to_get = {*old_to_resolve, *additional_manifests_to_load}
- additional_manifests_to_load.clear()
- else:
- to_get = old_to_resolve
+ integrations_to_setup = {
+ domain: itg
+ for domain, itg in integrations_to_process.items()
+ if domain in domains_to_setup
+ }
+ all_integrations_to_setup = integrations_to_setup.copy()
+ all_integrations_to_setup.update(
+ (dep, loader.async_get_loaded_integration(hass, dep))
+ for domain in integrations_to_setup
+ for dep in integrations_dependencies[domain].difference(
+ all_integrations_to_setup
+ )
+ )
- manifest_deps: set[str] = set()
- resolve_dependencies_tasks: list[asyncio.Task[bool]] = []
- integrations_to_process: list[loader.Integration] = []
-
- for domain, itg in (await loader.async_get_integrations(hass, to_get)).items():
- if not isinstance(itg, loader.Integration):
- continue
- integration_cache[domain] = itg
- needed_requirements.update(itg.requirements)
-
- # Make sure manifests for dependencies are loaded in the next
- # loop to try to group as many as manifest loads in a single
- # call to avoid the creating one-off executor jobs later in
- # the setup process
- additional_manifests_to_load.update(
- dep
- for dep in chain(itg.dependencies, itg.after_dependencies)
- if dep not in integration_cache
- )
-
- if domain not in old_to_resolve:
- continue
-
- integrations_to_process.append(itg)
- manifest_deps.update(itg.dependencies)
- manifest_deps.update(itg.after_dependencies)
- if not itg.all_dependencies_resolved:
- resolve_dependencies_tasks.append(
- create_eager_task(
- itg.resolve_dependencies(),
- name=f"resolve dependencies {domain}",
- loop=hass.loop,
- )
- )
-
- if unseen_deps := manifest_deps - integration_cache.keys():
- # If there are dependencies, try to preload all
- # the integrations manifest at once and add them
- # to the list of requirements we need to install
- # so we can try to check if they are already installed
- # in a single call below which avoids each integration
- # having to wait for the lock to do it individually
- deps = await loader.async_get_integrations(hass, unseen_deps)
- for dependant_domain, dependant_itg in deps.items():
- if isinstance(dependant_itg, loader.Integration):
- integration_cache[dependant_domain] = dependant_itg
- needed_requirements.update(dependant_itg.requirements)
-
- if resolve_dependencies_tasks:
- await asyncio.gather(*resolve_dependencies_tasks)
-
- for itg in integrations_to_process:
- try:
- all_deps = itg.all_dependencies
- except RuntimeError:
- # Integration.all_dependencies raises RuntimeError if
- # dependencies could not be resolved
- continue
- for dep in all_deps:
- if dep in domains_to_setup:
- continue
- domains_to_setup.add(dep)
- to_resolve.add(dep)
-
- _LOGGER.info("Domains to be set up: %s", domains_to_setup)
+ # Gather requirements for all integrations,
+ # their dependencies and after dependencies.
+ # To gather all the requirements we must ignore exceptions here.
+ # The exceptions will be detected and handled later in the bootstrap process.
+ integrations_after_dependencies = (
+ await loader.resolve_integrations_after_dependencies(
+ hass, integrations_to_process.values(), ignore_exceptions=True
+ )
+ )
+ integrations_requirements = {
+ domain: itg.requirements for domain, itg in integrations_to_process.items()
+ }
+ integrations_requirements.update(
+ (dep, loader.async_get_loaded_integration(hass, dep).requirements)
+ for deps in integrations_after_dependencies.values()
+ for dep in deps.difference(integrations_requirements)
+ )
+ all_requirements = set(chain.from_iterable(integrations_requirements.values()))
# Optimistically check if requirements are already installed
# ahead of setting up the integrations so we can prime the cache
- # We do not wait for this since its an optimization only
+ # We do not wait for this since it's an optimization only
hass.async_create_background_task(
- requirements.async_load_installed_versions(hass, needed_requirements),
+ requirements.async_load_installed_versions(hass, all_requirements),
"check installed requirements",
eager_start=True,
)
- #
- # Only add the domains_to_setup after we finish resolving
- # as new domains are likely to added in the process
- #
- translations_to_load.update(domains_to_setup)
# Start loading translations for all integrations we are going to set up
# in the background so they are ready when we need them. This avoids a
# lot of waiting for the translation load lock and a thundering herd of
@@ -860,6 +829,7 @@ async def _async_resolve_domains_to_setup(
# hold the translation load lock and if anything is fast enough to
# wait for the translation load lock, loading will be done by the
# time it gets to it.
+ translations_to_load = {*all_integrations_to_setup, *additional_domains_to_process}
hass.async_create_background_task(
translation.async_load_integrations(hass, translations_to_load),
"load translations",
@@ -871,13 +841,13 @@ async def _async_resolve_domains_to_setup(
# in the setup process.
hass.async_create_background_task(
get_internal_store_manager(hass).async_preload(
- [*PRELOAD_STORAGE, *domains_to_setup]
+ [*PRELOAD_STORAGE, *all_integrations_to_setup]
),
"preload storage",
eager_start=True,
)
- return domains_to_setup, integration_cache
+ return integrations_to_setup, all_integrations_to_setup
async def _async_set_up_integrations(
@@ -887,86 +857,84 @@ async def _async_set_up_integrations(
watcher = _WatchPendingSetups(hass, _setup_started(hass))
watcher.async_start()
- domains_to_setup, integration_cache = await _async_resolve_domains_to_setup(
+ integrations, all_integrations = await _async_resolve_domains_and_preload(
hass, config
)
+ # Detect all cycles
+ integrations_after_dependencies = (
+ await loader.resolve_integrations_after_dependencies(
+ hass, all_integrations.values(), set(all_integrations)
+ )
+ )
+ all_domains = set(integrations_after_dependencies)
+ domains = set(integrations) & all_domains
+
+ _LOGGER.info(
+ "Domains to be set up: %s | %s",
+ domains,
+ all_domains - domains,
+ )
+
+ async_set_domains_to_be_loaded(hass, all_domains)
# Initialize recorder
- if "recorder" in domains_to_setup:
+ if "recorder" in all_domains:
recorder.async_initialize_recorder(hass)
- pre_stage_domains = [
- (name, domains_to_setup & domain_group) for name, domain_group in SETUP_ORDER
+ # Initialize backup
+ if "backup" in all_domains:
+ backup.async_initialize_backup(hass)
+
+ stages: list[tuple[str, set[str], int | None]] = [
+ *(
+ (name, domain_group, timeout)
+ for name, domain_group, timeout in STAGE_0_INTEGRATIONS
+ ),
+ ("1", STAGE_1_INTEGRATIONS, STAGE_1_TIMEOUT),
+ ("2", domains, STAGE_2_TIMEOUT),
]
- # calculate what components to setup in what stage
- stage_1_domains: set[str] = set()
+ _LOGGER.info("Setting up stage 0")
+ for name, domain_group, timeout in stages:
+ stage_domains_unfiltered = domain_group & all_domains
+ if not stage_domains_unfiltered:
+ _LOGGER.info("Nothing to set up in stage %s: %s", name, domain_group)
+ continue
- # Find all dependencies of any dependency of any stage 1 integration that
- # we plan on loading and promote them to stage 1. This is done only to not
- # get misleading log messages
- deps_promotion: set[str] = STAGE_1_INTEGRATIONS
- while deps_promotion:
- old_deps_promotion = deps_promotion
- deps_promotion = set()
+ stage_domains = stage_domains_unfiltered - hass.config.components
+ if not stage_domains:
+ _LOGGER.info("Already set up stage %s: %s", name, stage_domains_unfiltered)
+ continue
- for domain in old_deps_promotion:
- if domain not in domains_to_setup or domain in stage_1_domains:
- continue
+ stage_dep_domains_unfiltered = {
+ dep
+ for domain in stage_domains
+ for dep in integrations_after_dependencies[domain]
+ if dep not in stage_domains
+ }
+ stage_dep_domains = stage_dep_domains_unfiltered - hass.config.components
- stage_1_domains.add(domain)
+ stage_all_domains = stage_domains | stage_dep_domains
- if (dep_itg := integration_cache.get(domain)) is None:
- continue
+ _LOGGER.info(
+ "Setting up stage %s: %s | %s\nDependencies: %s | %s",
+ name,
+ stage_domains,
+ stage_domains_unfiltered - stage_domains,
+ stage_dep_domains,
+ stage_dep_domains_unfiltered - stage_dep_domains,
+ )
- deps_promotion.update(dep_itg.all_dependencies)
-
- stage_2_domains = domains_to_setup - stage_1_domains
-
- for name, domain_group in pre_stage_domains:
- if domain_group:
- stage_2_domains -= domain_group
- _LOGGER.info("Setting up %s: %s", name, domain_group)
- to_be_loaded = domain_group.copy()
- to_be_loaded.update(
- dep
- for domain in domain_group
- if (integration := integration_cache.get(domain)) is not None
- for dep in integration.all_dependencies
- )
- async_set_domains_to_be_loaded(hass, to_be_loaded)
- await _async_setup_multi_components(hass, domain_group, config)
-
- # Enables after dependencies when setting up stage 1 domains
- async_set_domains_to_be_loaded(hass, stage_1_domains)
-
- # Start setup
- if stage_1_domains:
- _LOGGER.info("Setting up stage 1: %s", stage_1_domains)
+ if timeout is None:
+ await _async_setup_multi_components(hass, stage_all_domains, config)
+ continue
try:
- async with hass.timeout.async_timeout(
- STAGE_1_TIMEOUT, cool_down=COOLDOWN_TIME
- ):
- await _async_setup_multi_components(hass, stage_1_domains, config)
+ async with hass.timeout.async_timeout(timeout, cool_down=COOLDOWN_TIME):
+ await _async_setup_multi_components(hass, stage_all_domains, config)
except TimeoutError:
_LOGGER.warning(
- "Setup timed out for stage 1 waiting on %s - moving forward",
- hass._active_tasks, # noqa: SLF001
- )
-
- # Add after dependencies when setting up stage 2 domains
- async_set_domains_to_be_loaded(hass, stage_2_domains)
-
- if stage_2_domains:
- _LOGGER.info("Setting up stage 2: %s", stage_2_domains)
- try:
- async with hass.timeout.async_timeout(
- STAGE_2_TIMEOUT, cool_down=COOLDOWN_TIME
- ):
- await _async_setup_multi_components(hass, stage_2_domains, config)
- except TimeoutError:
- _LOGGER.warning(
- "Setup timed out for stage 2 waiting on %s - moving forward",
+ "Setup timed out for stage %s waiting on %s - moving forward",
+ name,
hass._active_tasks, # noqa: SLF001
)
@@ -1068,8 +1036,6 @@ async def _async_setup_multi_components(
config: dict[str, Any],
) -> None:
"""Set up multiple domains. Log on failure."""
- # Avoid creating tasks for domains that were setup in a previous stage
- domains_not_yet_setup = domains - hass.config.components
# Create setup tasks for base platforms first since everything will have
# to wait to be imported, and the sooner we can get the base platforms
# loaded the sooner we can start loading the rest of the integrations.
@@ -1079,9 +1045,7 @@ async def _async_setup_multi_components(
f"setup component {domain}",
eager_start=True,
)
- for domain in sorted(
- domains_not_yet_setup, key=SETUP_ORDER_SORT_KEY, reverse=True
- )
+ for domain in sorted(domains, key=SETUP_ORDER_SORT_KEY, reverse=True)
}
results = await asyncio.gather(*futures.values(), return_exceptions=True)
for idx, domain in enumerate(futures):
diff --git a/homeassistant/brands/bosch.json b/homeassistant/brands/bosch.json
new file mode 100644
index 00000000000..090cc2af7c3
--- /dev/null
+++ b/homeassistant/brands/bosch.json
@@ -0,0 +1,5 @@
+{
+ "domain": "bosch",
+ "name": "Bosch",
+ "integrations": ["bosch_alarm", "bosch_shc", "home_connect"]
+}
diff --git a/homeassistant/brands/eve.json b/homeassistant/brands/eve.json
new file mode 100644
index 00000000000..f27c8b3d849
--- /dev/null
+++ b/homeassistant/brands/eve.json
@@ -0,0 +1,5 @@
+{
+ "domain": "eve",
+ "name": "Eve",
+ "iot_standards": ["matter"]
+}
diff --git a/homeassistant/brands/microsoft.json b/homeassistant/brands/microsoft.json
index 0e00c4a7bc3..918f67f06dd 100644
--- a/homeassistant/brands/microsoft.json
+++ b/homeassistant/brands/microsoft.json
@@ -6,6 +6,7 @@
"azure_devops",
"azure_event_hub",
"azure_service_bus",
+ "azure_storage",
"microsoft_face_detect",
"microsoft_face_identify",
"microsoft_face",
diff --git a/homeassistant/brands/motionblinds.json b/homeassistant/brands/motionblinds.json
index 67013e75966..5a48b573b4d 100644
--- a/homeassistant/brands/motionblinds.json
+++ b/homeassistant/brands/motionblinds.json
@@ -1,5 +1,6 @@
{
"domain": "motionblinds",
"name": "Motionblinds",
- "integrations": ["motion_blinds", "motionblinds_ble"]
+ "integrations": ["motion_blinds", "motionblinds_ble"],
+ "iot_standards": ["matter"]
}
diff --git a/homeassistant/brands/sensorpush.json b/homeassistant/brands/sensorpush.json
new file mode 100644
index 00000000000..b7e528948f8
--- /dev/null
+++ b/homeassistant/brands/sensorpush.json
@@ -0,0 +1,5 @@
+{
+ "domain": "sensorpush",
+ "name": "SensorPush",
+ "integrations": ["sensorpush", "sensorpush_cloud"]
+}
diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py
index 4ec59ca4c39..554cf932fca 100644
--- a/homeassistant/components/abode/alarm_control_panel.py
+++ b/homeassistant/components/abode/alarm_control_panel.py
@@ -11,7 +11,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeSystem
from .const import DOMAIN
@@ -19,7 +19,9 @@ from .entity import AbodeDevice
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode alarm control panel device."""
data: AbodeSystem = hass.data[DOMAIN]
diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py
index ca9679a5aaa..6f64fa46c0a 100644
--- a/homeassistant/components/abode/binary_sensor.py
+++ b/homeassistant/components/abode/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from . import AbodeSystem
@@ -21,7 +21,9 @@ from .entity import AbodeDevice
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode binary sensor devices."""
data: AbodeSystem = hass.data[DOMAIN]
diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py
index 58107f16462..3587c8c1799 100644
--- a/homeassistant/components/abode/camera.py
+++ b/homeassistant/components/abode/camera.py
@@ -15,7 +15,7 @@ from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from . import AbodeSystem
@@ -26,7 +26,9 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode camera devices."""
data: AbodeSystem = hass.data[DOMAIN]
diff --git a/homeassistant/components/abode/cover.py b/homeassistant/components/abode/cover.py
index b5b1e878b96..bc9df9d4a25 100644
--- a/homeassistant/components/abode/cover.py
+++ b/homeassistant/components/abode/cover.py
@@ -7,7 +7,7 @@ from jaraco.abode.devices.cover import Cover
from homeassistant.components.cover import CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeSystem
from .const import DOMAIN
@@ -15,7 +15,9 @@ from .entity import AbodeDevice
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode cover devices."""
data: AbodeSystem = hass.data[DOMAIN]
diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py
index e2d0a331f0a..9614e84ebad 100644
--- a/homeassistant/components/abode/light.py
+++ b/homeassistant/components/abode/light.py
@@ -18,7 +18,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeSystem
from .const import DOMAIN
@@ -26,7 +26,9 @@ from .entity import AbodeDevice
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode light devices."""
data: AbodeSystem = hass.data[DOMAIN]
diff --git a/homeassistant/components/abode/lock.py b/homeassistant/components/abode/lock.py
index ceff263e6b5..94832ed41f9 100644
--- a/homeassistant/components/abode/lock.py
+++ b/homeassistant/components/abode/lock.py
@@ -7,7 +7,7 @@ from jaraco.abode.devices.lock import Lock
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeSystem
from .const import DOMAIN
@@ -15,7 +15,9 @@ from .entity import AbodeDevice
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode lock devices."""
data: AbodeSystem = hass.data[DOMAIN]
diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py
index d6a5389029b..ee168c16509 100644
--- a/homeassistant/components/abode/sensor.py
+++ b/homeassistant/components/abode/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeSystem
from .const import DOMAIN
@@ -61,7 +61,9 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode sensor devices."""
data: AbodeSystem = hass.data[DOMAIN]
diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py
index 7dad750c8d5..f6018d99fc8 100644
--- a/homeassistant/components/abode/switch.py
+++ b/homeassistant/components/abode/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeSystem
from .const import DOMAIN
@@ -20,7 +20,9 @@ DEVICE_TYPES = ["switch", "valve"]
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode switch devices."""
data: AbodeSystem = hass.data[DOMAIN]
diff --git a/homeassistant/components/acaia/binary_sensor.py b/homeassistant/components/acaia/binary_sensor.py
index ecb7ac06eb5..d720488faa0 100644
--- a/homeassistant/components/acaia/binary_sensor.py
+++ b/homeassistant/components/acaia/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
@@ -40,7 +40,7 @@ BINARY_SENSORS: tuple[AcaiaBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AcaiaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors."""
diff --git a/homeassistant/components/acaia/button.py b/homeassistant/components/acaia/button.py
index a41233bfc17..446f6134789 100644
--- a/homeassistant/components/acaia/button.py
+++ b/homeassistant/components/acaia/button.py
@@ -8,7 +8,7 @@ from aioacaia.acaiascale import AcaiaScale
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
@@ -45,7 +45,7 @@ BUTTONS: tuple[AcaiaButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AcaiaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button entities and services."""
diff --git a/homeassistant/components/acaia/sensor.py b/homeassistant/components/acaia/sensor.py
index 7ba44958eca..f62b93ddf1d 100644
--- a/homeassistant/components/acaia/sensor.py
+++ b/homeassistant/components/acaia/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfMass, UnitOfVolumeFlowRate
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
@@ -77,7 +77,7 @@ RESTORE_SENSORS: tuple[AcaiaSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AcaiaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors."""
diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py
index 1bbf5a36187..7216f5a0b9b 100644
--- a/homeassistant/components/accuweather/const.py
+++ b/homeassistant/components/accuweather/const.py
@@ -24,7 +24,7 @@ from homeassistant.components.weather import (
API_METRIC: Final = "Metric"
ATTRIBUTION: Final = "Data provided by AccuWeather"
-ATTR_CATEGORY: Final = "Category"
+ATTR_CATEGORY_VALUE = "CategoryValue"
ATTR_DIRECTION: Final = "Direction"
ATTR_ENGLISH: Final = "English"
ATTR_LEVEL: Final = "level"
@@ -55,5 +55,18 @@ CONDITION_MAP = {
for cond_ha, cond_codes in CONDITION_CLASSES.items()
for cond_code in cond_codes
}
+AIR_QUALITY_CATEGORY_MAP = {
+ 1: "good",
+ 2: "moderate",
+ 3: "unhealthy",
+ 4: "very_unhealthy",
+ 5: "hazardous",
+}
+POLLEN_CATEGORY_MAP = {
+ 1: "low",
+ 2: "moderate",
+ 3: "high",
+ 4: "very_high",
+}
UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40)
UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6)
diff --git a/homeassistant/components/accuweather/coordinator.py b/homeassistant/components/accuweather/coordinator.py
index 40ff3ad2c87..780c977f930 100644
--- a/homeassistant/components/accuweather/coordinator.py
+++ b/homeassistant/components/accuweather/coordinator.py
@@ -75,7 +75,11 @@ class AccuWeatherObservationDataUpdateCoordinator(
async with timeout(10):
result = await self.accuweather.async_get_current_conditions()
except EXCEPTIONS as error:
- raise UpdateFailed(error) from error
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="current_conditions_update_error",
+ translation_placeholders={"error": repr(error)},
+ ) from error
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
@@ -117,9 +121,15 @@ class AccuWeatherDailyForecastDataUpdateCoordinator(
"""Update data via library."""
try:
async with timeout(10):
- result = await self.accuweather.async_get_daily_forecast()
+ result = await self.accuweather.async_get_daily_forecast(
+ language=self.hass.config.language
+ )
except EXCEPTIONS as error:
- raise UpdateFailed(error) from error
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="forecast_update_error",
+ translation_placeholders={"error": repr(error)},
+ ) from error
_LOGGER.debug("Requests remaining: %d", self.accuweather.requests_remaining)
diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json
index 75f4a265b5f..810557519eb 100644
--- a/homeassistant/components/accuweather/manifest.json
+++ b/homeassistant/components/accuweather/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["accuweather"],
- "requirements": ["accuweather==4.0.0"],
+ "requirements": ["accuweather==4.2.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py
index 001edc5f197..415df402d55 100644
--- a/homeassistant/components/accuweather/sensor.py
+++ b/homeassistant/components/accuweather/sensor.py
@@ -25,12 +25,13 @@ from homeassistant.const import (
UnitOfVolumetricFlux,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
+ AIR_QUALITY_CATEGORY_MAP,
API_METRIC,
- ATTR_CATEGORY,
+ ATTR_CATEGORY_VALUE,
ATTR_DIRECTION,
ATTR_ENGLISH,
ATTR_LEVEL,
@@ -38,6 +39,7 @@ from .const import (
ATTR_VALUE,
ATTRIBUTION,
MAX_FORECAST_DAYS,
+ POLLEN_CATEGORY_MAP,
)
from .coordinator import (
AccuWeatherConfigEntry,
@@ -59,9 +61,9 @@ class AccuWeatherSensorDescription(SensorEntityDescription):
FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
AccuWeatherSensorDescription(
key="AirQuality",
- value_fn=lambda data: cast(str, data[ATTR_CATEGORY]),
+ value_fn=lambda data: AIR_QUALITY_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]],
device_class=SensorDeviceClass.ENUM,
- options=["good", "hazardous", "high", "low", "moderate", "unhealthy"],
+ options=list(AIR_QUALITY_CATEGORY_MAP.values()),
translation_key="air_quality",
),
AccuWeatherSensorDescription(
@@ -83,7 +85,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
- attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
+ attr_fn=lambda data: {
+ ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
+ },
translation_key="grass_pollen",
),
AccuWeatherSensorDescription(
@@ -107,7 +111,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
entity_registry_enabled_default=False,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
- attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
+ attr_fn=lambda data: {
+ ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
+ },
translation_key="mold_pollen",
),
AccuWeatherSensorDescription(
@@ -115,7 +121,9 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
- attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
+ attr_fn=lambda data: {
+ ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
+ },
translation_key="ragweed_pollen",
),
AccuWeatherSensorDescription(
@@ -181,14 +189,18 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
native_unit_of_measurement=CONCENTRATION_PARTS_PER_CUBIC_METER,
entity_registry_enabled_default=False,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
- attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
+ attr_fn=lambda data: {
+ ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
+ },
translation_key="tree_pollen",
),
AccuWeatherSensorDescription(
key="UVIndex",
native_unit_of_measurement=UV_INDEX,
value_fn=lambda data: cast(int, data[ATTR_VALUE]),
- attr_fn=lambda data: {ATTR_LEVEL: data[ATTR_CATEGORY]},
+ attr_fn=lambda data: {
+ ATTR_LEVEL: POLLEN_CATEGORY_MAP[data[ATTR_CATEGORY_VALUE]]
+ },
translation_key="uv_index_forecast",
),
AccuWeatherSensorDescription(
@@ -375,7 +387,7 @@ SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AccuWeatherConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add AccuWeather entities from a config_entry."""
observation_coordinator: AccuWeatherObservationDataUpdateCoordinator = (
diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json
index d0250a382e9..e81ef782d98 100644
--- a/homeassistant/components/accuweather/strings.json
+++ b/homeassistant/components/accuweather/strings.json
@@ -26,10 +26,20 @@
"state": {
"good": "Good",
"hazardous": "Hazardous",
- "high": "High",
- "low": "Low",
"moderate": "Moderate",
- "unhealthy": "Unhealthy"
+ "unhealthy": "Unhealthy",
+ "very_unhealthy": "Very unhealthy"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
+ "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
+ "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
+ "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]",
+ "very_unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::very_unhealthy%]"
+ }
+ }
}
},
"apparent_temperature": {
@@ -62,12 +72,10 @@
"level": {
"name": "Level",
"state": {
- "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
- "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
- "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
- "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
- "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
- "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "moderate": "Moderate",
+ "very_high": "[%key:common::state::very_high%]"
}
}
}
@@ -81,12 +89,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
- "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
- "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
- "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
- "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
- "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
- "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
+ "very_high": "[%key:common::state::very_high%]"
}
}
}
@@ -100,6 +106,15 @@
"steady": "Steady",
"rising": "Rising",
"falling": "Falling"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "falling": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::falling%]",
+ "rising": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::rising%]",
+ "steady": "[%key:component::accuweather::entity::sensor::pressure_tendency::state::steady%]"
+ }
+ }
}
},
"ragweed_pollen": {
@@ -108,12 +123,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
- "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
- "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
- "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
- "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
- "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
- "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
+ "very_high": "[%key:common::state::very_high%]"
}
}
}
@@ -154,12 +167,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
- "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
- "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
- "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
- "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
- "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
- "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
+ "very_high": "[%key:common::state::very_high%]"
}
}
}
@@ -170,12 +181,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
- "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
- "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
- "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
- "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
- "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
- "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
+ "very_high": "[%key:common::state::very_high%]"
}
}
}
@@ -186,12 +195,10 @@
"level": {
"name": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::name%]",
"state": {
- "good": "[%key:component::accuweather::entity::sensor::air_quality::state::good%]",
- "hazardous": "[%key:component::accuweather::entity::sensor::air_quality::state::hazardous%]",
- "high": "[%key:component::accuweather::entity::sensor::air_quality::state::high%]",
- "low": "[%key:component::accuweather::entity::sensor::air_quality::state::low%]",
- "moderate": "[%key:component::accuweather::entity::sensor::air_quality::state::moderate%]",
- "unhealthy": "[%key:component::accuweather::entity::sensor::air_quality::state::unhealthy%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "moderate": "[%key:component::accuweather::entity::sensor::grass_pollen::state_attributes::level::state::moderate%]",
+ "very_high": "[%key:common::state::very_high%]"
}
}
}
@@ -222,6 +229,14 @@
}
}
},
+ "exceptions": {
+ "current_conditions_update_error": {
+ "message": "An error occurred while retrieving weather current conditions data from the AccuWeather API: {error}"
+ },
+ "forecast_update_error": {
+ "message": "An error occurred while retrieving weather forecast data from the AccuWeather API: {error}"
+ }
+ },
"system_health": {
"info": {
"can_reach_server": "Reach AccuWeather server",
diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py
index 7d754278d91..770f2b64f20 100644
--- a/homeassistant/components/accuweather/weather.py
+++ b/homeassistant/components/accuweather/weather.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utc_from_timestamp
from .const import (
@@ -54,7 +54,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: AccuWeatherConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a AccuWeather weather entity from a config_entry."""
async_add_entities([AccuWeatherEntity(entry.runtime_data)])
diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py
index 77099e86adc..d09ba4bac08 100644
--- a/homeassistant/components/acmeda/cover.py
+++ b/homeassistant/components/acmeda/cover.py
@@ -11,7 +11,7 @@ from homeassistant.components.cover import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AcmedaConfigEntry
from .const import ACMEDA_HUB_UPDATE
@@ -22,7 +22,7 @@ from .helpers import async_add_acmeda_entities
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AcmedaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Acmeda Rollers from a config entry."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/acmeda/helpers.py b/homeassistant/components/acmeda/helpers.py
index 52af7d586de..4c0f9b32cff 100644
--- a/homeassistant/components/acmeda/helpers.py
+++ b/homeassistant/components/acmeda/helpers.py
@@ -9,7 +9,7 @@ from aiopulse import Roller
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LOGGER
@@ -23,7 +23,7 @@ def async_add_acmeda_entities(
entity_class: type,
config_entry: AcmedaConfigEntry,
current: set[int],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add any new entities."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/acmeda/sensor.py b/homeassistant/components/acmeda/sensor.py
index f5df1bf013d..515146f3d1a 100644
--- a/homeassistant/components/acmeda/sensor.py
+++ b/homeassistant/components/acmeda/sensor.py
@@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AcmedaConfigEntry
from .const import ACMEDA_HUB_UPDATE
@@ -17,7 +17,7 @@ from .helpers import async_add_acmeda_entities
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AcmedaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Acmeda Rollers from a config entry."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/adax/climate.py b/homeassistant/components/adax/climate.py
index 15022ba3c9f..078640cd367 100644
--- a/homeassistant/components/adax/climate.py
+++ b/homeassistant/components/adax/climate.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL
@@ -33,7 +33,7 @@ from .const import ACCOUNT_ID, CONNECTION_TYPE, DOMAIN, LOCAL
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Adax thermostat with config flow."""
if entry.data.get(CONNECTION_TYPE) == LOCAL:
diff --git a/homeassistant/components/adax/strings.json b/homeassistant/components/adax/strings.json
index 6157b7dfc91..9ba497a9aca 100644
--- a/homeassistant/components/adax/strings.json
+++ b/homeassistant/components/adax/strings.json
@@ -5,14 +5,14 @@
"data": {
"connection_type": "Select connection type"
},
- "description": "Select connection type. Local requires heaters with bluetooth"
+ "description": "Select connection type. Local requires heaters with Bluetooth"
},
"local": {
"data": {
"wifi_ssid": "Wi-Fi SSID",
- "wifi_pswd": "Wi-Fi Password"
+ "wifi_pswd": "Wi-Fi password"
},
- "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue led starts blinking before pressing Submit. Configuring heater might take some minutes."
+ "description": "Reset the heater by pressing + and OK until display shows 'Reset'. Then press and hold OK button on the heater until the blue LED starts blinking before pressing Submit. Configuring heater might take some minutes."
},
"cloud": {
"data": {
diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py
index f8ddeba6767..bbc763d7ec3 100644
--- a/homeassistant/components/adguard/__init__.py
+++ b/homeassistant/components/adguard/__init__.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from adguardhome import AdGuardHome, AdGuardHomeConnectionError
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
@@ -123,12 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool:
"""Unload AdGuard Home config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# This is the last loaded instance of AdGuard, deregister any services
hass.services.async_remove(DOMAIN, SERVICE_ADD_URL)
hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL)
diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py
index b2404a88278..f1af8ac32a4 100644
--- a/homeassistant/components/adguard/sensor.py
+++ b/homeassistant/components/adguard/sensor.py
@@ -12,7 +12,7 @@ from adguardhome import AdGuardHome
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.const import PERCENTAGE, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdGuardConfigEntry, AdGuardData
from .const import DOMAIN
@@ -85,7 +85,7 @@ SENSORS: tuple[AdGuardHomeEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AdGuardConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdGuard Home sensor based on a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py
index 3ea4f9d1d93..5128102a955 100644
--- a/homeassistant/components/adguard/switch.py
+++ b/homeassistant/components/adguard/switch.py
@@ -11,7 +11,7 @@ from adguardhome import AdGuardHome, AdGuardHomeError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdGuardConfigEntry, AdGuardData
from .const import DOMAIN, LOGGER
@@ -79,7 +79,7 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AdGuardConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdGuard Home switch based on a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/advantage_air/binary_sensor.py b/homeassistant/components/advantage_air/binary_sensor.py
index 601b10aeb4a..dd306b82c8a 100644
--- a/homeassistant/components/advantage_air/binary_sensor.py
+++ b/homeassistant/components/advantage_air/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .entity import AdvantageAirAcEntity, AdvantageAirZoneEntity
@@ -20,7 +20,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AdvantageAirDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdvantageAir Binary Sensor platform."""
diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py
index d07a3182ed7..1d593c5c3c8 100644
--- a/homeassistant/components/advantage_air/climate.py
+++ b/homeassistant/components/advantage_air/climate.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from decimal import Decimal
import logging
from typing import Any
@@ -14,12 +15,13 @@ from homeassistant.components.climate import (
FAN_MEDIUM,
ClimateEntity,
ClimateEntityFeature,
+ HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .const import (
@@ -49,6 +51,14 @@ ADVANTAGE_AIR_MYTEMP_ENABLED = "climateControlModeEnabled"
ADVANTAGE_AIR_HEAT_TARGET = "myAutoHeatTargetTemp"
ADVANTAGE_AIR_COOL_TARGET = "myAutoCoolTargetTemp"
ADVANTAGE_AIR_MYFAN = "autoAA"
+ADVANTAGE_AIR_MYAUTO_MODE_SET = "myAutoModeCurrentSetMode"
+
+HVAC_ACTIONS = {
+ "cool": HVACAction.COOLING,
+ "heat": HVACAction.HEATING,
+ "vent": HVACAction.FAN,
+ "dry": HVACAction.DRYING,
+}
HVAC_MODES = [
HVACMode.OFF,
@@ -76,7 +86,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AdvantageAirDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdvantageAir climate platform."""
@@ -175,6 +185,17 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity):
return ADVANTAGE_AIR_HVAC_MODES.get(self._ac["mode"])
return HVACMode.OFF
+ @property
+ def hvac_action(self) -> HVACAction | None:
+ """Return the current running HVAC action."""
+ if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF:
+ return HVACAction.OFF
+ if self._ac["mode"] == "myauto":
+ return HVAC_ACTIONS.get(
+ self._ac.get(ADVANTAGE_AIR_MYAUTO_MODE_SET, HVACAction.OFF)
+ )
+ return HVAC_ACTIONS.get(self._ac["mode"])
+
@property
def fan_mode(self) -> str | None:
"""Return the current fan modes."""
@@ -273,6 +294,22 @@ class AdvantageAirZone(AdvantageAirZoneEntity, ClimateEntity):
return HVACMode.HEAT_COOL
return HVACMode.OFF
+ @property
+ def hvac_action(self) -> HVACAction | None:
+ """Return the HVAC action, inheriting from master AC if zone is open but idle if air is <= 5%."""
+ if self._ac["state"] == ADVANTAGE_AIR_STATE_OFF:
+ return HVACAction.OFF
+ master_action = HVAC_ACTIONS.get(self._ac["mode"], HVACAction.OFF)
+ if self._ac["mode"] == "myauto":
+ master_action = HVAC_ACTIONS.get(
+ str(self._ac.get(ADVANTAGE_AIR_MYAUTO_MODE_SET)), HVACAction.OFF
+ )
+ if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN:
+ if self._zone["value"] <= Decimal(5):
+ return HVACAction.IDLE
+ return master_action
+ return HVACAction.OFF
+
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
diff --git a/homeassistant/components/advantage_air/const.py b/homeassistant/components/advantage_air/const.py
index 6ae0a0e06d5..103ca57f6ef 100644
--- a/homeassistant/components/advantage_air/const.py
+++ b/homeassistant/components/advantage_air/const.py
@@ -7,3 +7,4 @@ ADVANTAGE_AIR_STATE_CLOSE = "close"
ADVANTAGE_AIR_STATE_ON = "on"
ADVANTAGE_AIR_STATE_OFF = "off"
ADVANTAGE_AIR_AUTOFAN_ENABLED = "aaAutoFanModeEnabled"
+ADVANTAGE_AIR_NIGHT_MODE_ENABLED = "quietNightModeEnabled"
diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py
index b091f0077a1..e764d484128 100644
--- a/homeassistant/components/advantage_air/cover.py
+++ b/homeassistant/components/advantage_air/cover.py
@@ -9,7 +9,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .const import ADVANTAGE_AIR_STATE_CLOSE, ADVANTAGE_AIR_STATE_OPEN
@@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AdvantageAirDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdvantageAir cover platform."""
@@ -41,7 +41,7 @@ async def async_setup_entry(
entities.append(
AdvantageAirThingCover(instance, thing, CoverDeviceClass.BLIND)
)
- elif thing["channelDipState"] == 3: # 3 = "Garage door"
+ elif thing["channelDipState"] in [3, 10]: # 3 & 10 = "Garage door"
entities.append(
AdvantageAirThingCover(instance, thing, CoverDeviceClass.GARAGE)
)
diff --git a/homeassistant/components/advantage_air/light.py b/homeassistant/components/advantage_air/light.py
index 7dd0a0a183b..ffd502663b0 100644
--- a/homeassistant/components/advantage_air/light.py
+++ b/homeassistant/components/advantage_air/light.py
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .const import ADVANTAGE_AIR_STATE_ON, DOMAIN as ADVANTAGE_AIR_DOMAIN
@@ -16,7 +16,7 @@ from .models import AdvantageAirData
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AdvantageAirDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdvantageAir light platform."""
diff --git a/homeassistant/components/advantage_air/select.py b/homeassistant/components/advantage_air/select.py
index 84c37f38d7f..320bfd35aba 100644
--- a/homeassistant/components/advantage_air/select.py
+++ b/homeassistant/components/advantage_air/select.py
@@ -2,7 +2,7 @@
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .entity import AdvantageAirAcEntity
@@ -14,7 +14,7 @@ ADVANTAGE_AIR_INACTIVE = "Inactive"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AdvantageAirDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdvantageAir select platform."""
diff --git a/homeassistant/components/advantage_air/sensor.py b/homeassistant/components/advantage_air/sensor.py
index ab1a1c4f9a0..2abcc3b5a68 100644
--- a/homeassistant/components/advantage_air/sensor.py
+++ b/homeassistant/components/advantage_air/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .const import ADVANTAGE_AIR_STATE_OPEN
@@ -32,7 +32,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AdvantageAirDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdvantageAir sensor platform."""
diff --git a/homeassistant/components/advantage_air/switch.py b/homeassistant/components/advantage_air/switch.py
index 876875a2510..8560c9a9138 100644
--- a/homeassistant/components/advantage_air/switch.py
+++ b/homeassistant/components/advantage_air/switch.py
@@ -4,11 +4,12 @@ from typing import Any
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .const import (
ADVANTAGE_AIR_AUTOFAN_ENABLED,
+ ADVANTAGE_AIR_NIGHT_MODE_ENABLED,
ADVANTAGE_AIR_STATE_OFF,
ADVANTAGE_AIR_STATE_ON,
)
@@ -19,7 +20,7 @@ from .models import AdvantageAirData
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AdvantageAirDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdvantageAir switch platform."""
@@ -32,6 +33,8 @@ async def async_setup_entry(
entities.append(AdvantageAirFreshAir(instance, ac_key))
if ADVANTAGE_AIR_AUTOFAN_ENABLED in ac_device["info"]:
entities.append(AdvantageAirMyFan(instance, ac_key))
+ if ADVANTAGE_AIR_NIGHT_MODE_ENABLED in ac_device["info"]:
+ entities.append(AdvantageAirNightMode(instance, ac_key))
if things := instance.coordinator.data.get("myThings"):
entities.extend(
AdvantageAirRelay(instance, thing)
@@ -93,6 +96,32 @@ class AdvantageAirMyFan(AdvantageAirAcEntity, SwitchEntity):
await self.async_update_ac({ADVANTAGE_AIR_AUTOFAN_ENABLED: False})
+class AdvantageAirNightMode(AdvantageAirAcEntity, SwitchEntity):
+ """Representation of Advantage 'MySleep$aver' Mode control."""
+
+ _attr_icon = "mdi:weather-night"
+ _attr_name = "MySleep$aver"
+ _attr_device_class = SwitchDeviceClass.SWITCH
+
+ def __init__(self, instance: AdvantageAirData, ac_key: str) -> None:
+ """Initialize an Advantage Air Night Mode control."""
+ super().__init__(instance, ac_key)
+ self._attr_unique_id += "-nightmode"
+
+ @property
+ def is_on(self) -> bool:
+ """Return the Night Mode status."""
+ return self._ac[ADVANTAGE_AIR_NIGHT_MODE_ENABLED]
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn Night Mode on."""
+ await self.async_update_ac({ADVANTAGE_AIR_NIGHT_MODE_ENABLED: True})
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn Night Mode off."""
+ await self.async_update_ac({ADVANTAGE_AIR_NIGHT_MODE_ENABLED: False})
+
+
class AdvantageAirRelay(AdvantageAirThingEntity, SwitchEntity):
"""Representation of Advantage Air Thing."""
diff --git a/homeassistant/components/advantage_air/update.py b/homeassistant/components/advantage_air/update.py
index b639e4df867..92a162303dd 100644
--- a/homeassistant/components/advantage_air/update.py
+++ b/homeassistant/components/advantage_air/update.py
@@ -3,7 +3,7 @@
from homeassistant.components.update import UpdateEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AdvantageAirDataConfigEntry
from .const import DOMAIN as ADVANTAGE_AIR_DOMAIN
@@ -14,7 +14,7 @@ from .models import AdvantageAirData
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AdvantageAirDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AdvantageAir update platform."""
diff --git a/homeassistant/components/aemet/image.py b/homeassistant/components/aemet/image.py
index ffc53022e4c..ba9986a5ccc 100644
--- a/homeassistant/components/aemet/image.py
+++ b/homeassistant/components/aemet/image.py
@@ -9,7 +9,7 @@ from aemet_opendata.helpers import dict_nested_value
from homeassistant.components.image import Image, ImageEntity, ImageEntityDescription
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator
from .entity import AemetEntity
@@ -25,7 +25,7 @@ AEMET_IMAGES: Final[tuple[ImageEntityDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AemetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AEMET OpenData image entities based on a config entry."""
domain_data = config_entry.runtime_data
diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py
index 88eb34b6f84..9077b2bc44d 100644
--- a/homeassistant/components/aemet/sensor.py
+++ b/homeassistant/components/aemet/sensor.py
@@ -52,7 +52,7 @@ from homeassistant.const import (
UnitOfVolumetricFlux,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import (
@@ -358,7 +358,7 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AemetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AEMET OpenData sensor entities based on a config entry."""
domain_data = config_entry.runtime_data
diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py
index a156652eadd..3a17430300d 100644
--- a/homeassistant/components/aemet/weather.py
+++ b/homeassistant/components/aemet/weather.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONDITIONS_MAP
from .coordinator import AemetConfigEntry, WeatherUpdateCoordinator
@@ -35,7 +35,7 @@ from .entity import AemetEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AemetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AEMET OpenData weather entity based on a config entry."""
domain_data = config_entry.runtime_data
diff --git a/homeassistant/components/aftership/sensor.py b/homeassistant/components/aftership/sensor.py
index 085be2499d4..7e0c6f524ab 100644
--- a/homeassistant/components/aftership/sensor.py
+++ b/homeassistant/components/aftership/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from . import AfterShipConfigEntry
@@ -42,7 +42,7 @@ PLATFORM_SCHEMA: Final = cv.removed(DOMAIN, raise_if_present=False)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AfterShipConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AfterShip sensor entities based on a config entry."""
aftership = config_entry.runtime_data
diff --git a/homeassistant/components/aftership/strings.json b/homeassistant/components/aftership/strings.json
index ace8eb6d2d3..c3817a0cd24 100644
--- a/homeassistant/components/aftership/strings.json
+++ b/homeassistant/components/aftership/strings.json
@@ -51,7 +51,7 @@
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The {integration_title} YAML configuration import failed",
- "description": "Configuring {integration_title} using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
+ "description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to {integration_title} works and restart Home Assistant to try again or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
}
}
}
diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py
index 23328315e42..1ac808c87ad 100644
--- a/homeassistant/components/agent_dvr/alarm_control_panel.py
+++ b/homeassistant/components/agent_dvr/alarm_control_panel.py
@@ -9,7 +9,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AgentDVRConfigEntry
from .const import DOMAIN as AGENT_DOMAIN
@@ -24,7 +24,7 @@ CONST_ALARM_CONTROL_PANEL_NAME = "Alarm Panel"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AgentDVRConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Agent DVR Alarm Control Panels."""
async_add_entities([AgentBaseStation(config_entry.runtime_data)])
diff --git a/homeassistant/components/agent_dvr/camera.py b/homeassistant/components/agent_dvr/camera.py
index 933d0c6b40b..3de7f095b13 100644
--- a/homeassistant/components/agent_dvr/camera.py
+++ b/homeassistant/components/agent_dvr/camera.py
@@ -10,7 +10,7 @@ from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
@@ -39,7 +39,7 @@ CAMERA_SERVICES = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AgentDVRConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Agent cameras."""
filter_urllib3_logging()
diff --git a/homeassistant/components/airgradient/button.py b/homeassistant/components/airgradient/button.py
index ea7b12062e8..5e6f857f686 100644
--- a/homeassistant/components/airgradient/button.py
+++ b/homeassistant/components/airgradient/button.py
@@ -13,7 +13,7 @@ from homeassistant.components.button import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
@@ -47,7 +47,7 @@ LED_BAR_TEST = AirGradientButtonEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: AirGradientConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirGradient button entities based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airgradient/manifest.json b/homeassistant/components/airgradient/manifest.json
index 13764142697..afaf2698ced 100644
--- a/homeassistant/components/airgradient/manifest.json
+++ b/homeassistant/components/airgradient/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/airgradient",
"integration_type": "device",
"iot_class": "local_polling",
- "requirements": ["airgradient==0.9.1"],
+ "requirements": ["airgradient==0.9.2"],
"zeroconf": ["_airgradient._tcp.local."]
}
diff --git a/homeassistant/components/airgradient/number.py b/homeassistant/components/airgradient/number.py
index 4265215fa25..eeb24867845 100644
--- a/homeassistant/components/airgradient/number.py
+++ b/homeassistant/components/airgradient/number.py
@@ -14,7 +14,7 @@ from homeassistant.components.number import (
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
@@ -60,7 +60,7 @@ LED_BAR_BRIGHTNESS = AirGradientNumberEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: AirGradientConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirGradient number entities based on a config entry."""
diff --git a/homeassistant/components/airgradient/select.py b/homeassistant/components/airgradient/select.py
index 8c15102ad3a..f288055ebf4 100644
--- a/homeassistant/components/airgradient/select.py
+++ b/homeassistant/components/airgradient/select.py
@@ -14,7 +14,7 @@ from homeassistant.components.select import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN, PM_STANDARD, PM_STANDARD_REVERSE
@@ -142,7 +142,7 @@ CONTROL_ENTITIES: tuple[AirGradientSelectEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirGradientConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirGradient select entities based on a config entry."""
diff --git a/homeassistant/components/airgradient/sensor.py b/homeassistant/components/airgradient/sensor.py
index 3b20b31f923..a4944a6196e 100644
--- a/homeassistant/components/airgradient/sensor.py
+++ b/homeassistant/components/airgradient/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import AirGradientConfigEntry
@@ -225,7 +225,7 @@ CONFIG_DISPLAY_SENSOR_TYPES: tuple[AirGradientConfigSensorEntityDescription, ...
async def async_setup_entry(
hass: HomeAssistant,
entry: AirGradientConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirGradient sensor entities based on a config entry."""
diff --git a/homeassistant/components/airgradient/strings.json b/homeassistant/components/airgradient/strings.json
index 4cf3a6a34ea..cef4db57358 100644
--- a/homeassistant/components/airgradient/strings.json
+++ b/homeassistant/components/airgradient/strings.json
@@ -11,7 +11,7 @@
}
},
"discovery_confirm": {
- "description": "Do you want to setup {model}?"
+ "description": "Do you want to set up {model}?"
}
},
"abort": {
@@ -68,8 +68,8 @@
"led_bar_mode": {
"name": "LED bar mode",
"state": {
- "off": "Off",
- "co2": "Carbon dioxide",
+ "off": "[%key:common::state::off%]",
+ "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"pm": "Particulate matter"
}
},
@@ -143,8 +143,8 @@
"led_bar_mode": {
"name": "[%key:component::airgradient::entity::select::led_bar_mode::name%]",
"state": {
- "off": "[%key:component::airgradient::entity::select::led_bar_mode::state::off%]",
- "co2": "[%key:component::airgradient::entity::select::led_bar_mode::state::co2%]",
+ "off": "[%key:common::state::off%]",
+ "co2": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
"pm": "[%key:component::airgradient::entity::select::led_bar_mode::state::pm%]"
}
},
diff --git a/homeassistant/components/airgradient/switch.py b/homeassistant/components/airgradient/switch.py
index 55835fa30a6..0d404ed0f15 100644
--- a/homeassistant/components/airgradient/switch.py
+++ b/homeassistant/components/airgradient/switch.py
@@ -15,7 +15,7 @@ from homeassistant.components.switch import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirGradientConfigEntry
from .const import DOMAIN
@@ -45,7 +45,7 @@ POST_DATA_TO_AIRGRADIENT = AirGradientSwitchEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: AirGradientConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirGradient switch entities based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airgradient/update.py b/homeassistant/components/airgradient/update.py
index 12cec65f791..97cb8576e79 100644
--- a/homeassistant/components/airgradient/update.py
+++ b/homeassistant/components/airgradient/update.py
@@ -6,7 +6,7 @@ from propcache.api import cached_property
from homeassistant.components.update import UpdateDeviceClass, UpdateEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirGradientConfigEntry, AirGradientCoordinator
from .entity import AirGradientEntity
@@ -18,7 +18,7 @@ SCAN_INTERVAL = timedelta(hours=1)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirGradientConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airgradient update platform."""
diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py
index b255c5f078f..668cabdae63 100644
--- a/homeassistant/components/airly/coordinator.py
+++ b/homeassistant/components/airly/coordinator.py
@@ -105,7 +105,14 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i
try:
await measurements.update()
except (AirlyError, ClientConnectorError) as error:
- raise UpdateFailed(error) from error
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ translation_placeholders={
+ "entry": self.config_entry.title,
+ "error": repr(error),
+ },
+ ) from error
_LOGGER.debug(
"Requests remaining: %s/%s",
@@ -126,7 +133,11 @@ class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | i
standards = measurements.current["standards"]
if index["description"] == NO_AIRLY_SENSORS:
- raise UpdateFailed("Can't retrieve data: no Airly sensors in this area")
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="no_station",
+ translation_placeholders={"entry": self.config_entry.title},
+ )
for value in values:
data[value["name"]] = value["value"]
for standard in standards:
diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py
index fbf73ed753e..2aa99d9c792 100644
--- a/homeassistant/components/airly/sensor.py
+++ b/homeassistant/components/airly/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -175,7 +175,7 @@ SENSOR_TYPES: tuple[AirlySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirlyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Airly sensor entities based on a config entry."""
name = entry.data[CONF_NAME]
diff --git a/homeassistant/components/airly/strings.json b/homeassistant/components/airly/strings.json
index 33ee8bbe4c9..fe4ccbb4745 100644
--- a/homeassistant/components/airly/strings.json
+++ b/homeassistant/components/airly/strings.json
@@ -36,5 +36,13 @@
"name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]"
}
}
+ },
+ "exceptions": {
+ "update_error": {
+ "message": "An error occurred while retrieving data from the Airly API for {entry}: {error}"
+ },
+ "no_station": {
+ "message": "An error occurred while retrieving data from the Airly API for {entry}: no measuring stations in this area"
+ }
}
}
diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py
index ee5bf4a1dd7..1e73bc7551e 100644
--- a/homeassistant/components/airnow/coordinator.py
+++ b/homeassistant/components/airnow/coordinator.py
@@ -8,7 +8,7 @@ from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
from pyairnow import WebServiceAPI
from pyairnow.conv import aqi_to_concentration
-from pyairnow.errors import AirNowError
+from pyairnow.errors import AirNowError, InvalidJsonError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -79,7 +79,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
distance=self.distance,
)
- except (AirNowError, ClientConnectorError) as error:
+ except (AirNowError, ClientConnectorError, InvalidJsonError) as error:
raise UpdateFailed(error) from error
if not obs:
diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py
index c8b1c985c8b..db579de4976 100644
--- a/homeassistant/components/airnow/sensor.py
+++ b/homeassistant/components/airnow/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -130,7 +130,7 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirNowConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirNow sensor entities based on a config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json
index d5fb22106f9..a69f67948cb 100644
--- a/homeassistant/components/airnow/strings.json
+++ b/homeassistant/components/airnow/strings.json
@@ -7,7 +7,7 @@
"api_key": "[%key:common::config_flow::data::api_key%]",
"latitude": "[%key:common::config_flow::data::latitude%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
- "radius": "Station Radius (miles; optional)"
+ "radius": "Station radius (miles; optional)"
}
}
},
@@ -25,7 +25,7 @@
"step": {
"init": {
"data": {
- "radius": "Station Radius (miles)"
+ "radius": "Station radius (miles)"
}
}
}
diff --git a/homeassistant/components/airq/config_flow.py b/homeassistant/components/airq/config_flow.py
index 0c57b399b1b..f87b73b5283 100644
--- a/homeassistant/components/airq/config_flow.py
+++ b/homeassistant/components/airq/config_flow.py
@@ -83,6 +83,7 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(device_info["id"])
self._abort_if_unique_id_configured()
+ _LOGGER.debug("Creating an entry for %s", device_info["name"])
return self.async_create_entry(title=device_info["name"], data=user_input)
return self.async_show_form(
diff --git a/homeassistant/components/airq/coordinator.py b/homeassistant/components/airq/coordinator.py
index b48d8047910..743d12d40e5 100644
--- a/homeassistant/components/airq/coordinator.py
+++ b/homeassistant/components/airq/coordinator.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
-from aioairq import AirQ
+from aioairq.core import AirQ, identify_warming_up_sensors
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
@@ -55,6 +55,9 @@ class AirQCoordinator(DataUpdateCoordinator):
async def _async_update_data(self) -> dict:
"""Fetch the data from the device."""
if "name" not in self.device_info:
+ _LOGGER.debug(
+ "'name' not found in AirQCoordinator.device_info, fetching from the device"
+ )
info = await self.airq.fetch_device_info()
self.device_info.update(
DeviceInfo(
@@ -64,7 +67,16 @@ class AirQCoordinator(DataUpdateCoordinator):
hw_version=info["hw_version"],
)
)
- return await self.airq.get_latest_data( # type: ignore[no-any-return]
+ _LOGGER.debug(
+ "Updated AirQCoordinator.device_info for 'name' %s",
+ self.device_info.get("name"),
+ )
+ data: dict = await self.airq.get_latest_data(
return_average=self.return_average,
clip_negative_values=self.clip_negative,
)
+ if warming_up_sensors := identify_warming_up_sensors(data):
+ _LOGGER.debug(
+ "Following sensors are still warming up: %s", warming_up_sensors
+ )
+ return data
diff --git a/homeassistant/components/airq/sensor.py b/homeassistant/components/airq/sensor.py
index c465d710406..08a344ae9f4 100644
--- a/homeassistant/components/airq/sensor.py
+++ b/homeassistant/components/airq/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AirQConfigEntry, AirQCoordinator
@@ -399,7 +399,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
entry: AirQConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor entities based on a config entry."""
diff --git a/homeassistant/components/airq/strings.json b/homeassistant/components/airq/strings.json
index 26b944467e6..9c16975a3ab 100644
--- a/homeassistant/components/airq/strings.json
+++ b/homeassistant/components/airq/strings.json
@@ -91,7 +91,7 @@
"name": "Hydrogen fluoride"
},
"health_index": {
- "name": "Health Index"
+ "name": "Health index"
},
"absolute_humidity": {
"name": "Absolute humidity"
@@ -112,10 +112,10 @@
"name": "Oxygen"
},
"performance_index": {
- "name": "Performance Index"
+ "name": "Performance index"
},
"hydrogen_phosphide": {
- "name": "Hydrogen Phosphide"
+ "name": "Hydrogen phosphide"
},
"relative_pressure": {
"name": "Relative pressure"
@@ -127,22 +127,22 @@
"name": "Refrigerant"
},
"silicon_hydride": {
- "name": "Silicon Hydride"
+ "name": "Silicon hydride"
},
"noise": {
"name": "Noise"
},
"maximum_noise": {
- "name": "Noise (Maximum)"
+ "name": "Noise (maximum)"
},
"radon": {
"name": "Radon"
},
"industrial_volatile_organic_compounds": {
- "name": "VOCs (Industrial)"
+ "name": "VOCs (industrial)"
},
"virus_index": {
- "name": "Virus Index"
+ "name": "Virus index"
}
}
}
diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py
index 1b604d72032..a0d9c97c8c8 100644
--- a/homeassistant/components/airthings/sensor.py
+++ b/homeassistant/components/airthings/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -114,7 +114,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: AirthingsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Airthings sensor."""
diff --git a/homeassistant/components/airthings_ble/config_flow.py b/homeassistant/components/airthings_ble/config_flow.py
index 3e7b659bff1..2d32fa6e7df 100644
--- a/homeassistant/components/airthings_ble/config_flow.py
+++ b/homeassistant/components/airthings_ble/config_flow.py
@@ -102,7 +102,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError:
return self.async_abort(reason="cannot_connect")
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown")
name = get_name(device)
@@ -160,7 +161,8 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
device = await self._get_device_data(discovery_info)
except AirthingsDeviceUpdateError:
return self.async_abort(reason="cannot_connect")
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unknown error occurred")
return self.async_abort(reason="unknown")
name = get_name(device)
self._discovered_devices[address] = Discovery(name, discovery_info, device)
diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py
index 248561706a3..9c1a2af7a9f 100644
--- a/homeassistant/components/airthings_ble/sensor.py
+++ b/homeassistant/components/airthings_ble/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_registry import (
RegistryEntry,
async_entries_for_device,
@@ -153,7 +153,7 @@ def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None:
async def async_setup_entry(
hass: HomeAssistant,
entry: AirthingsBLEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Airthings BLE sensors."""
is_metric = hass.config.units is METRIC_SYSTEM
diff --git a/homeassistant/components/airtouch4/climate.py b/homeassistant/components/airtouch4/climate.py
index 0af920bd7a9..6d393ed0c99 100644
--- a/homeassistant/components/airtouch4/climate.py
+++ b/homeassistant/components/airtouch4/climate.py
@@ -19,7 +19,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import AirTouch4ConfigEntry
@@ -64,7 +64,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AirTouch4ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Airtouch 4."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/airtouch5/climate.py b/homeassistant/components/airtouch5/climate.py
index 16566f5d664..f3b914bf341 100644
--- a/homeassistant/components/airtouch5/climate.py
+++ b/homeassistant/components/airtouch5/climate.py
@@ -37,7 +37,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Airtouch5ConfigEntry
from .const import DOMAIN, FAN_INTELLIGENT_AUTO, FAN_TURBO
@@ -93,7 +93,7 @@ FAN_MODE_TO_SET_AC_FAN_SPEED = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: Airtouch5ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Airtouch 5 Climate entities."""
client = config_entry.runtime_data
diff --git a/homeassistant/components/airtouch5/config_flow.py b/homeassistant/components/airtouch5/config_flow.py
index d96aaed96b7..38c85e45fb8 100644
--- a/homeassistant/components/airtouch5/config_flow.py
+++ b/homeassistant/components/airtouch5/config_flow.py
@@ -32,7 +32,8 @@ class AirTouch5ConfigFlow(ConfigFlow, domain=DOMAIN):
client = Airtouch5SimpleClient(user_input[CONF_HOST])
try:
await client.test_connection()
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors = {"base": "cannot_connect"}
else:
await self.async_set_unique_id(user_input[CONF_HOST])
diff --git a/homeassistant/components/airtouch5/cover.py b/homeassistant/components/airtouch5/cover.py
index 62cf7938fc2..b811ed5c451 100644
--- a/homeassistant/components/airtouch5/cover.py
+++ b/homeassistant/components/airtouch5/cover.py
@@ -20,7 +20,7 @@ from homeassistant.components.cover import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Airtouch5ConfigEntry
from .const import DOMAIN
@@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: Airtouch5ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Airtouch 5 Cover entities."""
client = config_entry.runtime_data
diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py
index 88a670edb82..1f406bd8f36 100644
--- a/homeassistant/components/airvisual/sensor.py
+++ b/homeassistant/components/airvisual/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
CONF_STATE,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import AirVisualConfigEntry
@@ -108,7 +108,7 @@ POLLUTANT_UNITS = {
async def async_setup_entry(
hass: HomeAssistant,
entry: AirVisualConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirVisual sensors based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json
index 148b1368a19..9d53be4dee7 100644
--- a/homeassistant/components/airvisual/strings.json
+++ b/homeassistant/components/airvisual/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"geography_by_coords": {
- "title": "Configure a Geography",
+ "title": "Configure a geography",
"description": "Use the AirVisual cloud API to monitor a latitude/longitude.",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
@@ -16,8 +16,8 @@
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"city": "City",
- "country": "Country",
- "state": "State"
+ "state": "State",
+ "country": "[%key:common::config_flow::data::country%]"
}
},
"reauth_confirm": {
@@ -56,12 +56,12 @@
"sensor": {
"pollutant_label": {
"state": {
- "co": "Carbon Monoxide",
- "n2": "Nitrogen Dioxide",
- "o3": "Ozone",
- "p1": "PM10",
- "p2": "PM2.5",
- "s2": "Sulfur Dioxide"
+ "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
+ "n2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
+ "o3": "[%key:component::sensor::entity_component::ozone::name%]",
+ "p1": "[%key:component::sensor::entity_component::pm10::name%]",
+ "p2": "[%key:component::sensor::entity_component::pm25::name%]",
+ "s2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]"
}
},
"pollutant_level": {
diff --git a/homeassistant/components/airvisual_pro/sensor.py b/homeassistant/components/airvisual_pro/sensor.py
index 58ad730bc31..215370736fe 100644
--- a/homeassistant/components/airvisual_pro/sensor.py
+++ b/homeassistant/components/airvisual_pro/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AirVisualProConfigEntry
from .entity import AirVisualProEntity
@@ -130,7 +130,7 @@ def async_get_aqi_locale(settings: dict[str, Any]) -> str:
async def async_setup_entry(
hass: HomeAssistant,
entry: AirVisualProConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AirVisual sensors based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/airzone/binary_sensor.py b/homeassistant/components/airzone/binary_sensor.py
index 48f6ce8fd94..7274df44261 100644
--- a/homeassistant/components/airzone/binary_sensor.py
+++ b/homeassistant/components/airzone/binary_sensor.py
@@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneSystemEntity, AirzoneZoneEntity
@@ -76,7 +76,7 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone binary sensors from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py
index 23355a070ab..39e70e58e6d 100644
--- a/homeassistant/components/airzone/climate.py
+++ b/homeassistant/components/airzone/climate.py
@@ -48,7 +48,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import API_TEMPERATURE_STEP, TEMP_UNIT_LIB_TO_HASS
from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator
@@ -100,7 +100,7 @@ HVAC_MODE_HASS_TO_LIB: Final[dict[HVACMode, OperationMode]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone climate from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json
index 95ed9d200f4..1b636de0a47 100644
--- a/homeassistant/components/airzone/manifest.json
+++ b/homeassistant/components/airzone/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
- "requirements": ["aioairzone==0.9.9"]
+ "requirements": ["aioairzone==1.0.0"]
}
diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py
index 56a9b06ea21..c00e83f2c5b 100644
--- a/homeassistant/components/airzone/select.py
+++ b/homeassistant/components/airzone/select.py
@@ -25,7 +25,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneZoneEntity
@@ -117,7 +117,7 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone select from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airzone/sensor.py b/homeassistant/components/airzone/sensor.py
index 0b5c5666c89..66657836b74 100644
--- a/homeassistant/components/airzone/sensor.py
+++ b/homeassistant/components/airzone/sensor.py
@@ -9,6 +9,8 @@ from aioairzone.const import (
AZD_HUMIDITY,
AZD_TEMP,
AZD_TEMP_UNIT,
+ AZD_THERMOSTAT_BATTERY,
+ AZD_THERMOSTAT_SIGNAL,
AZD_WEBSERVER,
AZD_WIFI_RSSI,
AZD_ZONES,
@@ -28,7 +30,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import TEMP_UNIT_LIB_TO_HASS
from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator
@@ -73,13 +75,27 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
+ SensorEntityDescription(
+ device_class=SensorDeviceClass.BATTERY,
+ key=AZD_THERMOSTAT_BATTERY,
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ key=AZD_THERMOSTAT_SIGNAL,
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key="thermostat_signal",
+ ),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone sensors from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json
index cd313b821aa..c7d9701aa83 100644
--- a/homeassistant/components/airzone/strings.json
+++ b/homeassistant/components/airzone/strings.json
@@ -76,6 +76,9 @@
"sensor": {
"rssi": {
"name": "RSSI"
+ },
+ "thermostat_signal": {
+ "name": "Signal strength"
}
}
}
diff --git a/homeassistant/components/airzone/switch.py b/homeassistant/components/airzone/switch.py
index 69bf33666a5..07278970e03 100644
--- a/homeassistant/components/airzone/switch.py
+++ b/homeassistant/components/airzone/switch.py
@@ -14,7 +14,7 @@ from homeassistant.components.switch import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneZoneEntity
@@ -39,7 +39,7 @@ ZONE_SWITCH_TYPES: Final[tuple[AirzoneSwitchDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone switch from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airzone/water_heater.py b/homeassistant/components/airzone/water_heater.py
index 2a1ca72db21..eb1537dc222 100644
--- a/homeassistant/components/airzone/water_heater.py
+++ b/homeassistant/components/airzone/water_heater.py
@@ -28,7 +28,7 @@ from homeassistant.components.water_heater import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import TEMP_UNIT_LIB_TO_HASS
from .coordinator import AirzoneConfigEntry, AirzoneUpdateCoordinator
@@ -58,7 +58,7 @@ OPERATION_MODE_TO_DHW_PARAMS: Final[dict[str, dict[str, Any]]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone Water Heater from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airzone_cloud/binary_sensor.py b/homeassistant/components/airzone_cloud/binary_sensor.py
index 4a7b5441b68..64fa8cb5151 100644
--- a/homeassistant/components/airzone_cloud/binary_sensor.py
+++ b/homeassistant/components/airzone_cloud/binary_sensor.py
@@ -26,7 +26,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator
from .entity import (
@@ -111,7 +111,7 @@ ZONE_BINARY_SENSOR_TYPES: Final[tuple[AirzoneBinarySensorEntityDescription, ...]
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone Cloud binary sensors from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py
index 69b10d2a69e..115f6e32dbf 100644
--- a/homeassistant/components/airzone_cloud/climate.py
+++ b/homeassistant/components/airzone_cloud/climate.py
@@ -56,7 +56,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator
from .entity import (
@@ -119,7 +119,7 @@ HVAC_MODE_HASS_TO_LIB: Final[dict[HVACMode, OperationMode]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone climate from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json
index 0e21e57ec52..3b6f94df57c 100644
--- a/homeassistant/components/airzone_cloud/manifest.json
+++ b/homeassistant/components/airzone_cloud/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
"iot_class": "cloud_push",
"loggers": ["aioairzone_cloud"],
- "requirements": ["aioairzone-cloud==0.6.10"]
+ "requirements": ["aioairzone-cloud==0.6.11"]
}
diff --git a/homeassistant/components/airzone_cloud/select.py b/homeassistant/components/airzone_cloud/select.py
index e0c595a80e8..816544efdf8 100644
--- a/homeassistant/components/airzone_cloud/select.py
+++ b/homeassistant/components/airzone_cloud/select.py
@@ -21,7 +21,7 @@ from aioairzone_cloud.const import (
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneZoneEntity
@@ -89,7 +89,7 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone Cloud select from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py
index 4b13e09d126..43526c3aa52 100644
--- a/homeassistant/components/airzone_cloud/sensor.py
+++ b/homeassistant/components/airzone_cloud/sensor.py
@@ -47,7 +47,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator
from .entity import (
@@ -221,7 +221,7 @@ ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone Cloud sensors from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json
index 6e0f9adcd66..5481bfbc984 100644
--- a/homeassistant/components/airzone_cloud/strings.json
+++ b/homeassistant/components/airzone_cloud/strings.json
@@ -32,9 +32,9 @@
"air_quality": {
"name": "Air Quality mode",
"state": {
- "off": "Off",
- "on": "On",
- "auto": "Auto"
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]",
+ "auto": "[%key:common::state::auto%]"
}
},
"modes": {
diff --git a/homeassistant/components/airzone_cloud/switch.py b/homeassistant/components/airzone_cloud/switch.py
index 8de0685e15e..ab703cd537a 100644
--- a/homeassistant/components/airzone_cloud/switch.py
+++ b/homeassistant/components/airzone_cloud/switch.py
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator
from .entity import AirzoneEntity, AirzoneZoneEntity
@@ -38,7 +38,7 @@ ZONE_SWITCH_TYPES: Final[tuple[AirzoneSwitchDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone Cloud switch from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/airzone_cloud/water_heater.py b/homeassistant/components/airzone_cloud/water_heater.py
index 381dce913fe..41d43002569 100644
--- a/homeassistant/components/airzone_cloud/water_heater.py
+++ b/homeassistant/components/airzone_cloud/water_heater.py
@@ -29,7 +29,7 @@ from homeassistant.components.water_heater import (
)
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirzoneCloudConfigEntry, AirzoneUpdateCoordinator
from .entity import AirzoneHotWaterEntity
@@ -68,7 +68,7 @@ OPERATION_MODE_TO_DHW_PARAMS: Final[dict[str, dict[str, Any]]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: AirzoneCloudConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Airzone Cloud Water Heater from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py
index 6d3f1d642b5..af50147a8ef 100644
--- a/homeassistant/components/aladdin_connect/__init__.py
+++ b/homeassistant/components/aladdin_connect/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -28,11 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- if all(
- config_entry.state is ConfigEntryState.NOT_LOADED
- for config_entry in hass.config_entries.async_entries(DOMAIN)
- if config_entry.entry_id != entry.entry_id
- ):
- ir.async_delete_issue(hass, DOMAIN, DOMAIN)
-
return True
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Remove a config entry."""
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
+ ir.async_delete_issue(hass, DOMAIN, DOMAIN)
+ # Remove any remaining disabled or ignored entries
+ for _entry in hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json
index 5f718280566..ed02b2d0ee8 100644
--- a/homeassistant/components/alarm_control_panel/strings.json
+++ b/homeassistant/components/alarm_control_panel/strings.json
@@ -90,7 +90,7 @@
},
"alarm_arm_home": {
"name": "Arm home",
- "description": "Sets the alarm to: _armed, but someone is home_.",
+ "description": "Arms the alarm in the home mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@@ -100,7 +100,7 @@
},
"alarm_arm_away": {
"name": "Arm away",
- "description": "Sets the alarm to: _armed, no one home_.",
+ "description": "Arms the alarm in the away mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@@ -110,7 +110,7 @@
},
"alarm_arm_night": {
"name": "Arm night",
- "description": "Sets the alarm to: _armed for the night_.",
+ "description": "Arms the alarm in the night mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@@ -120,7 +120,7 @@
},
"alarm_arm_vacation": {
"name": "Arm vacation",
- "description": "Sets the alarm to: _armed for vacation_.",
+ "description": "Arms the alarm in the vacation mode.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
@@ -130,7 +130,7 @@
},
"alarm_trigger": {
"name": "Trigger",
- "description": "Trigger the alarm manually.",
+ "description": "Triggers the alarm manually.",
"fields": {
"code": {
"name": "[%key:component::alarm_control_panel::services::alarm_disarm::fields::code::name%]",
diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py
index d7092bbe1c4..52687f04bf9 100644
--- a/homeassistant/components/alarmdecoder/alarm_control_panel.py
+++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py
@@ -14,7 +14,7 @@ from homeassistant.const import ATTR_CODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AlarmDecoderConfigEntry
from .const import (
@@ -36,7 +36,7 @@ ATTR_KEYPRESS = "keypress"
async def async_setup_entry(
hass: HomeAssistant,
entry: AlarmDecoderConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up for AlarmDecoder alarm panels."""
options = entry.options
diff --git a/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant/components/alarmdecoder/binary_sensor.py
index 1234c9f349b..b025da70d59 100644
--- a/homeassistant/components/alarmdecoder/binary_sensor.py
+++ b/homeassistant/components/alarmdecoder/binary_sensor.py
@@ -5,7 +5,7 @@ import logging
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AlarmDecoderConfigEntry
from .const import (
@@ -40,7 +40,7 @@ ATTR_RF_LOOP1 = "rf_loop1"
async def async_setup_entry(
hass: HomeAssistant,
entry: AlarmDecoderConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up for AlarmDecoder sensor."""
diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json
index ae1a2f4684d..c2c12792801 100644
--- a/homeassistant/components/alarmdecoder/manifest.json
+++ b/homeassistant/components/alarmdecoder/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["adext", "alarmdecoder"],
- "requirements": ["adext==0.4.3"]
+ "requirements": ["adext==0.4.4"]
}
diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py
index f5e744457fd..8fdc6d57c67 100644
--- a/homeassistant/components/alarmdecoder/sensor.py
+++ b/homeassistant/components/alarmdecoder/sensor.py
@@ -3,7 +3,7 @@
from homeassistant.components.sensor import SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AlarmDecoderConfigEntry
from .const import SIGNAL_PANEL_MESSAGE
@@ -13,7 +13,7 @@ from .entity import AlarmDecoderEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: AlarmDecoderConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up for AlarmDecoder sensor."""
diff --git a/homeassistant/components/alert/entity.py b/homeassistant/components/alert/entity.py
index 629047b15ba..a11b281428f 100644
--- a/homeassistant/components/alert/entity.py
+++ b/homeassistant/components/alert/entity.py
@@ -14,7 +14,7 @@ from homeassistant.components.notify import (
)
from homeassistant.const import STATE_IDLE, STATE_OFF, STATE_ON
from homeassistant.core import Event, EventStateChangedData, HassJob, HomeAssistant
-from homeassistant.exceptions import ServiceNotFound
+from homeassistant.exceptions import ServiceNotFound, ServiceValidationError
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import (
async_track_point_in_time,
@@ -195,7 +195,8 @@ class AlertEntity(Entity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Async Acknowledge alert."""
- LOGGER.debug("Acknowledged Alert: %s", self._attr_name)
+ if not self._can_ack:
+ raise ServiceValidationError("This alert cannot be acknowledged")
self._ack = True
self.async_write_ha_state()
diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py
index e70055c20b1..897037987a7 100644
--- a/homeassistant/components/alexa/capabilities.py
+++ b/homeassistant/components/alexa/capabilities.py
@@ -1438,7 +1438,7 @@ class AlexaModeController(AlexaCapability):
# Fan preset_mode
if self.instance == f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}":
mode = self.entity.attributes.get(fan.ATTR_PRESET_MODE, None)
- if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, None):
+ if mode in self.entity.attributes.get(fan.ATTR_PRESET_MODES, ()):
return f"{fan.ATTR_PRESET_MODE}.{mode}"
# Humidifier mode
diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json
index e7fbf8edc74..f684292d9a2 100644
--- a/homeassistant/components/amazon_polly/manifest.json
+++ b/homeassistant/components/amazon_polly/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["boto3", "botocore", "s3transfer"],
"quality_scale": "legacy",
- "requirements": ["boto3==1.34.131"]
+ "requirements": ["boto3==1.37.1"]
}
diff --git a/homeassistant/components/amberelectric/binary_sensor.py b/homeassistant/components/amberelectric/binary_sensor.py
index 66292ea5524..3ee27f19849 100644
--- a/homeassistant/components/amberelectric/binary_sensor.py
+++ b/homeassistant/components/amberelectric/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION
@@ -85,7 +85,7 @@ class AmberDemandWindowBinarySensor(AmberPriceGridSensor):
async def async_setup_entry(
hass: HomeAssistant,
entry: AmberConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/amberelectric/sensor.py b/homeassistant/components/amberelectric/sensor.py
index 49d6e5f4eac..7276ddb26a5 100644
--- a/homeassistant/components/amberelectric/sensor.py
+++ b/homeassistant/components/amberelectric/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE, UnitOfEnergy
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION
@@ -196,7 +196,7 @@ class AmberGridSensor(CoordinatorEntity[AmberUpdateCoordinator], SensorEntity):
async def async_setup_entry(
hass: HomeAssistant,
entry: AmberConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ambient_network/sensor.py b/homeassistant/components/ambient_network/sensor.py
index 9d262e5a987..b96da9863a1 100644
--- a/homeassistant/components/ambient_network/sensor.py
+++ b/homeassistant/components/ambient_network/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
UnitOfVolumetricFlux,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import AmbientNetworkConfigEntry, AmbientNetworkDataUpdateCoordinator
@@ -239,6 +239,8 @@ SENSOR_DESCRIPTIONS = (
native_unit_of_measurement=DEGREE,
suggested_display_precision=0,
entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
SensorEntityDescription(
key=TYPE_WINDGUSTMPH,
@@ -270,7 +272,7 @@ SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AmbientNetworkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ambient Network sensor entities."""
diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py
index a79788a4c38..9a7c89db95e 100644
--- a/homeassistant/components/ambient_station/binary_sensor.py
+++ b/homeassistant/components/ambient_station/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import ATTR_NAME, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AmbientStationConfigEntry
from .const import ATTR_LAST_DATA
@@ -381,7 +381,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AmbientStationConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ambient PWS binary sensors based on a config entry."""
ambient = entry.runtime_data
diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py
index dfbd2d1b4a0..1b4334774d4 100644
--- a/homeassistant/components/ambient_station/sensor.py
+++ b/homeassistant/components/ambient_station/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AmbientStation, AmbientStationConfigEntry
from .const import ATTR_LAST_DATA, TYPE_SOLARRADIATION, TYPE_SOLARRADIATION_LX
@@ -608,21 +608,26 @@ SENSOR_DESCRIPTIONS = (
key=TYPE_WINDDIR,
translation_key="wind_direction",
native_unit_of_measurement=DEGREE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
SensorEntityDescription(
key=TYPE_WINDDIR_AVG10M,
translation_key="wind_direction_average_10m",
native_unit_of_measurement=DEGREE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
SensorEntityDescription(
key=TYPE_WINDDIR_AVG2M,
translation_key="wind_direction_average_2m",
native_unit_of_measurement=DEGREE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
SensorEntityDescription(
key=TYPE_WINDGUSTDIR,
translation_key="wind_gust_direction",
native_unit_of_measurement=DEGREE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
SensorEntityDescription(
key=TYPE_WINDGUSTMPH,
@@ -662,7 +667,7 @@ SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AmbientStationConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ambient PWS sensors based on a config entry."""
ambient = entry.runtime_data
diff --git a/homeassistant/components/analytics_insights/config_flow.py b/homeassistant/components/analytics_insights/config_flow.py
index da77a35f789..b2648f7c13c 100644
--- a/homeassistant/components/analytics_insights/config_flow.py
+++ b/homeassistant/components/analytics_insights/config_flow.py
@@ -8,7 +8,7 @@ from python_homeassistant_analytics import (
HomeassistantAnalyticsClient,
HomeassistantAnalyticsConnectionError,
)
-from python_homeassistant_analytics.models import IntegrationType
+from python_homeassistant_analytics.models import Environment, IntegrationType
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
@@ -81,7 +81,7 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
)
try:
addons = await client.get_addons()
- integrations = await client.get_integrations()
+ integrations = await client.get_integrations(Environment.NEXT)
custom_integrations = await client.get_custom_integrations()
except HomeassistantAnalyticsConnectionError:
LOGGER.exception("Error connecting to Home Assistant analytics")
@@ -165,7 +165,7 @@ class HomeassistantAnalyticsOptionsFlowHandler(OptionsFlow):
)
try:
addons = await client.get_addons()
- integrations = await client.get_integrations()
+ integrations = await client.get_integrations(Environment.NEXT)
custom_integrations = await client.get_custom_integrations()
except HomeassistantAnalyticsConnectionError:
LOGGER.exception("Error connecting to Home Assistant analytics")
diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py
index 324ca6991d2..830d914b311 100644
--- a/homeassistant/components/analytics_insights/sensor.py
+++ b/homeassistant/components/analytics_insights/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -94,7 +94,7 @@ GENERAL_SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: AnalyticsInsightsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize the entries."""
diff --git a/homeassistant/components/android_ip_webcam/binary_sensor.py b/homeassistant/components/android_ip_webcam/binary_sensor.py
index 1846889bfda..67816664752 100644
--- a/homeassistant/components/android_ip_webcam/binary_sensor.py
+++ b/homeassistant/components/android_ip_webcam/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import MOTION_ACTIVE
from .coordinator import AndroidIPCamConfigEntry, AndroidIPCamDataUpdateCoordinator
@@ -24,7 +24,7 @@ BINARY_SENSOR_DESCRIPTION = BinarySensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AndroidIPCamConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the IP Webcam sensors from config entry."""
diff --git a/homeassistant/components/android_ip_webcam/camera.py b/homeassistant/components/android_ip_webcam/camera.py
index 95d4fb9f67a..833b9a0d296 100644
--- a/homeassistant/components/android_ip_webcam/camera.py
+++ b/homeassistant/components/android_ip_webcam/camera.py
@@ -11,7 +11,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import AndroidIPCamConfigEntry, AndroidIPCamDataUpdateCoordinator
@@ -20,7 +20,7 @@ from .coordinator import AndroidIPCamConfigEntry, AndroidIPCamDataUpdateCoordina
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AndroidIPCamConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the IP Webcam camera from config entry."""
filter_urllib3_logging()
diff --git a/homeassistant/components/android_ip_webcam/manifest.json b/homeassistant/components/android_ip_webcam/manifest.json
index 57af567ec51..d7a9f8ad97a 100644
--- a/homeassistant/components/android_ip_webcam/manifest.json
+++ b/homeassistant/components/android_ip_webcam/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/android_ip_webcam",
"iot_class": "local_polling",
- "requirements": ["pydroid-ipcam==2.0.0"]
+ "requirements": ["pydroid-ipcam==3.0.0"]
}
diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py
index 9b2454d6c09..e9d5f8514e8 100644
--- a/homeassistant/components/android_ip_webcam/sensor.py
+++ b/homeassistant/components/android_ip_webcam/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import AndroidIPCamConfigEntry, AndroidIPCamDataUpdateCoordinator
@@ -119,7 +119,7 @@ SENSOR_TYPES: tuple[AndroidIPWebcamSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AndroidIPCamConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the IP Webcam sensors from config entry."""
diff --git a/homeassistant/components/android_ip_webcam/switch.py b/homeassistant/components/android_ip_webcam/switch.py
index f813415df0b..3ceaf6e59b9 100644
--- a/homeassistant/components/android_ip_webcam/switch.py
+++ b/homeassistant/components/android_ip_webcam/switch.py
@@ -11,7 +11,7 @@ from pydroid_ipcam import PyDroidIPCam
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AndroidIPCamConfigEntry, AndroidIPCamDataUpdateCoordinator
from .entity import AndroidIPCamBaseEntity
@@ -112,7 +112,7 @@ SWITCH_TYPES: tuple[AndroidIPWebcamSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AndroidIPCamConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the IP Webcam switches from config entry."""
diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py
index 728411ddf42..c9e62908cac 100644
--- a/homeassistant/components/androidtv/media_player.py
+++ b/homeassistant/components/androidtv/media_player.py
@@ -21,7 +21,7 @@ from homeassistant.const import ATTR_COMMAND
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from . import AndroidTVConfigEntry
@@ -65,7 +65,7 @@ ANDROIDTV_STATES = {
async def async_setup_entry(
hass: HomeAssistant,
entry: AndroidTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Android Debug Bridge entity."""
device_class = entry.runtime_data.aftv.DEVICE_CLASS
diff --git a/homeassistant/components/androidtv/remote.py b/homeassistant/components/androidtv/remote.py
index db48b0cf1b6..026d1485e07 100644
--- a/homeassistant/components/androidtv/remote.py
+++ b/homeassistant/components/androidtv/remote.py
@@ -12,7 +12,7 @@ from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_TURN_OFF_COMMAND, CONF_TURN_ON_COMMAND, DOMAIN
from .entity import AndroidTVEntity, adb_decorator
@@ -21,7 +21,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AndroidTV remote from a config entry."""
async_add_entities([AndroidTVRemote(entry)])
diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py
index 44b2d2a5f20..bf146a11e13 100644
--- a/homeassistant/components/androidtv_remote/entity.py
+++ b/homeassistant/components/androidtv_remote/entity.py
@@ -73,7 +73,7 @@ class AndroidTVRemoteBaseEntity(Entity):
self._api.send_key_command(key_code, direction)
except ConnectionClosed as exc:
raise HomeAssistantError(
- "Connection to Android TV device is closed"
+ translation_domain=DOMAIN, translation_key="connection_closed"
) from exc
def _send_launch_app_command(self, app_link: str) -> None:
@@ -85,5 +85,5 @@ class AndroidTVRemoteBaseEntity(Entity):
self._api.send_launch_app_command(app_link)
except ConnectionClosed as exc:
raise HomeAssistantError(
- "Connection to Android TV device is closed"
+ translation_domain=DOMAIN, translation_key="connection_closed"
) from exc
diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json
index d9c2dd05c44..89cc0fc3965 100644
--- a/homeassistant/components/androidtv_remote/manifest.json
+++ b/homeassistant/components/androidtv_remote/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
- "requirements": ["androidtvremote2==0.1.2"],
+ "requirements": ["androidtvremote2==0.2.1"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}
diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py
index cdc307a0472..5bc205b32df 100644
--- a/homeassistant/components/androidtv_remote/media_player.py
+++ b/homeassistant/components/androidtv_remote/media_player.py
@@ -18,10 +18,10 @@ from homeassistant.components.media_player import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AndroidTVRemoteConfigEntry
-from .const import CONF_APP_ICON, CONF_APP_NAME
+from .const import CONF_APP_ICON, CONF_APP_NAME, DOMAIN
from .entity import AndroidTVRemoteBaseEntity
PARALLEL_UPDATES = 0
@@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AndroidTVRemoteConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Android TV media player entity based on a config entry."""
api = config_entry.runtime_data
@@ -233,5 +233,5 @@ class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEnt
await asyncio.sleep(delay_secs)
except ConnectionClosed as exc:
raise HomeAssistantError(
- "Connection to Android TV device is closed"
+ translation_domain=DOMAIN, translation_key="connection_closed"
) from exc
diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py
index c9a261c8735..212b0491d2d 100644
--- a/homeassistant/components/androidtv_remote/remote.py
+++ b/homeassistant/components/androidtv_remote/remote.py
@@ -18,7 +18,7 @@ from homeassistant.components.remote import (
RemoteEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AndroidTVRemoteConfigEntry
from .const import CONF_APP_NAME
@@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AndroidTVRemoteConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Android TV remote entity based on a config entry."""
api = config_entry.runtime_data
diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json
index e41cbcf9a76..106cac3a63d 100644
--- a/homeassistant/components/androidtv_remote/strings.json
+++ b/homeassistant/components/androidtv_remote/strings.json
@@ -54,5 +54,10 @@
}
}
}
+ },
+ "exceptions": {
+ "connection_closed": {
+ "message": "Connection to the Android TV device is closed"
+ }
}
}
diff --git a/homeassistant/components/anova/config_flow.py b/homeassistant/components/anova/config_flow.py
index bc4723b1dba..f382606baba 100644
--- a/homeassistant/components/anova/config_flow.py
+++ b/homeassistant/components/anova/config_flow.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+import logging
+
from anova_wifi import AnovaApi, InvalidLogin
import voluptuous as vol
@@ -11,8 +13,10 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
+_LOGGER = logging.getLogger(__name__)
-class AnovaConfligFlow(ConfigFlow, domain=DOMAIN):
+
+class AnovaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Sets up a config flow for Anova."""
VERSION = 1
@@ -35,7 +39,8 @@ class AnovaConfligFlow(ConfigFlow, domain=DOMAIN):
await api.authenticate()
except InvalidLogin:
errors["base"] = "invalid_auth"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py
index 7365e4597ba..c3a3d3861f2 100644
--- a/homeassistant/components/anova/sensor.py
+++ b/homeassistant/components/anova/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import AnovaConfigEntry, AnovaCoordinator
@@ -97,7 +97,7 @@ SENSOR_DESCRIPTIONS: list[AnovaSensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
entry: AnovaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Anova device."""
anova_data = entry.runtime_data
@@ -108,7 +108,7 @@ async def async_setup_entry(
def setup_coordinator(
coordinator: AnovaCoordinator,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an individual Anova Coordinator."""
diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py
index be5a6ad2258..317498e96b5 100644
--- a/homeassistant/components/anthemav/media_player.py
+++ b/homeassistant/components/anthemav/media_player.py
@@ -16,18 +16,19 @@ from homeassistant.const import CONF_MAC, CONF_MODEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AnthemavConfigEntry
from .const import ANTHEMAV_UPDATE_SIGNAL, DOMAIN, MANUFACTURER
_LOGGER = logging.getLogger(__name__)
+VOLUME_STEP = 0.01
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AnthemavConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
name = config_entry.title
@@ -60,6 +61,7 @@ class AnthemAVR(MediaPlayerEntity):
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.SELECT_SOURCE
)
+ _attr_volume_step = VOLUME_STEP
def __init__(
self,
diff --git a/homeassistant/components/anthemav/strings.json b/homeassistant/components/anthemav/strings.json
index 1f1dd0ec75b..15e365b3e63 100644
--- a/homeassistant/components/anthemav/strings.json
+++ b/homeassistant/components/anthemav/strings.json
@@ -10,7 +10,7 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "cannot_receive_deviceinfo": "Failed to retreive MAC Address. Make sure the device is turned on"
+ "cannot_receive_deviceinfo": "Failed to retrieve MAC Address. Make sure the device is turned on"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py
index aa6cf509fa1..a9745d1a6a5 100644
--- a/homeassistant/components/anthropic/__init__.py
+++ b/homeassistant/components/anthropic/__init__.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from functools import partial
+
import anthropic
from homeassistant.config_entries import ConfigEntry
@@ -10,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
-from .const import DOMAIN, LOGGER
+from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL
PLATFORMS = (Platform.CONVERSATION,)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -20,14 +22,13 @@ type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
"""Set up Anthropic from a config entry."""
- client = anthropic.AsyncAnthropic(api_key=entry.data[CONF_API_KEY])
+ client = await hass.async_add_executor_job(
+ partial(anthropic.AsyncAnthropic, api_key=entry.data[CONF_API_KEY])
+ )
try:
- await client.messages.create(
- model="claude-3-haiku-20240307",
- max_tokens=1,
- messages=[{"role": "user", "content": "Hi"}],
- timeout=10.0,
- )
+ model_id = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
+ model = await client.models.retrieve(model_id=model_id, timeout=10.0)
+ LOGGER.debug("Anthropic model: %s", model.display_name)
except anthropic.AuthenticationError as err:
LOGGER.error("Invalid API key: %s", err)
return False
diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py
index fa43a3c4bcc..1b6289efe7c 100644
--- a/homeassistant/components/anthropic/config_flow.py
+++ b/homeassistant/components/anthropic/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from functools import partial
import logging
from types import MappingProxyType
from typing import Any
@@ -33,10 +34,12 @@ from .const import (
CONF_PROMPT,
CONF_RECOMMENDED,
CONF_TEMPERATURE,
+ CONF_THINKING_BUDGET,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
+ RECOMMENDED_THINKING_BUDGET,
)
_LOGGER = logging.getLogger(__name__)
@@ -49,7 +52,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
RECOMMENDED_OPTIONS = {
CONF_RECOMMENDED: True,
- CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
+ CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
@@ -59,13 +62,10 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
- client = anthropic.AsyncAnthropic(api_key=data[CONF_API_KEY])
- await client.messages.create(
- model="claude-3-haiku-20240307",
- max_tokens=1,
- messages=[{"role": "user", "content": "Hi"}],
- timeout=10.0,
+ client = await hass.async_add_executor_job(
+ partial(anthropic.AsyncAnthropic, api_key=data[CONF_API_KEY])
)
+ await client.models.list(timeout=10.0)
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -130,25 +130,36 @@ class AnthropicOptionsFlow(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the options."""
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
+ errors: dict[str, str] = {}
if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
- if user_input[CONF_LLM_HASS_API] == "none":
- user_input.pop(CONF_LLM_HASS_API)
- return self.async_create_entry(title="", data=user_input)
+ if not user_input.get(CONF_LLM_HASS_API):
+ user_input.pop(CONF_LLM_HASS_API, None)
+ if user_input.get(
+ CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET
+ ) >= user_input.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS):
+ errors[CONF_THINKING_BUDGET] = "thinking_budget_too_large"
- # Re-render the options again, now with the recommended options shown/hidden
- self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
+ if not errors:
+ return self.async_create_entry(title="", data=user_input)
+ else:
+ # Re-render the options again, now with the recommended options shown/hidden
+ self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
- options = {
- CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
- CONF_PROMPT: user_input[CONF_PROMPT],
- CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
- }
+ options = {
+ CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
+ CONF_PROMPT: user_input[CONF_PROMPT],
+ CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API),
+ }
suggested_values = options.copy()
if not suggested_values.get(CONF_PROMPT):
suggested_values[CONF_PROMPT] = llm.DEFAULT_INSTRUCTIONS_PROMPT
+ if (
+ suggested_llm_apis := suggested_values.get(CONF_LLM_HASS_API)
+ ) and isinstance(suggested_llm_apis, str):
+ suggested_values[CONF_LLM_HASS_API] = [suggested_llm_apis]
schema = self.add_suggested_values_to_schema(
vol.Schema(anthropic_config_option_schema(self.hass, options)),
@@ -158,6 +169,7 @@ class AnthropicOptionsFlow(OptionsFlow):
return self.async_show_form(
step_id="init",
data_schema=schema,
+ errors=errors or None,
)
@@ -167,24 +179,18 @@ def anthropic_config_option_schema(
) -> dict:
"""Return a schema for Anthropic completion options."""
hass_apis: list[SelectOptionDict] = [
- SelectOptionDict(
- label="No control",
- value="none",
- )
- ]
- hass_apis.extend(
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
- )
+ ]
schema = {
vol.Optional(CONF_PROMPT): TemplateSelector(),
- vol.Optional(CONF_LLM_HASS_API, default="none"): SelectSelector(
- SelectSelectorConfig(options=hass_apis)
- ),
+ vol.Optional(
+ CONF_LLM_HASS_API,
+ ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,
@@ -207,6 +213,10 @@ def anthropic_config_option_schema(
CONF_TEMPERATURE,
default=RECOMMENDED_TEMPERATURE,
): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
+ vol.Optional(
+ CONF_THINKING_BUDGET,
+ default=RECOMMENDED_THINKING_BUDGET,
+ ): int,
}
)
return schema
diff --git a/homeassistant/components/anthropic/const.py b/homeassistant/components/anthropic/const.py
index 0dbf9c51ac1..38e4270e6e1 100644
--- a/homeassistant/components/anthropic/const.py
+++ b/homeassistant/components/anthropic/const.py
@@ -13,3 +13,8 @@ CONF_MAX_TOKENS = "max_tokens"
RECOMMENDED_MAX_TOKENS = 1024
CONF_TEMPERATURE = "temperature"
RECOMMENDED_TEMPERATURE = 1.0
+CONF_THINKING_BUDGET = "thinking_budget"
+RECOMMENDED_THINKING_BUDGET = 0
+MIN_THINKING_BUDGET = 1024
+
+THINKING_MODELS = ["claude-3-7-sonnet-20250219", "claude-3-7-sonnet-latest"]
diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py
index 259d1295809..288ec63509e 100644
--- a/homeassistant/components/anthropic/conversation.py
+++ b/homeassistant/components/anthropic/conversation.py
@@ -1,33 +1,46 @@
"""Conversation support for Anthropic."""
-from collections.abc import Callable
+from collections.abc import AsyncGenerator, Callable, Iterable
import json
from typing import Any, Literal, cast
import anthropic
+from anthropic import AsyncStream
from anthropic._types import NOT_GIVEN
from anthropic.types import (
- Message,
+ InputJSONDelta,
MessageParam,
+ MessageStreamEvent,
+ RawContentBlockDeltaEvent,
+ RawContentBlockStartEvent,
+ RawContentBlockStopEvent,
+ RawMessageStartEvent,
+ RawMessageStopEvent,
+ RedactedThinkingBlock,
+ RedactedThinkingBlockParam,
+ SignatureDelta,
TextBlock,
TextBlockParam,
+ TextDelta,
+ ThinkingBlock,
+ ThinkingBlockParam,
+ ThinkingConfigDisabledParam,
+ ThinkingConfigEnabledParam,
+ ThinkingDelta,
ToolParam,
ToolResultBlockParam,
ToolUseBlock,
ToolUseBlockParam,
)
-import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import conversation
-from homeassistant.components.conversation import trace
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError, TemplateError
-from homeassistant.helpers import device_registry as dr, intent, llm, template
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import ulid as ulid_util
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import device_registry as dr, intent, llm
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AnthropicConfigEntry
from .const import (
@@ -35,11 +48,15 @@ from .const import (
CONF_MAX_TOKENS,
CONF_PROMPT,
CONF_TEMPERATURE,
+ CONF_THINKING_BUDGET,
DOMAIN,
LOGGER,
+ MIN_THINKING_BUDGET,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_TEMPERATURE,
+ RECOMMENDED_THINKING_BUDGET,
+ THINKING_MODELS,
)
# Max number of back and forth with the LLM to generate a response
@@ -49,7 +66,7 @@ MAX_TOOL_ITERATIONS = 10
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AnthropicConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up conversation entities."""
agent = AnthropicConversationEntity(config_entry)
@@ -67,26 +84,211 @@ def _format_tool(
)
-def _message_convert(
- message: Message,
-) -> MessageParam:
- """Convert from class to TypedDict."""
- param_content: list[TextBlockParam | ToolUseBlockParam] = []
+def _convert_content(
+ chat_content: Iterable[conversation.Content],
+) -> list[MessageParam]:
+ """Transform HA chat_log content into Anthropic API format."""
+ messages: list[MessageParam] = []
- for message_content in message.content:
- if isinstance(message_content, TextBlock):
- param_content.append(TextBlockParam(type="text", text=message_content.text))
- elif isinstance(message_content, ToolUseBlock):
- param_content.append(
- ToolUseBlockParam(
- type="tool_use",
- id=message_content.id,
- name=message_content.name,
- input=message_content.input,
- )
+ for content in chat_content:
+ if isinstance(content, conversation.ToolResultContent):
+ tool_result_block = ToolResultBlockParam(
+ type="tool_result",
+ tool_use_id=content.tool_call_id,
+ content=json.dumps(content.tool_result),
)
+ if not messages or messages[-1]["role"] != "user":
+ messages.append(
+ MessageParam(
+ role="user",
+ content=[tool_result_block],
+ )
+ )
+ elif isinstance(messages[-1]["content"], str):
+ messages[-1]["content"] = [
+ TextBlockParam(type="text", text=messages[-1]["content"]),
+ tool_result_block,
+ ]
+ else:
+ messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
+ elif isinstance(content, conversation.UserContent):
+ # Combine consequent user messages
+ if not messages or messages[-1]["role"] != "user":
+ messages.append(
+ MessageParam(
+ role="user",
+ content=content.content,
+ )
+ )
+ elif isinstance(messages[-1]["content"], str):
+ messages[-1]["content"] = [
+ TextBlockParam(type="text", text=messages[-1]["content"]),
+ TextBlockParam(type="text", text=content.content),
+ ]
+ else:
+ messages[-1]["content"].append( # type: ignore[attr-defined]
+ TextBlockParam(type="text", text=content.content)
+ )
+ elif isinstance(content, conversation.AssistantContent):
+ # Combine consequent assistant messages
+ if not messages or messages[-1]["role"] != "assistant":
+ messages.append(
+ MessageParam(
+ role="assistant",
+ content=[],
+ )
+ )
- return MessageParam(role=message.role, content=param_content)
+ if content.content:
+ messages[-1]["content"].append( # type: ignore[union-attr]
+ TextBlockParam(type="text", text=content.content)
+ )
+ if content.tool_calls:
+ messages[-1]["content"].extend( # type: ignore[union-attr]
+ [
+ ToolUseBlockParam(
+ type="tool_use",
+ id=tool_call.id,
+ name=tool_call.tool_name,
+ input=tool_call.tool_args,
+ )
+ for tool_call in content.tool_calls
+ ]
+ )
+ else:
+ # Note: We don't pass SystemContent here as its passed to the API as the prompt
+ raise TypeError(f"Unexpected content type: {type(content)}")
+
+ return messages
+
+
+async def _transform_stream(
+ result: AsyncStream[MessageStreamEvent],
+ messages: list[MessageParam],
+) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
+ """Transform the response stream into HA format.
+
+ A typical stream of responses might look something like the following:
+ - RawMessageStartEvent with no content
+ - RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
+ - RawContentBlockDeltaEvent with a ThinkingDelta
+ - RawContentBlockDeltaEvent with a ThinkingDelta
+ - RawContentBlockDeltaEvent with a ThinkingDelta
+ - ...
+ - RawContentBlockDeltaEvent with a SignatureDelta
+ - RawContentBlockStopEvent
+ - RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
+ - RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
+ - RawContentBlockStartEvent with an empty TextBlock
+ - RawContentBlockDeltaEvent with a TextDelta
+ - RawContentBlockDeltaEvent with a TextDelta
+ - RawContentBlockDeltaEvent with a TextDelta
+ - ...
+ - RawContentBlockStopEvent
+ - RawContentBlockStartEvent with ToolUseBlock specifying the function name
+ - RawContentBlockDeltaEvent with a InputJSONDelta
+ - RawContentBlockDeltaEvent with a InputJSONDelta
+ - ...
+ - RawContentBlockStopEvent
+ - RawMessageDeltaEvent with a stop_reason='tool_use'
+ - RawMessageStopEvent(type='message_stop')
+
+ Each message could contain multiple blocks of the same type.
+ """
+ if result is None:
+ raise TypeError("Expected a stream of messages")
+
+ current_message: MessageParam | None = None
+ current_block: (
+ TextBlockParam
+ | ToolUseBlockParam
+ | ThinkingBlockParam
+ | RedactedThinkingBlockParam
+ | None
+ ) = None
+ current_tool_args: str
+
+ async for response in result:
+ LOGGER.debug("Received response: %s", response)
+
+ if isinstance(response, RawMessageStartEvent):
+ if response.message.role != "assistant":
+ raise ValueError("Unexpected message role")
+ current_message = MessageParam(role=response.message.role, content=[])
+ elif isinstance(response, RawContentBlockStartEvent):
+ if isinstance(response.content_block, ToolUseBlock):
+ current_block = ToolUseBlockParam(
+ type="tool_use",
+ id=response.content_block.id,
+ name=response.content_block.name,
+ input="",
+ )
+ current_tool_args = ""
+ elif isinstance(response.content_block, TextBlock):
+ current_block = TextBlockParam(
+ type="text", text=response.content_block.text
+ )
+ yield {"role": "assistant"}
+ if response.content_block.text:
+ yield {"content": response.content_block.text}
+ elif isinstance(response.content_block, ThinkingBlock):
+ current_block = ThinkingBlockParam(
+ type="thinking",
+ thinking=response.content_block.thinking,
+ signature=response.content_block.signature,
+ )
+ elif isinstance(response.content_block, RedactedThinkingBlock):
+ current_block = RedactedThinkingBlockParam(
+ type="redacted_thinking", data=response.content_block.data
+ )
+ LOGGER.debug(
+ "Some of Claude’s internal reasoning has been automatically "
+ "encrypted for safety reasons. This doesn’t affect the quality of "
+ "responses"
+ )
+ elif isinstance(response, RawContentBlockDeltaEvent):
+ if current_block is None:
+ raise ValueError("Unexpected delta without a block")
+ if isinstance(response.delta, InputJSONDelta):
+ current_tool_args += response.delta.partial_json
+ elif isinstance(response.delta, TextDelta):
+ text_block = cast(TextBlockParam, current_block)
+ text_block["text"] += response.delta.text
+ yield {"content": response.delta.text}
+ elif isinstance(response.delta, ThinkingDelta):
+ thinking_block = cast(ThinkingBlockParam, current_block)
+ thinking_block["thinking"] += response.delta.thinking
+ elif isinstance(response.delta, SignatureDelta):
+ thinking_block = cast(ThinkingBlockParam, current_block)
+ thinking_block["signature"] += response.delta.signature
+ elif isinstance(response, RawContentBlockStopEvent):
+ if current_block is None:
+ raise ValueError("Unexpected stop event without a current block")
+ if current_block["type"] == "tool_use":
+ tool_block = cast(ToolUseBlockParam, current_block)
+ tool_args = json.loads(current_tool_args) if current_tool_args else {}
+ tool_block["input"] = tool_args
+ yield {
+ "tool_calls": [
+ llm.ToolInput(
+ id=tool_block["id"],
+ tool_name=tool_block["name"],
+ tool_args=tool_args,
+ )
+ ]
+ }
+ elif current_block["type"] == "thinking":
+ thinking_block = cast(ThinkingBlockParam, current_block)
+ LOGGER.debug("Thinking: %s", thinking_block["thinking"])
+
+ if current_message is None:
+ raise ValueError("Unexpected stop event without a current message")
+ current_message["content"].append(current_block) # type: ignore[union-attr]
+ current_block = None
+ elif isinstance(response, RawMessageStopEvent):
+ if current_message is not None:
+ messages.append(current_message)
+ current_message = None
class AnthropicConversationEntity(
@@ -100,7 +302,6 @@ class AnthropicConversationEntity(
def __init__(self, entry: AnthropicConfigEntry) -> None:
"""Initialize the agent."""
self.entry = entry
- self.history: dict[str, list[MessageParam]] = {}
self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
@@ -125,188 +326,92 @@ class AnthropicConversationEntity(
self.entry.add_update_listener(self._async_entry_update_listener)
)
- async def async_process(
- self, user_input: conversation.ConversationInput
+ async def _async_handle_message(
+ self,
+ user_input: conversation.ConversationInput,
+ chat_log: conversation.ChatLog,
) -> conversation.ConversationResult:
- """Process a sentence."""
+ """Call the API."""
options = self.entry.options
- intent_response = intent.IntentResponse(language=user_input.language)
- llm_api: llm.APIInstance | None = None
- tools: list[ToolParam] | None = None
- user_name: str | None = None
- llm_context = llm.LLMContext(
- platform=DOMAIN,
- context=user_input.context,
- user_prompt=user_input.text,
- language=user_input.language,
- assistant=conversation.DOMAIN,
- device_id=user_input.device_id,
- )
-
- if options.get(CONF_LLM_HASS_API):
- try:
- llm_api = await llm.async_get_api(
- self.hass,
- options[CONF_LLM_HASS_API],
- llm_context,
- )
- except HomeAssistantError as err:
- LOGGER.error("Error getting LLM API: %s", err)
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- f"Error preparing LLM API: {err}",
- )
- return conversation.ConversationResult(
- response=intent_response, conversation_id=user_input.conversation_id
- )
- tools = [
- _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools
- ]
-
- if user_input.conversation_id is None:
- conversation_id = ulid_util.ulid_now()
- messages = []
-
- elif user_input.conversation_id in self.history:
- conversation_id = user_input.conversation_id
- messages = self.history[conversation_id]
-
- else:
- # Conversation IDs are ULIDs. We generate a new one if not provided.
- # If an old OLID is passed in, we will generate a new one to indicate
- # a new conversation was started. If the user picks their own, they
- # want to track a conversation and we respect it.
- try:
- ulid_util.ulid_to_bytes(user_input.conversation_id)
- conversation_id = ulid_util.ulid_now()
- except ValueError:
- conversation_id = user_input.conversation_id
-
- messages = []
-
- if (
- user_input.context
- and user_input.context.user_id
- and (
- user := await self.hass.auth.async_get_user(user_input.context.user_id)
- )
- ):
- user_name = user.name
try:
- prompt_parts = [
- template.Template(
- llm.BASE_PROMPT
- + options.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT),
- self.hass,
- ).async_render(
- {
- "ha_name": self.hass.config.location_name,
- "user_name": user_name,
- "llm_context": llm_context,
- },
- parse_result=False,
- )
+ await chat_log.async_update_llm_data(
+ DOMAIN,
+ user_input,
+ options.get(CONF_LLM_HASS_API),
+ options.get(CONF_PROMPT),
+ )
+ except conversation.ConverseError as err:
+ return err.as_conversation_result()
+
+ tools: list[ToolParam] | None = None
+ if chat_log.llm_api:
+ tools = [
+ _format_tool(tool, chat_log.llm_api.custom_serializer)
+ for tool in chat_log.llm_api.tools
]
- except TemplateError as err:
- LOGGER.error("Error rendering prompt: %s", err)
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- f"Sorry, I had a problem with my template: {err}",
- )
- return conversation.ConversationResult(
- response=intent_response, conversation_id=conversation_id
- )
-
- if llm_api:
- prompt_parts.append(llm_api.api_prompt)
-
- prompt = "\n".join(prompt_parts)
-
- # Create a copy of the variable because we attach it to the trace
- messages = [*messages, MessageParam(role="user", content=user_input.text)]
-
- LOGGER.debug("Prompt: %s", messages)
- LOGGER.debug("Tools: %s", tools)
- trace.async_conversation_trace_append(
- trace.ConversationTraceEventType.AGENT_DETAIL,
- {"system": prompt, "messages": messages},
- )
+ system = chat_log.content[0]
+ if not isinstance(system, conversation.SystemContent):
+ raise TypeError("First message must be a system message")
+ messages = _convert_content(chat_log.content[1:])
client = self.entry.runtime_data
+ thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
+ model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
+
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
+ model_args = {
+ "model": model,
+ "messages": messages,
+ "tools": tools or NOT_GIVEN,
+ "max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
+ "system": system.content,
+ "stream": True,
+ }
+ if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
+ model_args["thinking"] = ThinkingConfigEnabledParam(
+ type="enabled", budget_tokens=thinking_budget
+ )
+ else:
+ model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
+ model_args["temperature"] = options.get(
+ CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
+ )
+
try:
- response = await client.messages.create(
- model=options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
- messages=messages,
- tools=tools or NOT_GIVEN,
- max_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
- system=prompt,
- temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
- )
+ stream = await client.messages.create(**model_args)
except anthropic.AnthropicError as err:
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- f"Sorry, I had a problem talking to Anthropic: {err}",
- )
- return conversation.ConversationResult(
- response=intent_response, conversation_id=conversation_id
+ raise HomeAssistantError(
+ f"Sorry, I had a problem talking to Anthropic: {err}"
+ ) from err
+
+ messages.extend(
+ _convert_content(
+ [
+ content
+ async for content in chat_log.async_add_delta_content_stream(
+ user_input.agent_id, _transform_stream(stream, messages)
+ )
+ if not isinstance(content, conversation.AssistantContent)
+ ]
)
+ )
- LOGGER.debug("Response %s", response)
-
- messages.append(_message_convert(response))
-
- if response.stop_reason != "tool_use" or not llm_api:
- break
-
- tool_results: list[ToolResultBlockParam] = []
- for tool_call in response.content:
- if isinstance(tool_call, TextBlock):
- LOGGER.info(tool_call.text)
-
- if not isinstance(tool_call, ToolUseBlock):
- continue
-
- tool_input = llm.ToolInput(
- id=tool_call.id,
- tool_name=tool_call.name,
- tool_args=cast(dict[str, Any], tool_call.input),
- )
- LOGGER.debug(
- "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args
- )
-
- try:
- tool_response = await llm_api.async_call_tool(tool_input)
- except (HomeAssistantError, vol.Invalid) as e:
- tool_response = {"error": type(e).__name__}
- if str(e):
- tool_response["error_text"] = str(e)
-
- LOGGER.debug("Tool response: %s", tool_response)
- tool_results.append(
- ToolResultBlockParam(
- type="tool_result",
- tool_use_id=tool_call.id,
- content=json.dumps(tool_response),
- )
- )
-
- messages.append(MessageParam(role="user", content=tool_results))
-
- self.history[conversation_id] = messages
-
- for content in response.content:
- if isinstance(content, TextBlock):
- intent_response.async_set_speech(content.text)
+ if not chat_log.unresponded_tool_results:
break
+ response_content = chat_log.content[-1]
+ if not isinstance(response_content, conversation.AssistantContent):
+ raise TypeError("Last message must be an assistant message")
+ intent_response = intent.IntentResponse(language=user_input.language)
+ intent_response.async_set_speech(response_content.content or "")
return conversation.ConversationResult(
- response=intent_response, conversation_id=conversation_id
+ response=intent_response,
+ conversation_id=chat_log.conversation_id,
+ continue_conversation=chat_log.continue_conversation,
)
async def _async_entry_update_listener(
diff --git a/homeassistant/components/anthropic/manifest.json b/homeassistant/components/anthropic/manifest.json
index b5cbb36c034..797a7299d16 100644
--- a/homeassistant/components/anthropic/manifest.json
+++ b/homeassistant/components/anthropic/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/anthropic",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["anthropic==0.44.0"]
+ "requirements": ["anthropic==0.47.2"]
}
diff --git a/homeassistant/components/anthropic/strings.json b/homeassistant/components/anthropic/strings.json
index 9550a1a6672..c2caf3a6666 100644
--- a/homeassistant/components/anthropic/strings.json
+++ b/homeassistant/components/anthropic/strings.json
@@ -23,12 +23,17 @@
"max_tokens": "Maximum tokens to return in response",
"temperature": "Temperature",
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
- "recommended": "Recommended model settings"
+ "recommended": "Recommended model settings",
+ "thinking_budget_tokens": "Thinking budget"
},
"data_description": {
- "prompt": "Instruct how the LLM should respond. This can be a template."
+ "prompt": "Instruct how the LLM should respond. This can be a template.",
+ "thinking_budget_tokens": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking."
}
}
+ },
+ "error": {
+ "thinking_budget_too_large": "Maximum tokens must be greater than the thinking budget."
}
}
}
diff --git a/homeassistant/components/aosmith/sensor.py b/homeassistant/components/aosmith/sensor.py
index 8a7a98115fa..071407e7b17 100644
--- a/homeassistant/components/aosmith/sensor.py
+++ b/homeassistant/components/aosmith/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfEnergy
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
AOSmithConfigEntry,
@@ -43,7 +43,7 @@ STATUS_ENTITY_DESCRIPTIONS: tuple[AOSmithStatusSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AOSmithConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up A. O. Smith sensor platform."""
data = entry.runtime_data
diff --git a/homeassistant/components/aosmith/water_heater.py b/homeassistant/components/aosmith/water_heater.py
index 110f997065b..d29b00955b6 100644
--- a/homeassistant/components/aosmith/water_heater.py
+++ b/homeassistant/components/aosmith/water_heater.py
@@ -15,7 +15,7 @@ from homeassistant.components.water_heater import (
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AOSmithConfigEntry, AOSmithStatusCoordinator
from .entity import AOSmithStatusEntity
@@ -45,7 +45,7 @@ DEFAULT_OPERATION_MODE_PRIORITY = [
async def async_setup_entry(
hass: HomeAssistant,
entry: AOSmithConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up A. O. Smith water heater platform."""
data = entry.runtime_data
diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py
index 2a44845618e..dfeb56c8d06 100644
--- a/homeassistant/components/apcupsd/binary_sensor.py
+++ b/homeassistant/components/apcupsd/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import APCUPSdConfigEntry, APCUPSdCoordinator
@@ -27,7 +27,7 @@ _VALUE_ONLINE_MASK: Final = 0b1000
async def async_setup_entry(
hass: HomeAssistant,
config_entry: APCUPSdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an APCUPSd Online Status binary sensor."""
coordinator = config_entry.runtime_data
@@ -53,10 +53,8 @@ class OnlineStatus(CoordinatorEntity[APCUPSdCoordinator], BinarySensorEntity):
"""Initialize the APCUPSd binary device."""
super().__init__(coordinator, context=description.key.upper())
- # Set up unique id and device info if serial number is available.
- if (serial_no := coordinator.data.serial_no) is not None:
- self._attr_unique_id = f"{serial_no}_{description.key}"
self.entity_description = description
+ self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
self._attr_device_info = coordinator.device_info
@property
diff --git a/homeassistant/components/apcupsd/coordinator.py b/homeassistant/components/apcupsd/coordinator.py
index e2c1af50cee..4e663725303 100644
--- a/homeassistant/components/apcupsd/coordinator.py
+++ b/homeassistant/components/apcupsd/coordinator.py
@@ -85,11 +85,16 @@ class APCUPSdCoordinator(DataUpdateCoordinator[APCUPSdData]):
self._host = host
self._port = port
+ @property
+ def unique_device_id(self) -> str:
+ """Return a unique ID of the device, which is the serial number (if available) or the config entry ID."""
+ return self.data.serial_no or self.config_entry.entry_id
+
@property
def device_info(self) -> DeviceInfo:
"""Return the DeviceInfo of this APC UPS, if serial number is available."""
return DeviceInfo(
- identifiers={(DOMAIN, self.data.serial_no or self.config_entry.entry_id)},
+ identifiers={(DOMAIN, self.unique_device_id)},
model=self.data.model,
manufacturer="APC",
name=self.data.name or "APC UPS",
diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py
index b3c396daf5e..a3faf6b0268 100644
--- a/homeassistant/components/apcupsd/sensor.py
+++ b/homeassistant/components/apcupsd/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import LAST_S_TEST
@@ -406,7 +406,7 @@ INFERRED_UNITS = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: APCUPSdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the APCUPSd sensors from config entries."""
coordinator = config_entry.runtime_data
@@ -458,11 +458,8 @@ class APCUPSdSensor(CoordinatorEntity[APCUPSdCoordinator], SensorEntity):
"""Initialize the sensor."""
super().__init__(coordinator=coordinator, context=description.key.upper())
- # Set up unique id and device info if serial number is available.
- if (serial_no := coordinator.data.serial_no) is not None:
- self._attr_unique_id = f"{serial_no}_{description.key}"
-
self.entity_description = description
+ self._attr_unique_id = f"{coordinator.unique_device_id}_{description.key}"
self._attr_device_info = coordinator.device_info
# Initial update of attributes.
diff --git a/homeassistant/components/apcupsd/strings.json b/homeassistant/components/apcupsd/strings.json
index 93102ac1393..fb5df9ec390 100644
--- a/homeassistant/components/apcupsd/strings.json
+++ b/homeassistant/components/apcupsd/strings.json
@@ -57,7 +57,7 @@
"name": "Status date"
},
"dip_switch_settings": {
- "name": "Dip switch settings"
+ "name": "DIP switch settings"
},
"low_battery_signal": {
"name": "Low battery signal"
diff --git a/homeassistant/components/apollo_automation/__init__.py b/homeassistant/components/apollo_automation/__init__.py
new file mode 100644
index 00000000000..7815b17818f
--- /dev/null
+++ b/homeassistant/components/apollo_automation/__init__.py
@@ -0,0 +1 @@
+"""Virtual integration: Apollo Automation."""
diff --git a/homeassistant/components/apollo_automation/manifest.json b/homeassistant/components/apollo_automation/manifest.json
new file mode 100644
index 00000000000..8e4c58f3f3d
--- /dev/null
+++ b/homeassistant/components/apollo_automation/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "apollo_automation",
+ "name": "Apollo Automation",
+ "integration_type": "virtual",
+ "supported_by": "esphome"
+}
diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py
index f4417134b37..b911b3cec99 100644
--- a/homeassistant/components/apple_tv/__init__.py
+++ b/homeassistant/components/apple_tv/__init__.py
@@ -233,7 +233,6 @@ class AppleTVManager(DeviceListener):
pass
except Exception:
_LOGGER.exception("Failed to connect")
- await self.disconnect()
async def _connect_loop(self) -> None:
"""Connect loop background task function."""
diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py
index 76c4681a30d..b026da33231 100644
--- a/homeassistant/components/apple_tv/config_flow.py
+++ b/homeassistant/components/apple_tv/config_flow.py
@@ -20,6 +20,7 @@ import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_IGNORE,
+ SOURCE_REAUTH,
SOURCE_ZEROCONF,
ConfigEntry,
ConfigFlow,
@@ -381,7 +382,9 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_IDENTIFIERS: list(combined_identifiers),
},
)
- if entry.source != SOURCE_IGNORE:
+ # Don't reload ignored entries or in the middle of reauth,
+ # e.g. if the user is entering a new PIN
+ if entry.source != SOURCE_IGNORE and self.source != SOURCE_REAUTH:
self.hass.config_entries.async_schedule_reload(entry.entry_id)
if not allow_exist:
raise DeviceAlreadyConfigured
diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py
index 8a2336eea3b..b6d451a9ea0 100644
--- a/homeassistant/components/apple_tv/media_player.py
+++ b/homeassistant/components/apple_tv/media_player.py
@@ -39,7 +39,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import AppleTvConfigEntry, AppleTVManager
@@ -100,7 +100,7 @@ SUPPORT_FEATURE_MAPPING = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AppleTvConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Apple TV media player based on a config entry."""
name: str = config_entry.data[CONF_NAME]
@@ -120,6 +120,7 @@ class AppleTvMediaPlayer(
"""Initialize the Apple TV media player."""
super().__init__(name, identifier, manager)
self._playing: Playing | None = None
+ self._playing_last_updated: datetime | None = None
self._app_list: dict[str, str] = {}
@callback
@@ -209,6 +210,7 @@ class AppleTvMediaPlayer(
This is a callback function from pyatv.interface.PushListener.
"""
self._playing = playstatus
+ self._playing_last_updated = dt_util.utcnow()
self.async_write_ha_state()
@callback
@@ -316,7 +318,7 @@ class AppleTvMediaPlayer(
def media_position_updated_at(self) -> datetime | None:
"""Last valid time of media position."""
if self.state in {MediaPlayerState.PLAYING, MediaPlayerState.PAUSED}:
- return dt_util.utcnow()
+ return self._playing_last_updated
return None
async def async_play_media(
diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py
index 7f2c9f1b591..97e31bd4bb0 100644
--- a/homeassistant/components/apple_tv/remote.py
+++ b/homeassistant/components/apple_tv/remote.py
@@ -17,7 +17,7 @@ from homeassistant.components.remote import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AppleTvConfigEntry
from .entity import AppleTVEntity
@@ -38,7 +38,7 @@ COMMAND_TO_ATTRIBUTE = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AppleTvConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Apple TV remote based on a config entry."""
name: str = config_entry.data[CONF_NAME]
diff --git a/homeassistant/components/aprilaire/climate.py b/homeassistant/components/aprilaire/climate.py
index 194453046e6..5116e06b58f 100644
--- a/homeassistant/components/aprilaire/climate.py
+++ b/homeassistant/components/aprilaire/climate.py
@@ -18,7 +18,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import PRECISION_HALVES, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
FAN_CIRCULATE,
@@ -63,7 +63,7 @@ FAN_MODE_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AprilaireConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add climates for passed config_entry in HA."""
diff --git a/homeassistant/components/aprilaire/humidifier.py b/homeassistant/components/aprilaire/humidifier.py
index 8a173e5e95e..fdb9233a0e3 100644
--- a/homeassistant/components/aprilaire/humidifier.py
+++ b/homeassistant/components/aprilaire/humidifier.py
@@ -15,7 +15,7 @@ from homeassistant.components.humidifier import (
HumidifierEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import AprilaireConfigEntry, AprilaireCoordinator
@@ -40,7 +40,7 @@ DEHUMIDIFIER_ACTION_MAP: dict[StateType, HumidifierAction] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AprilaireConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aprilaire humidifier devices."""
diff --git a/homeassistant/components/aprilaire/manifest.json b/homeassistant/components/aprilaire/manifest.json
index 577de8ae88d..b40460dd61b 100644
--- a/homeassistant/components/aprilaire/manifest.json
+++ b/homeassistant/components/aprilaire/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["pyaprilaire"],
- "requirements": ["pyaprilaire==0.7.7"]
+ "requirements": ["pyaprilaire==0.8.1"]
}
diff --git a/homeassistant/components/aprilaire/select.py b/homeassistant/components/aprilaire/select.py
index d8f6137f53d..c38c9e94501 100644
--- a/homeassistant/components/aprilaire/select.py
+++ b/homeassistant/components/aprilaire/select.py
@@ -10,7 +10,7 @@ from pyaprilaire.const import Attribute
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AprilaireConfigEntry, AprilaireCoordinator
from .entity import BaseAprilaireEntity
@@ -24,7 +24,7 @@ FRESH_AIR_MODE_MAP = {0: "off", 1: "automatic"}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AprilaireConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aprilaire select devices."""
diff --git a/homeassistant/components/aprilaire/sensor.py b/homeassistant/components/aprilaire/sensor.py
index e1909746364..bf3bd12f43d 100644
--- a/homeassistant/components/aprilaire/sensor.py
+++ b/homeassistant/components/aprilaire/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import AprilaireConfigEntry, AprilaireCoordinator
@@ -75,7 +75,7 @@ def get_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AprilaireConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Aprilaire sensor devices."""
diff --git a/homeassistant/components/apsystems/binary_sensor.py b/homeassistant/components/apsystems/binary_sensor.py
index 863a50ca455..202d878014d 100644
--- a/homeassistant/components/apsystems/binary_sensor.py
+++ b/homeassistant/components/apsystems/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ApSystemsConfigEntry, ApSystemsData, ApSystemsDataCoordinator
@@ -63,7 +63,7 @@ BINARY_SENSORS: tuple[ApsystemsLocalApiBinarySensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ApSystemsConfigEntry,
- add_entities: AddEntitiesCallback,
+ add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary sensor platform."""
config = config_entry.runtime_data
diff --git a/homeassistant/components/apsystems/coordinator.py b/homeassistant/components/apsystems/coordinator.py
index ca423055176..f7f1039b8a4 100644
--- a/homeassistant/components/apsystems/coordinator.py
+++ b/homeassistant/components/apsystems/coordinator.py
@@ -43,6 +43,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
config_entry: ApSystemsConfigEntry
device_version: str
+ battery_system: bool
def __init__(
self,
@@ -68,6 +69,7 @@ class ApSystemsDataCoordinator(DataUpdateCoordinator[ApSystemsSensorData]):
self.api.max_power = device_info.maxPower
self.api.min_power = device_info.minPower
self.device_version = device_info.devVer
+ self.battery_system = device_info.isBatterySystem
async def _async_update_data(self) -> ApSystemsSensorData:
try:
diff --git a/homeassistant/components/apsystems/entity.py b/homeassistant/components/apsystems/entity.py
index 9ba7d046b60..2ce8becbf80 100644
--- a/homeassistant/components/apsystems/entity.py
+++ b/homeassistant/components/apsystems/entity.py
@@ -19,10 +19,20 @@ class ApSystemsEntity(Entity):
data: ApSystemsData,
) -> None:
"""Initialize the APsystems entity."""
+
+ # Handle device version safely
+ sw_version = None
+ if data.coordinator.device_version:
+ version_parts = data.coordinator.device_version.split(" ")
+ if len(version_parts) > 1:
+ sw_version = version_parts[1]
+ else:
+ sw_version = version_parts[0]
+
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, data.device_id)},
manufacturer="APsystems",
model="EZ1-M",
serial_number=data.device_id,
- sw_version=data.coordinator.device_version.split(" ")[1],
+ sw_version=sw_version,
)
diff --git a/homeassistant/components/apsystems/manifest.json b/homeassistant/components/apsystems/manifest.json
index a58530b05e2..934a155c500 100644
--- a/homeassistant/components/apsystems/manifest.json
+++ b/homeassistant/components/apsystems/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/apsystems",
"integration_type": "device",
"iot_class": "local_polling",
- "requirements": ["apsystems-ez1==2.4.0"]
+ "requirements": ["apsystems-ez1==2.5.0"]
}
diff --git a/homeassistant/components/apsystems/number.py b/homeassistant/components/apsystems/number.py
index f7bdc7c2711..b43b21f2b71 100644
--- a/homeassistant/components/apsystems/number.py
+++ b/homeassistant/components/apsystems/number.py
@@ -7,7 +7,7 @@ from aiohttp import ClientConnectorError
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import DiscoveryInfoType
from .coordinator import ApSystemsConfigEntry, ApSystemsData
@@ -17,7 +17,7 @@ from .entity import ApSystemsEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ApSystemsConfigEntry,
- add_entities: AddEntitiesCallback,
+ add_entities: AddConfigEntryEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the sensor platform."""
diff --git a/homeassistant/components/apsystems/sensor.py b/homeassistant/components/apsystems/sensor.py
index 673dba05acc..6e654cfbf61 100644
--- a/homeassistant/components/apsystems/sensor.py
+++ b/homeassistant/components/apsystems/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import DiscoveryInfoType, StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -109,7 +109,7 @@ SENSORS: tuple[ApsystemsLocalApiSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ApSystemsConfigEntry,
- add_entities: AddEntitiesCallback,
+ add_entities: AddConfigEntryEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the sensor platform."""
diff --git a/homeassistant/components/apsystems/switch.py b/homeassistant/components/apsystems/switch.py
index 2d3b0cfd08f..5451f2885fe 100644
--- a/homeassistant/components/apsystems/switch.py
+++ b/homeassistant/components/apsystems/switch.py
@@ -9,7 +9,7 @@ from APsystemsEZ1 import InverterReturnedError
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ApSystemsConfigEntry, ApSystemsData
from .entity import ApSystemsEntity
@@ -18,7 +18,7 @@ from .entity import ApSystemsEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ApSystemsConfigEntry,
- add_entities: AddEntitiesCallback,
+ add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switch platform."""
@@ -36,6 +36,8 @@ class ApSystemsInverterSwitch(ApSystemsEntity, SwitchEntity):
super().__init__(data)
self._api = data.coordinator.api
self._attr_unique_id = f"{data.device_id}_inverter_status"
+ if data.coordinator.battery_system:
+ self._attr_available = False
async def async_update(self) -> None:
"""Update switch status and availability."""
diff --git a/homeassistant/components/aquacell/config_flow.py b/homeassistant/components/aquacell/config_flow.py
index 1ee89035d93..277cb742486 100644
--- a/homeassistant/components/aquacell/config_flow.py
+++ b/homeassistant/components/aquacell/config_flow.py
@@ -60,7 +60,7 @@ class AquaCellConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except AuthenticationFailed:
errors["base"] = "invalid_auth"
- except Exception: # pylint: disable=broad-except
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
diff --git a/homeassistant/components/aquacell/sensor.py b/homeassistant/components/aquacell/sensor.py
index a76d26244ad..77cd3cdd60a 100644
--- a/homeassistant/components/aquacell/sensor.py
+++ b/homeassistant/components/aquacell/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import AquacellConfigEntry, AquacellCoordinator
@@ -83,7 +83,7 @@ SENSORS: tuple[SoftenerSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AquacellConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors."""
softeners = config_entry.runtime_data.data
diff --git a/homeassistant/components/aquacell/strings.json b/homeassistant/components/aquacell/strings.json
index 53304d04804..e07adf3c199 100644
--- a/homeassistant/components/aquacell/strings.json
+++ b/homeassistant/components/aquacell/strings.json
@@ -36,9 +36,9 @@
"wi_fi_strength": {
"name": "Wi-Fi strength",
"state": {
- "low": "Low",
- "medium": "Medium",
- "high": "High"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
}
}
diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py
index b5187cba1f4..ee2eb8c8a75 100644
--- a/homeassistant/components/aranet/sensor.py
+++ b/homeassistant/components/aranet/sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
-from aranet4.client import Aranet4Advertisement
+from aranet4.client import Aranet4Advertisement, Color
from bleak.backends.device import BLEDevice
from homeassistant.components.bluetooth.passive_update_processor import (
@@ -35,7 +35,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AranetConfigEntry
from .const import ARANET_MANUFACTURER_NAME
@@ -74,6 +74,13 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfPressure.HPA,
state_class=SensorStateClass.MEASUREMENT,
),
+ "status": AranetSensorEntityDescription(
+ key="threshold",
+ translation_key="threshold",
+ name="Threshold",
+ device_class=SensorDeviceClass.ENUM,
+ options=[status.name.lower() for status in Color],
+ ),
"co2": AranetSensorEntityDescription(
key="co2",
name="Carbon Dioxide",
@@ -161,7 +168,10 @@ def sensor_update_to_bluetooth_data_update(
val = getattr(adv.readings, key)
if val == -1:
continue
- val *= desc.scale
+ if key == "status":
+ val = val.name.lower()
+ else:
+ val *= desc.scale
data[tag] = val
names[tag] = desc.name
descs[tag] = desc
@@ -176,7 +186,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: AranetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Aranet sensors."""
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
diff --git a/homeassistant/components/aranet/strings.json b/homeassistant/components/aranet/strings.json
index 1cc695637d4..f786f4b2d4d 100644
--- a/homeassistant/components/aranet/strings.json
+++ b/homeassistant/components/aranet/strings.json
@@ -21,5 +21,17 @@
"no_devices_found": "No unconfigured Aranet devices found.",
"outdated_version": "This device is using outdated firmware. Please update it to at least v1.2.0 and try again."
}
+ },
+ "entity": {
+ "sensor": {
+ "threshold": {
+ "state": {
+ "error": "Error",
+ "green": "Green",
+ "yellow": "Yellow",
+ "red": "Red"
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json
index 39d289f9cb1..41396eca5d6 100644
--- a/homeassistant/components/arcam_fmj/manifest.json
+++ b/homeassistant/components/arcam_fmj/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/arcam_fmj",
"iot_class": "local_polling",
"loggers": ["arcam"],
- "requirements": ["arcam-fmj==1.5.2"],
+ "requirements": ["arcam-fmj==1.8.1"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
diff --git a/homeassistant/components/arcam_fmj/media_player.py b/homeassistant/components/arcam_fmj/media_player.py
index 7a133777a0a..cd4ed7bbb05 100644
--- a/homeassistant/components/arcam_fmj/media_player.py
+++ b/homeassistant/components/arcam_fmj/media_player.py
@@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ArcamFmjConfigEntry
from .const import (
@@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ArcamFmjConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the configuration entry."""
diff --git a/homeassistant/components/arve/sensor.py b/homeassistant/components/arve/sensor.py
index 64d9f6f8874..dea7110f611 100644
--- a/homeassistant/components/arve/sensor.py
+++ b/homeassistant/components/arve/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ArveConfigEntry
from .entity import ArveDeviceEntity
@@ -83,7 +83,9 @@ SENSORS: tuple[ArveDeviceEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ArveConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ArveConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Arve device based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py
index ada96c07340..4cc4feed2d4 100644
--- a/homeassistant/components/arwn/sensor.py
+++ b/homeassistant/components/arwn/sensor.py
@@ -6,7 +6,11 @@ import logging
from typing import Any
from homeassistant.components import mqtt
-from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorStateClass,
+)
from homeassistant.const import DEGREE, UnitOfPrecipitationDepth, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -92,7 +96,13 @@ def discover_sensors(topic: str, payload: dict[str, Any]) -> list[ArwnSensor] |
device_class=SensorDeviceClass.WIND_SPEED,
),
ArwnSensor(
- topic + "/dir", "Wind Direction", "direction", DEGREE, "mdi:compass"
+ topic + "/dir",
+ "Wind Direction",
+ "direction",
+ DEGREE,
+ "mdi:compass",
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
]
return None
@@ -173,6 +183,7 @@ class ArwnSensor(SensorEntity):
units: str,
icon: str | None = None,
device_class: SensorDeviceClass | None = None,
+ state_class: SensorStateClass | None = None,
) -> None:
"""Initialize the sensor."""
self.entity_id = _slug(name)
@@ -183,6 +194,7 @@ class ArwnSensor(SensorEntity):
self._attr_native_unit_of_measurement = units
self._attr_icon = icon
self._attr_device_class = device_class
+ self._attr_state_class = state_class
def set_event(self, event: dict[str, Any]) -> None:
"""Update the sensor with the most recent event."""
diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py
index c8cc31dc795..15c72614ee1 100644
--- a/homeassistant/components/aseko_pool_live/binary_sensor.py
+++ b/homeassistant/components/aseko_pool_live/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AsekoConfigEntry
from .entity import AsekoEntity
@@ -37,7 +37,7 @@ BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AsekoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Aseko Pool Live binary sensors."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py
index 3fe7cdd5272..f9a7287a9f1 100644
--- a/homeassistant/components/aseko_pool_live/sensor.py
+++ b/homeassistant/components/aseko_pool_live/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfElectricPotential, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import AsekoConfigEntry
@@ -86,7 +86,7 @@ SENSORS: list[AsekoSensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AsekoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Aseko Pool Live sensors."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py
index 9a32821e3a0..59bd987d90e 100644
--- a/homeassistant/components/assist_pipeline/__init__.py
+++ b/homeassistant/components/assist_pipeline/__init__.py
@@ -117,7 +117,7 @@ async def async_pipeline_from_audio_stream(
"""
with chat_session.async_get_chat_session(hass, conversation_id) as session:
pipeline_input = PipelineInput(
- conversation_id=session.conversation_id,
+ session=session,
device_id=device_id,
stt_metadata=stt_metadata,
stt_stream=stt_stream,
diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py
index ef26e1a5a6d..a205db4e615 100644
--- a/homeassistant/components/assist_pipeline/pipeline.py
+++ b/homeassistant/components/assist_pipeline/pipeline.py
@@ -13,24 +13,17 @@ from pathlib import Path
from queue import Empty, Queue
from threading import Thread
import time
-from typing import Any, Literal, cast
+from typing import TYPE_CHECKING, Any, Literal, cast
import wave
import hass_nabucasa
import voluptuous as vol
-from homeassistant.components import (
- conversation,
- media_source,
- stt,
- tts,
- wake_word,
- websocket_api,
-)
+from homeassistant.components import conversation, stt, tts, wake_word, websocket_api
from homeassistant.components.tts import (
generate_media_source_id as tts_generate_media_source_id,
)
-from homeassistant.const import MATCH_ALL
+from homeassistant.const import ATTR_SUPPORTED_FEATURES, MATCH_ALL
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session, intent
@@ -81,6 +74,9 @@ from .error import (
)
from .vad import AudioBuffer, VoiceActivityTimeout, VoiceCommandSegmenter, chunk_samples
+if TYPE_CHECKING:
+ from hassil.recognize import RecognizeResult
+
_LOGGER = logging.getLogger(__name__)
STORAGE_KEY = f"{DOMAIN}.pipelines"
@@ -93,6 +89,9 @@ ENGINE_LANGUAGE_PAIRS = (
)
KEY_ASSIST_PIPELINE: HassKey[PipelineData] = HassKey(DOMAIN)
+KEY_PIPELINE_CONVERSATION_DATA: HassKey[dict[str, PipelineConversationData]] = HassKey(
+ "pipeline_conversation_data"
+)
def validate_language(data: dict[str, Any]) -> Any:
@@ -123,6 +122,12 @@ STORED_PIPELINE_RUNS = 10
SAVE_DELAY = 10
+@callback
+def _async_local_fallback_intent_filter(result: RecognizeResult) -> bool:
+ """Filter out intents that are not local fallback."""
+ return result.intent.name in (intent.INTENT_GET_STATE)
+
+
@callback
def _async_resolve_default_pipeline_settings(
hass: HomeAssistant,
@@ -374,6 +379,7 @@ class PipelineEventType(StrEnum):
STT_VAD_END = "stt-vad-end"
STT_END = "stt-end"
INTENT_START = "intent-start"
+ INTENT_PROGRESS = "intent-progress"
INTENT_END = "intent-end"
TTS_START = "tts-start"
TTS_END = "tts-end"
@@ -556,8 +562,7 @@ class PipelineRun:
id: str = field(default_factory=ulid_util.ulid_now)
stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False)
- tts_engine: str = field(init=False, repr=False)
- tts_options: dict | None = field(init=False, default=None)
+ tts_stream: tts.ResultStream | None = field(init=False, default=None)
wake_word_entity_id: str | None = field(init=False, default=None, repr=False)
wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False, repr=False)
@@ -580,6 +585,12 @@ class PipelineRun:
_device_id: str | None = None
"""Optional device id set during run start."""
+ _conversation_data: PipelineConversationData | None = None
+ """Data tied to the conversation ID."""
+
+ _intent_agent_only = False
+ """If request should only be handled by agent, ignoring sentence triggers and local processing."""
+
def __post_init__(self) -> None:
"""Set language for pipeline."""
self.language = self.pipeline.language or self.hass.config.language
@@ -629,13 +640,19 @@ class PipelineRun:
self._device_id = device_id
self._start_debug_recording_thread()
- data = {
+ data: dict[str, Any] = {
"pipeline": self.pipeline.id,
"language": self.language,
"conversation_id": conversation_id,
}
if self.runner_data is not None:
data["runner_data"] = self.runner_data
+ if self.tts_stream:
+ data["tts_output"] = {
+ "token": self.tts_stream.token,
+ "url": self.tts_stream.url,
+ "mime_type": self.tts_stream.content_type,
+ }
self.process_event(PipelineEvent(PipelineEventType.RUN_START, data))
@@ -997,19 +1014,36 @@ class PipelineRun:
yield chunk.audio
- async def prepare_recognize_intent(self) -> None:
+ async def prepare_recognize_intent(self, session: chat_session.ChatSession) -> None:
"""Prepare recognizing an intent."""
- agent_info = conversation.async_get_agent_info(
- self.hass,
- self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT,
+ self._conversation_data = async_get_pipeline_conversation_data(
+ self.hass, session
)
- if agent_info is None:
- engine = self.pipeline.conversation_engine or "default"
- raise IntentRecognitionError(
- code="intent-not-supported",
- message=f"Intent recognition engine {engine} is not found",
+ if self._conversation_data.continue_conversation_agent is not None:
+ agent_info = conversation.async_get_agent_info(
+ self.hass, self._conversation_data.continue_conversation_agent
)
+ self._conversation_data.continue_conversation_agent = None
+ if agent_info is None:
+ raise IntentRecognitionError(
+ code="intent-agent-not-found",
+ message=f"Intent recognition engine {self._conversation_data.continue_conversation_agent} asked for follow-up but is no longer found",
+ )
+ self._intent_agent_only = True
+
+ else:
+ agent_info = conversation.async_get_agent_info(
+ self.hass,
+ self.pipeline.conversation_engine or conversation.HOME_ASSISTANT_AGENT,
+ )
+
+ if agent_info is None:
+ engine = self.pipeline.conversation_engine or "default"
+ raise IntentRecognitionError(
+ code="intent-not-supported",
+ message=f"Intent recognition engine {engine} is not found",
+ )
self.intent_agent = agent_info.id
@@ -1021,7 +1055,7 @@ class PipelineRun:
conversation_extra_system_prompt: str | None,
) -> str:
"""Run intent recognition portion of pipeline. Returns text to speak."""
- if self.intent_agent is None:
+ if self.intent_agent is None or self._conversation_data is None:
raise RuntimeError("Recognize intent was not prepared")
if self.pipeline.conversation_language == MATCH_ALL:
@@ -1068,7 +1102,7 @@ class PipelineRun:
agent_id = self.intent_agent
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
intent_response: intent.IntentResponse | None = None
- if not processed_locally:
+ if not processed_locally and not self._intent_agent_only:
# Sentence triggers override conversation agent
if (
trigger_response_text
@@ -1083,16 +1117,46 @@ class PipelineRun:
)
intent_response.async_set_speech(trigger_response_text)
- # Try local intents first, if preferred.
- elif self.pipeline.prefer_local_intents and (
- intent_response := await conversation.async_handle_intents(
- self.hass, user_input
+ intent_filter: Callable[[RecognizeResult], bool] | None = None
+ # If the LLM has API access, we filter out some sentences that are
+ # interfering with LLM operation.
+ if (
+ intent_agent_state := self.hass.states.get(self.intent_agent)
+ ) and intent_agent_state.attributes.get(
+ ATTR_SUPPORTED_FEATURES, 0
+ ) & conversation.ConversationEntityFeature.CONTROL:
+ intent_filter = _async_local_fallback_intent_filter
+
+ # Try local intents
+ if (
+ intent_response is None
+ and self.pipeline.prefer_local_intents
+ and (
+ intent_response := await conversation.async_handle_intents(
+ self.hass,
+ user_input,
+ intent_filter=intent_filter,
+ )
)
):
# Local intent matched
agent_id = conversation.HOME_ASSISTANT_AGENT
processed_locally = True
+ @callback
+ def chat_log_delta_listener(
+ chat_log: conversation.ChatLog, delta: dict
+ ) -> None:
+ """Handle chat log delta."""
+ self.process_event(
+ PipelineEvent(
+ PipelineEventType.INTENT_PROGRESS,
+ {
+ "chat_log_delta": delta,
+ },
+ )
+ )
+
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
@@ -1101,6 +1165,7 @@ class PipelineRun:
self.hass,
session,
user_input,
+ chat_log_delta_listener=chat_log_delta_listener,
) as chat_log,
):
# It was already handled, create response and add to chat history
@@ -1154,6 +1219,9 @@ class PipelineRun:
)
)
+ if conversation_result.continue_conversation:
+ self._conversation_data.continue_conversation_agent = agent_id
+
return speech
async def prepare_text_to_speech(self) -> None:
@@ -1176,36 +1244,31 @@ class PipelineRun:
tts_options[tts.ATTR_PREFERRED_SAMPLE_BYTES] = SAMPLE_WIDTH
try:
- options_supported = await tts.async_support_options(
- self.hass,
- engine,
- self.pipeline.tts_language,
- tts_options,
+ self.tts_stream = tts.async_create_stream(
+ hass=self.hass,
+ engine=engine,
+ language=self.pipeline.tts_language,
+ options=tts_options,
)
except HomeAssistantError as err:
- raise TextToSpeechError(
- code="tts-not-supported",
- message=f"Text-to-speech engine '{engine}' not found",
- ) from err
- if not options_supported:
raise TextToSpeechError(
code="tts-not-supported",
message=(
f"Text-to-speech engine {engine} "
- f"does not support language {self.pipeline.tts_language} or options {tts_options}"
+ f"does not support language {self.pipeline.tts_language} or options {tts_options}:"
+ f" {err}"
),
- )
-
- self.tts_engine = engine
- self.tts_options = tts_options
+ ) from err
async def text_to_speech(self, tts_input: str) -> None:
"""Run text-to-speech portion of pipeline."""
+ assert self.tts_stream is not None
+
self.process_event(
PipelineEvent(
PipelineEventType.TTS_START,
{
- "engine": self.tts_engine,
+ "engine": self.tts_stream.engine,
"language": self.pipeline.tts_language,
"voice": self.pipeline.tts_voice,
"tts_input": tts_input,
@@ -1218,14 +1281,9 @@ class PipelineRun:
tts_media_id = tts_generate_media_source_id(
self.hass,
tts_input,
- engine=self.tts_engine,
- language=self.pipeline.tts_language,
- options=self.tts_options,
- )
- tts_media = await media_source.async_resolve_media(
- self.hass,
- tts_media_id,
- None,
+ engine=self.tts_stream.engine,
+ language=self.tts_stream.language,
+ options=self.tts_stream.options,
)
except Exception as src_error:
_LOGGER.exception("Unexpected error during text-to-speech")
@@ -1234,10 +1292,13 @@ class PipelineRun:
message="Unexpected error during text-to-speech",
) from src_error
- _LOGGER.debug("TTS result %s", tts_media)
+ self.tts_stream.async_set_message(tts_input)
+
tts_output = {
"media_id": tts_media_id,
- **asdict(tts_media),
+ "token": self.tts_stream.token,
+ "url": self.tts_stream.url,
+ "mime_type": self.tts_stream.content_type,
}
self.process_event(
@@ -1417,8 +1478,8 @@ class PipelineInput:
run: PipelineRun
- conversation_id: str
- """Identifier for the conversation."""
+ session: chat_session.ChatSession
+ """Session for the conversation."""
stt_metadata: stt.SpeechMetadata | None = None
"""Metadata of stt input audio. Required when start_stage = stt."""
@@ -1443,7 +1504,9 @@ class PipelineInput:
async def execute(self) -> None:
"""Run pipeline."""
- self.run.start(conversation_id=self.conversation_id, device_id=self.device_id)
+ self.run.start(
+ conversation_id=self.session.conversation_id, device_id=self.device_id
+ )
current_stage: PipelineStage | None = self.run.start_stage
stt_audio_buffer: list[EnhancedAudioChunk] = []
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
@@ -1527,7 +1590,7 @@ class PipelineInput:
assert intent_input is not None
tts_input = await self.run.recognize_intent(
intent_input,
- self.conversation_id,
+ self.session.conversation_id,
self.device_id,
self.conversation_extra_system_prompt,
)
@@ -1611,7 +1674,7 @@ class PipelineInput:
<= PIPELINE_STAGE_ORDER.index(PipelineStage.INTENT)
<= end_stage_index
):
- prepare_tasks.append(self.run.prepare_recognize_intent())
+ prepare_tasks.append(self.run.prepare_recognize_intent(self.session))
if (
start_stage_index
@@ -1890,7 +1953,7 @@ class PipelineRunDebug:
class PipelineStore(Store[SerializedPipelineStorageCollection]):
- """Store entity registry data."""
+ """Store pipeline data."""
async def _async_migrate_func(
self,
@@ -1972,3 +2035,37 @@ async def async_run_migrations(hass: HomeAssistant) -> None:
for pipeline, attr_updates in updates:
await async_update_pipeline(hass, pipeline, **attr_updates)
+
+
+@dataclass
+class PipelineConversationData:
+ """Hold data for the duration of a conversation."""
+
+ continue_conversation_agent: str | None = None
+ """The agent that requested the conversation to be continued."""
+
+
+@callback
+def async_get_pipeline_conversation_data(
+ hass: HomeAssistant, session: chat_session.ChatSession
+) -> PipelineConversationData:
+ """Get the pipeline data for a specific conversation."""
+ all_conversation_data = hass.data.get(KEY_PIPELINE_CONVERSATION_DATA)
+ if all_conversation_data is None:
+ all_conversation_data = {}
+ hass.data[KEY_PIPELINE_CONVERSATION_DATA] = all_conversation_data
+
+ data = all_conversation_data.get(session.conversation_id)
+
+ if data is not None:
+ return data
+
+ @callback
+ def do_cleanup() -> None:
+ """Handle cleanup."""
+ all_conversation_data.pop(session.conversation_id)
+
+ session.async_on_cleanup(do_cleanup)
+
+ data = all_conversation_data[session.conversation_id] = PipelineConversationData()
+ return data
diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py
index d2d54a1b7c3..937b3a0ea45 100644
--- a/homeassistant/components/assist_pipeline/websocket_api.py
+++ b/homeassistant/components/assist_pipeline/websocket_api.py
@@ -239,7 +239,7 @@ async def websocket_run(
with chat_session.async_get_chat_session(
hass, msg.get("conversation_id")
) as session:
- input_args["conversation_id"] = session.conversation_id
+ input_args["session"] = session
pipeline_input = PipelineInput(**input_args)
try:
diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py
index 038ff517264..3338f223bc9 100644
--- a/homeassistant/components/assist_satellite/__init__.py
+++ b/homeassistant/components/assist_satellite/__init__.py
@@ -1,9 +1,11 @@
"""Base class for assist satellite entities."""
import logging
+from pathlib import Path
import voluptuous as vol
+from homeassistant.components.http import StaticPathConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
@@ -15,6 +17,8 @@ from .const import (
CONNECTION_TEST_DATA,
DATA_COMPONENT,
DOMAIN,
+ PREANNOUNCE_FILENAME,
+ PREANNOUNCE_URL,
AssistSatelliteEntityFeature,
)
from .entity import (
@@ -56,6 +60,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{
vol.Optional("message"): str,
vol.Optional("media_id"): str,
+ vol.Optional("preannounce"): bool,
+ vol.Optional("preannounce_media_id"): str,
}
),
cv.has_at_least_one_key("message", "media_id"),
@@ -70,6 +76,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
{
vol.Optional("start_message"): str,
vol.Optional("start_media_id"): str,
+ vol.Optional("preannounce"): bool,
+ vol.Optional("preannounce_media_id"): str,
vol.Optional("extra_system_prompt"): str,
}
),
@@ -82,6 +90,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_websocket_api(hass)
hass.http.register_view(ConnectionTestView())
+ # Default preannounce sound
+ await hass.http.async_register_static_paths(
+ [
+ StaticPathConfig(
+ PREANNOUNCE_URL, str(Path(__file__).parent / PREANNOUNCE_FILENAME)
+ )
+ ]
+ )
+
return True
diff --git a/homeassistant/components/assist_satellite/const.py b/homeassistant/components/assist_satellite/const.py
index f7ac7e524b4..7fca88f3b12 100644
--- a/homeassistant/components/assist_satellite/const.py
+++ b/homeassistant/components/assist_satellite/const.py
@@ -20,6 +20,9 @@ CONNECTION_TEST_DATA: HassKey[dict[str, asyncio.Event]] = HassKey(
f"{DOMAIN}_connection_tests"
)
+PREANNOUNCE_FILENAME = "preannounce.mp3"
+PREANNOUNCE_URL = f"/api/assist_satellite/static/{PREANNOUNCE_FILENAME}"
+
class AssistSatelliteEntityFeature(IntFlag):
"""Supported features of Assist satellite entity."""
diff --git a/homeassistant/components/assist_satellite/entity.py b/homeassistant/components/assist_satellite/entity.py
index e43abb4539c..dc20c7650d7 100644
--- a/homeassistant/components/assist_satellite/entity.py
+++ b/homeassistant/components/assist_satellite/entity.py
@@ -23,15 +23,12 @@ from homeassistant.components.assist_pipeline import (
vad,
)
from homeassistant.components.media_player import async_process_play_media_url
-from homeassistant.components.tts import (
- generate_media_source_id as tts_generate_media_source_id,
-)
from homeassistant.core import Context, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import chat_session, entity
from homeassistant.helpers.entity import EntityDescription
-from .const import AssistSatelliteEntityFeature
+from .const import PREANNOUNCE_URL, AssistSatelliteEntityFeature
from .errors import AssistSatelliteError, SatelliteBusyError
_LOGGER = logging.getLogger(__name__)
@@ -98,9 +95,15 @@ class AssistSatelliteAnnouncement:
original_media_id: str
"""The raw media ID before processing."""
+ tts_token: str | None
+ """The TTS token of the media."""
+
media_id_source: Literal["url", "media_id", "tts"]
"""Source of the media ID."""
+ preannounce_media_id: str | None = None
+ """Media ID to be played before announcement."""
+
class AssistSatelliteEntity(entity.Entity):
"""Entity encapsulating the state and functionality of an Assist satellite."""
@@ -177,6 +180,8 @@ class AssistSatelliteEntity(entity.Entity):
self,
message: str | None = None,
media_id: str | None = None,
+ preannounce: bool = True,
+ preannounce_media_id: str = PREANNOUNCE_URL,
) -> None:
"""Play and show an announcement on the satellite.
@@ -186,6 +191,9 @@ class AssistSatelliteEntity(entity.Entity):
If media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text.
+ If preannounce is True, a sound is played before the announcement.
+ If preannounce_media_id is provided, it overrides the default sound.
+
Calls async_announce with message and media id.
"""
await self._cancel_running_pipeline()
@@ -193,7 +201,11 @@ class AssistSatelliteEntity(entity.Entity):
if message is None:
message = ""
- announcement = await self._resolve_announcement_media_id(message, media_id)
+ announcement = await self._resolve_announcement_media_id(
+ message,
+ media_id,
+ preannounce_media_id=preannounce_media_id if preannounce else None,
+ )
if self._is_announcing:
raise SatelliteBusyError
@@ -220,6 +232,8 @@ class AssistSatelliteEntity(entity.Entity):
start_message: str | None = None,
start_media_id: str | None = None,
extra_system_prompt: str | None = None,
+ preannounce: bool = True,
+ preannounce_media_id: str = PREANNOUNCE_URL,
) -> None:
"""Start a conversation from the satellite.
@@ -229,6 +243,9 @@ class AssistSatelliteEntity(entity.Entity):
If start_media_id is provided, it is played directly. It is possible
to omit the message and the satellite will not show any text.
+ If preannounce is True, a sound is played before the start message or media.
+ If preannounce_media_id is provided, it overrides the default sound.
+
Calls async_start_conversation.
"""
await self._cancel_running_pipeline()
@@ -244,13 +261,17 @@ class AssistSatelliteEntity(entity.Entity):
start_message = ""
announcement = await self._resolve_announcement_media_id(
- start_message, start_media_id
+ start_message,
+ start_media_id,
+ preannounce_media_id=preannounce_media_id if preannounce else None,
)
if self._is_announcing:
raise SatelliteBusyError
self._is_announcing = True
+ self._set_state(AssistSatelliteState.RESPONDING)
+
# Provide our start info to the LLM so it understands context of incoming message
if extra_system_prompt is not None:
self._extra_system_prompt = extra_system_prompt
@@ -280,6 +301,7 @@ class AssistSatelliteEntity(entity.Entity):
raise
finally:
self._is_announcing = False
+ self._set_state(AssistSatelliteState.IDLE)
async def async_start_conversation(
self, start_announcement: AssistSatelliteAnnouncement
@@ -405,7 +427,10 @@ class AssistSatelliteEntity(entity.Entity):
def _internal_on_pipeline_event(self, event: PipelineEvent) -> None:
"""Set state based on pipeline stage."""
if event.type is PipelineEventType.WAKE_WORD_START:
- self._set_state(AssistSatelliteState.IDLE)
+ # Only return to idle if we're not currently responding.
+ # The state will return to idle in tts_response_finished.
+ if self.state != AssistSatelliteState.RESPONDING:
+ self._set_state(AssistSatelliteState.IDLE)
elif event.type is PipelineEventType.STT_START:
self._set_state(AssistSatelliteState.LISTENING)
elif event.type is PipelineEventType.INTENT_START:
@@ -467,20 +492,27 @@ class AssistSatelliteEntity(entity.Entity):
return vad.VadSensitivity.to_seconds(vad_sensitivity)
async def _resolve_announcement_media_id(
- self, message: str, media_id: str | None
+ self,
+ message: str,
+ media_id: str | None,
+ preannounce_media_id: str | None = None,
) -> AssistSatelliteAnnouncement:
"""Resolve the media ID."""
media_id_source: Literal["url", "media_id", "tts"] | None = None
+ tts_token: str | None = None
if media_id:
original_media_id = media_id
-
else:
media_id_source = "tts"
# Synthesize audio and get URL
pipeline_id = self._resolve_pipeline()
pipeline = async_get_pipeline(self.hass, pipeline_id)
+ engine = tts.async_resolve_engine(self.hass, pipeline.tts_engine)
+ if engine is None:
+ raise HomeAssistantError(f"TTS engine {pipeline.tts_engine} not found")
+
tts_options: dict[str, Any] = {}
if pipeline.tts_voice is not None:
tts_options[tts.ATTR_VOICE] = pipeline.tts_voice
@@ -488,14 +520,23 @@ class AssistSatelliteEntity(entity.Entity):
if self.tts_options is not None:
tts_options.update(self.tts_options)
- media_id = tts_generate_media_source_id(
+ stream = tts.async_create_stream(
self.hass,
- message,
- engine=pipeline.tts_engine,
+ engine=engine,
+ language=pipeline.tts_language,
+ options=tts_options,
+ )
+ stream.async_set_message(message)
+
+ tts_token = stream.token
+ media_id = stream.url
+ original_media_id = tts.generate_media_source_id(
+ self.hass,
+ message,
+ engine=engine,
language=pipeline.tts_language,
options=tts_options,
)
- original_media_id = media_id
if media_source.is_media_source_id(media_id):
if not media_id_source:
@@ -513,6 +554,26 @@ class AssistSatelliteEntity(entity.Entity):
# Resolve to full URL
media_id = async_process_play_media_url(self.hass, media_id)
+ # Resolve preannounce media id
+ if preannounce_media_id:
+ if media_source.is_media_source_id(preannounce_media_id):
+ preannounce_media = await media_source.async_resolve_media(
+ self.hass,
+ preannounce_media_id,
+ None,
+ )
+ preannounce_media_id = preannounce_media.url
+
+ # Resolve to full URL
+ preannounce_media_id = async_process_play_media_url(
+ self.hass, preannounce_media_id
+ )
+
return AssistSatelliteAnnouncement(
- message, media_id, original_media_id, media_id_source
+ message=message,
+ media_id=media_id,
+ original_media_id=original_media_id,
+ tts_token=tts_token,
+ media_id_source=media_id_source,
+ preannounce_media_id=preannounce_media_id,
)
diff --git a/homeassistant/components/assist_satellite/preannounce.mp3 b/homeassistant/components/assist_satellite/preannounce.mp3
new file mode 100644
index 00000000000..6e2fa0aba3e
Binary files /dev/null and b/homeassistant/components/assist_satellite/preannounce.mp3 differ
diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml
index 89a20ada6f3..d88710c4c4e 100644
--- a/homeassistant/components/assist_satellite/services.yaml
+++ b/homeassistant/components/assist_satellite/services.yaml
@@ -8,12 +8,22 @@ announce:
message:
required: false
example: "Time to wake up!"
+ default: ""
selector:
text:
media_id:
required: false
selector:
text:
+ preannounce:
+ required: false
+ default: true
+ selector:
+ boolean:
+ preannounce_media_id:
+ required: false
+ selector:
+ text:
start_conversation:
target:
entity:
@@ -24,6 +34,7 @@ start_conversation:
start_message:
required: false
example: "You left the lights on in the living room. Turn them off?"
+ default: ""
selector:
text:
start_media_id:
@@ -34,3 +45,12 @@ start_conversation:
required: false
selector:
text:
+ preannounce:
+ required: false
+ default: true
+ selector:
+ boolean:
+ preannounce_media_id:
+ required: false
+ selector:
+ text:
diff --git a/homeassistant/components/assist_satellite/strings.json b/homeassistant/components/assist_satellite/strings.json
index fa2dc984ab7..b69711c7106 100644
--- a/homeassistant/components/assist_satellite/strings.json
+++ b/homeassistant/components/assist_satellite/strings.json
@@ -23,6 +23,14 @@
"media_id": {
"name": "Media ID",
"description": "The media ID to announce instead of using text-to-speech."
+ },
+ "preannounce": {
+ "name": "Preannounce",
+ "description": "Play a sound before the announcement."
+ },
+ "preannounce_media_id": {
+ "name": "Preannounce media ID",
+ "description": "Custom media ID to play before the announcement."
}
}
},
@@ -41,6 +49,14 @@
"extra_system_prompt": {
"name": "Extra system prompt",
"description": "Provide background information to the AI about the request."
+ },
+ "preannounce": {
+ "name": "Preannounce",
+ "description": "Play a sound before the start message or media."
+ },
+ "preannounce_media_id": {
+ "name": "Preannounce media ID",
+ "description": "Custom media ID to play before the start message or media."
}
}
}
diff --git a/homeassistant/components/assist_satellite/websocket_api.py b/homeassistant/components/assist_satellite/websocket_api.py
index 6cd7af2bbdb..6f8b3d723ad 100644
--- a/homeassistant/components/assist_satellite/websocket_api.py
+++ b/homeassistant/components/assist_satellite/websocket_api.py
@@ -19,6 +19,7 @@ from .const import (
DOMAIN,
AssistSatelliteEntityFeature,
)
+from .entity import AssistSatelliteConfiguration
CONNECTION_TEST_TIMEOUT = 30
@@ -91,7 +92,16 @@ def websocket_get_configuration(
)
return
- config_dict = asdict(satellite.async_get_configuration())
+ try:
+ config_dict = asdict(satellite.async_get_configuration())
+ except NotImplementedError:
+ # Stub configuration
+ config_dict = asdict(
+ AssistSatelliteConfiguration(
+ available_wake_words=[], active_wake_words=[], max_active_wake_words=1
+ )
+ )
+
config_dict["pipeline_entity_id"] = satellite.pipeline_entity_id
config_dict["vad_entity_id"] = satellite.vad_sensitivity_entity_id
@@ -188,7 +198,8 @@ async def websocket_test_connection(
hass.async_create_background_task(
satellite.async_internal_announce(
- media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}"
+ media_id=f"{CONNECTION_TEST_URL_BASE}/{connection_id}",
+ preannounce=False,
),
f"assist_satellite_connection_test_{msg['entity_id']}",
)
diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py
index 95d2e4c8000..ee6c3f96fc4 100644
--- a/homeassistant/components/asuswrt/device_tracker.py
+++ b/homeassistant/components/asuswrt/device_tracker.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AsusWrtConfigEntry
from .router import AsusWrtDevInfo, AsusWrtRouter
@@ -18,7 +18,7 @@ DEFAULT_DEVICE_NAME = "Unknown device"
async def async_setup_entry(
hass: HomeAssistant,
entry: AsusWrtConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for AsusWrt component."""
router = entry.runtime_data
@@ -38,7 +38,9 @@ async def async_setup_entry(
@callback
def add_entities(
- router: AsusWrtRouter, async_add_entities: AddEntitiesCallback, tracked: set[str]
+ router: AsusWrtRouter,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ tracked: set[str],
) -> None:
"""Add new tracker entities from the router."""
new_tracked = []
diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py
index fb43e574379..c4bd5e4bded 100644
--- a/homeassistant/components/asuswrt/sensor.py
+++ b/homeassistant/components/asuswrt/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -246,7 +246,7 @@ CONNECTION_SENSORS: tuple[AsusWrtSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AsusWrtConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors."""
router = entry.runtime_data
diff --git a/homeassistant/components/asuswrt/strings.json b/homeassistant/components/asuswrt/strings.json
index 9d50f50c7e9..cac37c0cfd0 100644
--- a/homeassistant/components/asuswrt/strings.json
+++ b/homeassistant/components/asuswrt/strings.json
@@ -66,28 +66,28 @@
"name": "Upload"
},
"load_avg_1m": {
- "name": "Average load (1m)"
+ "name": "Average load (1 min)"
},
"load_avg_5m": {
- "name": "Average load (5m)"
+ "name": "Average load (5 min)"
},
"load_avg_15m": {
- "name": "Average load (15m)"
+ "name": "Average load (15 min)"
},
"24ghz_temperature": {
- "name": "2.4GHz Temperature"
+ "name": "2.4GHz temperature"
},
"5ghz_temperature": {
- "name": "5GHz Temperature"
+ "name": "5GHz temperature"
},
"cpu_temperature": {
- "name": "CPU Temperature"
+ "name": "CPU temperature"
},
"5ghz_2_temperature": {
- "name": "5GHz Temperature (Radio 2)"
+ "name": "5GHz temperature (Radio 2)"
},
"6ghz_temperature": {
- "name": "6GHz Temperature"
+ "name": "6GHz temperature"
},
"cpu_usage": {
"name": "CPU usage"
diff --git a/homeassistant/components/atag/climate.py b/homeassistant/components/atag/climate.py
index a362b71fbc8..8f1ded150f1 100644
--- a/homeassistant/components/atag/climate.py
+++ b/homeassistant/components/atag/climate.py
@@ -14,7 +14,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from .coordinator import AtagConfigEntry, AtagDataUpdateCoordinator
@@ -32,7 +32,9 @@ HVAC_MODES = [HVACMode.AUTO, HVACMode.HEAT]
async def async_setup_entry(
- hass: HomeAssistant, entry: AtagConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: AtagConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load a config entry."""
async_add_entities([AtagThermostat(entry.runtime_data, "climate")])
diff --git a/homeassistant/components/atag/sensor.py b/homeassistant/components/atag/sensor.py
index bd39f0b3458..ca5bbd5e614 100644
--- a/homeassistant/components/atag/sensor.py
+++ b/homeassistant/components/atag/sensor.py
@@ -8,7 +8,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AtagConfigEntry, AtagDataUpdateCoordinator
from .entity import AtagEntity
@@ -28,7 +28,7 @@ SENSORS = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AtagConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize sensor platform from config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/atag/water_heater.py b/homeassistant/components/atag/water_heater.py
index 6b013b36885..00761f47324 100644
--- a/homeassistant/components/atag/water_heater.py
+++ b/homeassistant/components/atag/water_heater.py
@@ -9,7 +9,7 @@ from homeassistant.components.water_heater import (
)
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AtagConfigEntry
from .entity import AtagEntity
@@ -20,7 +20,7 @@ OPERATION_LIST = [STATE_OFF, STATE_ECO, STATE_PERFORMANCE]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AtagConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize DHW device from config entry."""
async_add_entities(
diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py
index fb877252010..b4c440599c4 100644
--- a/homeassistant/components/august/binary_sensor.py
+++ b/homeassistant/components/august/binary_sensor.py
@@ -21,7 +21,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import AugustConfigEntry, AugustData
@@ -92,7 +92,7 @@ SENSOR_TYPES_DOORBELL: tuple[AugustDoorbellBinarySensorEntityDescription, ...] =
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AugustConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the August binary sensors."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/august/button.py b/homeassistant/components/august/button.py
index 79f2b67888a..4971d0cccf5 100644
--- a/homeassistant/components/august/button.py
+++ b/homeassistant/components/august/button.py
@@ -2,7 +2,7 @@
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AugustConfigEntry
from .entity import AugustEntity
@@ -11,7 +11,7 @@ from .entity import AugustEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AugustConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up August lock wake buttons."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py
index f4398455256..7b013022299 100644
--- a/homeassistant/components/august/camera.py
+++ b/homeassistant/components/august/camera.py
@@ -12,7 +12,7 @@ from yalexs.util import update_doorbell_image_from_activity
from homeassistant.components.camera import Camera
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AugustConfigEntry, AugustData
from .const import DEFAULT_NAME, DEFAULT_TIMEOUT
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AugustConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up August cameras."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/august/event.py b/homeassistant/components/august/event.py
index 49b14630337..0abc840bc69 100644
--- a/homeassistant/components/august/event.py
+++ b/homeassistant/components/august/event.py
@@ -16,7 +16,7 @@ from homeassistant.components.event import (
EventEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AugustConfigEntry, AugustData
from .entity import AugustDescriptionEntity
@@ -59,7 +59,7 @@ TYPES_DOORBELL: tuple[AugustEventEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AugustConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the august event platform."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py
index c681cc98808..4a37149772a 100644
--- a/homeassistant/components/august/lock.py
+++ b/homeassistant/components/august/lock.py
@@ -14,7 +14,7 @@ from yalexs.util import get_latest_activity, update_lock_detail_from_activity
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature
from homeassistant.const import ATTR_BATTERY_LEVEL
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util import dt as dt_util
@@ -29,7 +29,7 @@ LOCK_JAMMED_ERR = 531
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AugustConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up August locks."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py
index b7c0d618492..94a5461149f 100644
--- a/homeassistant/components/august/sensor.py
+++ b/homeassistant/components/august/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
EntityCategory,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AugustConfigEntry
from .const import (
@@ -82,7 +82,7 @@ SENSOR_TYPE_KEYPAD_BATTERY = AugustSensorEntityDescription[KeypadDetail](
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AugustConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the August sensors."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py
index 648f6de08c9..73e732dc44a 100644
--- a/homeassistant/components/aurora/binary_sensor.py
+++ b/homeassistant/components/aurora/binary_sensor.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AuroraConfigEntry
from .entity import AuroraEntity
@@ -13,7 +13,7 @@ from .entity import AuroraEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: AuroraConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary_sensor platform."""
async_add_entities(
diff --git a/homeassistant/components/aurora/sensor.py b/homeassistant/components/aurora/sensor.py
index ec1b82c3c4d..d424b7e98ab 100644
--- a/homeassistant/components/aurora/sensor.py
+++ b/homeassistant/components/aurora/sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AuroraConfigEntry
from .entity import AuroraEntity
@@ -14,7 +14,7 @@ from .entity import AuroraEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: AuroraConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py
index 29d5cab2667..d35d8a2d8cb 100644
--- a/homeassistant/components/aurora_abb_powerone/sensor.py
+++ b/homeassistant/components/aurora_abb_powerone/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -130,7 +130,7 @@ SENSOR_TYPES = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AuroraAbbConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up aurora_abb_powerone sensor based on a config entry."""
diff --git a/homeassistant/components/aurora_abb_powerone/strings.json b/homeassistant/components/aurora_abb_powerone/strings.json
index 319bcb0adc4..6b28d9d8c1c 100644
--- a/homeassistant/components/aurora_abb_powerone/strings.json
+++ b/homeassistant/components/aurora_abb_powerone/strings.json
@@ -11,7 +11,7 @@
},
"error": {
"cannot_connect": "Unable to connect, please check serial port, address, electrical connection and that inverter is on (in daylight)",
- "invalid_serial_port": "Serial port is not a valid device or could not be openned",
+ "invalid_serial_port": "Serial port is not a valid device or could not be opened",
"cannot_open_serial_port": "Cannot open serial port, please check and try again"
},
"abort": {
diff --git a/homeassistant/components/aussie_broadband/sensor.py b/homeassistant/components/aussie_broadband/sensor.py
index 49da78da8de..41a2f164095 100644
--- a/homeassistant/components/aussie_broadband/sensor.py
+++ b/homeassistant/components/aussie_broadband/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import UnitOfInformation, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -123,7 +123,7 @@ SENSOR_DESCRIPTIONS: tuple[SensorValueEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AussieBroadbandConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Aussie Broadband sensor platform from a config entry."""
diff --git a/homeassistant/components/autarco/sensor.py b/homeassistant/components/autarco/sensor.py
index b7c4312815b..1635adefdb8 100644
--- a/homeassistant/components/autarco/sensor.py
+++ b/homeassistant/components/autarco/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -172,7 +172,7 @@ SENSORS_INVERTER: tuple[AutarcoInverterSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AutarcoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Autarco sensors based on a config entry."""
entities: list[SensorEntity] = []
diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py
index c92009d9b1b..a0c4b5ba8fe 100644
--- a/homeassistant/components/awair/sensor.py
+++ b/homeassistant/components/awair/sensor.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -133,7 +133,7 @@ SENSOR_TYPES_DUST: tuple[AwairSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AwairConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Awair sensor entity based on a config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json
index 12149e4388a..92ae37c857b 100644
--- a/homeassistant/components/aws/manifest.json
+++ b/homeassistant/components/aws/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["aiobotocore", "botocore"],
"quality_scale": "legacy",
- "requirements": ["aiobotocore==2.13.1", "botocore==1.34.131"]
+ "requirements": ["aiobotocore==2.21.1", "botocore==1.37.1"]
}
diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py
index d6f132874b6..6933380c094 100644
--- a/homeassistant/components/axis/binary_sensor.py
+++ b/homeassistant/components/axis/binary_sensor.py
@@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import AxisConfigEntry
@@ -178,7 +178,7 @@ ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AxisConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Axis binary sensor."""
config_entry.runtime_data.entity_loader.register_platform(
diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py
index a5a00bcd1ab..089a018ee5b 100644
--- a/homeassistant/components/axis/camera.py
+++ b/homeassistant/components/axis/camera.py
@@ -7,7 +7,7 @@ from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging
from homeassistant.const import HTTP_DIGEST_AUTHENTICATION
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AxisConfigEntry
from .const import DEFAULT_STREAM_PROFILE, DEFAULT_VIDEO_SOURCE
@@ -18,7 +18,7 @@ from .hub import AxisHub
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AxisConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Axis camera video stream."""
filter_urllib3_logging()
diff --git a/homeassistant/components/axis/light.py b/homeassistant/components/axis/light.py
index d0d144a28fa..0c6015efced 100644
--- a/homeassistant/components/axis/light.py
+++ b/homeassistant/components/axis/light.py
@@ -12,7 +12,7 @@ from homeassistant.components.light import (
LightEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AxisConfigEntry
from .entity import TOPIC_TO_EVENT_TYPE, AxisEventDescription, AxisEventEntity
@@ -46,7 +46,7 @@ ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AxisConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Axis light platform."""
config_entry.runtime_data.entity_loader.register_platform(
diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py
index 17824302871..55250b5f489 100644
--- a/homeassistant/components/axis/switch.py
+++ b/homeassistant/components/axis/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AxisConfigEntry
from .entity import AxisEventDescription, AxisEventEntity
@@ -39,7 +39,7 @@ ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: AxisConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Axis switch platform."""
config_entry.runtime_data.entity_loader.register_platform(
diff --git a/homeassistant/components/azure_devops/sensor.py b/homeassistant/components/azure_devops/sensor.py
index 1d590032a85..55c821a119e 100644
--- a/homeassistant/components/azure_devops/sensor.py
+++ b/homeassistant/components/azure_devops/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
@@ -145,7 +145,7 @@ def parse_datetime(value: str | None) -> datetime | None:
async def async_setup_entry(
hass: HomeAssistant,
entry: AzureDevOpsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Azure DevOps sensor based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/azure_devops/strings.json b/homeassistant/components/azure_devops/strings.json
index f5fe5cd06a7..611a8b9a758 100644
--- a/homeassistant/components/azure_devops/strings.json
+++ b/homeassistant/components/azure_devops/strings.json
@@ -14,7 +14,7 @@
"personal_access_token": "Personal Access Token (PAT)"
},
"description": "Set up an Azure DevOps instance to access your project. A Personal Access Token is only required for a private project.",
- "title": "Add Azure DevOps Project"
+ "title": "Add Azure DevOps project"
},
"reauth_confirm": {
"data": {
@@ -32,7 +32,7 @@
"entity": {
"sensor": {
"build_id": {
- "name": "{definition_name} latest build id"
+ "name": "{definition_name} latest build ID"
},
"finish_time": {
"name": "{definition_name} latest build finish time"
@@ -59,7 +59,7 @@
"name": "{definition_name} latest build start time"
},
"url": {
- "name": "{definition_name} latest build url"
+ "name": "{definition_name} latest build URL"
},
"work_item_count": {
"name": "{item_type} {item_state} work items"
@@ -68,7 +68,7 @@
},
"exceptions": {
"authentication_failed": {
- "message": "Could not authorize with Azure DevOps for {title}. You will need to update your personal access token."
+ "message": "Could not authorize with Azure DevOps for {title}. You will need to update your Personal Access Token."
}
}
}
diff --git a/homeassistant/components/azure_storage/__init__.py b/homeassistant/components/azure_storage/__init__.py
new file mode 100644
index 00000000000..f22e7b70c12
--- /dev/null
+++ b/homeassistant/components/azure_storage/__init__.py
@@ -0,0 +1,86 @@
+"""The Azure Storage integration."""
+
+from aiohttp import ClientTimeout
+from azure.core.exceptions import (
+ ClientAuthenticationError,
+ HttpResponseError,
+ ResourceNotFoundError,
+)
+from azure.core.pipeline.transport._aiohttp import (
+ AioHttpTransport,
+) # need to import from private file, as it is not properly imported in the init
+from azure.storage.blob.aio import ContainerClient
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
+ ConfigEntryError,
+ ConfigEntryNotReady,
+)
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
+
+from .const import (
+ CONF_ACCOUNT_NAME,
+ CONF_CONTAINER_NAME,
+ CONF_STORAGE_ACCOUNT_KEY,
+ DATA_BACKUP_AGENT_LISTENERS,
+ DOMAIN,
+)
+
+type AzureStorageConfigEntry = ConfigEntry[ContainerClient]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: AzureStorageConfigEntry
+) -> bool:
+ """Set up Azure Storage integration."""
+ # set increase aiohttp timeout for long running operations (up/download)
+ session = async_create_clientsession(
+ hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60)
+ )
+ container_client = ContainerClient(
+ account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
+ container_name=entry.data[CONF_CONTAINER_NAME],
+ credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
+ transport=AioHttpTransport(session=session),
+ )
+
+ try:
+ if not await container_client.exists():
+ await container_client.create_container()
+ except ResourceNotFoundError as err:
+ raise ConfigEntryError(
+ translation_domain=DOMAIN,
+ translation_key="account_not_found",
+ translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
+ ) from err
+ except ClientAuthenticationError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="invalid_auth",
+ translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
+ ) from err
+ except HttpResponseError as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
+ ) from err
+
+ entry.runtime_data = container_client
+
+ def _async_notify_backup_listeners() -> None:
+ for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
+ listener()
+
+ entry.async_on_unload(entry.async_on_state_change(_async_notify_backup_listeners))
+
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, entry: AzureStorageConfigEntry
+) -> bool:
+ """Unload an Azure Storage config entry."""
+ return True
diff --git a/homeassistant/components/azure_storage/backup.py b/homeassistant/components/azure_storage/backup.py
new file mode 100644
index 00000000000..4a9254213dc
--- /dev/null
+++ b/homeassistant/components/azure_storage/backup.py
@@ -0,0 +1,183 @@
+"""Support for Azure Storage backup."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable, Coroutine
+from functools import wraps
+import json
+import logging
+from typing import Any, Concatenate
+
+from azure.core.exceptions import HttpResponseError
+from azure.storage.blob import BlobProperties
+
+from homeassistant.components.backup import (
+ AgentBackup,
+ BackupAgent,
+ BackupAgentError,
+ BackupNotFound,
+ suggested_filename,
+)
+from homeassistant.core import HomeAssistant, callback
+
+from . import AzureStorageConfigEntry
+from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+METADATA_VERSION = "1"
+
+
+async def async_get_backup_agents(
+ hass: HomeAssistant,
+) -> list[BackupAgent]:
+ """Return a list of backup agents."""
+ entries: list[AzureStorageConfigEntry] = hass.config_entries.async_loaded_entries(
+ DOMAIN
+ )
+ return [AzureStorageBackupAgent(hass, entry) for entry in entries]
+
+
+@callback
+def async_register_backup_agents_listener(
+ hass: HomeAssistant,
+ *,
+ listener: Callable[[], None],
+ **kwargs: Any,
+) -> Callable[[], None]:
+ """Register a listener to be called when agents are added or removed."""
+ hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
+
+ @callback
+ def remove_listener() -> None:
+ """Remove the listener."""
+ hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
+ if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
+ hass.data.pop(DATA_BACKUP_AGENT_LISTENERS)
+
+ return remove_listener
+
+
+def handle_backup_errors[_R, **P](
+ func: Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]],
+) -> Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]]:
+ """Handle backup errors."""
+
+ @wraps(func)
+ async def wrapper(
+ self: AzureStorageBackupAgent, *args: P.args, **kwargs: P.kwargs
+ ) -> _R:
+ try:
+ return await func(self, *args, **kwargs)
+ except HttpResponseError as err:
+ _LOGGER.debug(
+ "Error during backup in %s: Status %s, message %s",
+ func.__name__,
+ err.status_code,
+ err.message,
+ exc_info=True,
+ )
+ raise BackupAgentError(
+ f"Error during backup operation in {func.__name__}:"
+ f" Status {err.status_code}, message: {err.message}"
+ ) from err
+
+ return wrapper
+
+
+class AzureStorageBackupAgent(BackupAgent):
+ """Azure storage backup agent."""
+
+ domain = DOMAIN
+
+ def __init__(self, hass: HomeAssistant, entry: AzureStorageConfigEntry) -> None:
+ """Initialize the Azure storage backup agent."""
+ super().__init__()
+ self._client = entry.runtime_data
+ self.name = entry.title
+ self.unique_id = entry.entry_id
+
+ @handle_backup_errors
+ async def async_download_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AsyncIterator[bytes]:
+ """Download a backup file."""
+ blob = await self._find_blob_by_backup_id(backup_id)
+ if blob is None:
+ raise BackupNotFound(f"Backup {backup_id} not found")
+ download_stream = await self._client.download_blob(blob.name)
+ return download_stream.chunks()
+
+ @handle_backup_errors
+ async def async_upload_backup(
+ self,
+ *,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ backup: AgentBackup,
+ **kwargs: Any,
+ ) -> None:
+ """Upload a backup."""
+
+ metadata = {
+ "metadata_version": METADATA_VERSION,
+ "backup_id": backup.backup_id,
+ "backup_metadata": json.dumps(backup.as_dict()),
+ }
+
+ await self._client.upload_blob(
+ name=suggested_filename(backup),
+ metadata=metadata,
+ data=await open_stream(),
+ length=backup.size,
+ )
+
+ @handle_backup_errors
+ async def async_delete_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> None:
+ """Delete a backup file."""
+ blob = await self._find_blob_by_backup_id(backup_id)
+ if blob is None:
+ raise BackupNotFound(f"Backup {backup_id} not found")
+ await self._client.delete_blob(blob.name)
+
+ @handle_backup_errors
+ async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List backups."""
+ backups: list[AgentBackup] = []
+ async for blob in self._client.list_blobs(include="metadata"):
+ metadata = blob.metadata
+
+ if metadata.get("metadata_version") == METADATA_VERSION:
+ backups.append(
+ AgentBackup.from_dict(json.loads(metadata["backup_metadata"]))
+ )
+
+ return backups
+
+ @handle_backup_errors
+ async def async_get_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AgentBackup:
+ """Return a backup."""
+ blob = await self._find_blob_by_backup_id(backup_id)
+ if blob is None:
+ raise BackupNotFound(f"Backup {backup_id} not found")
+
+ return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"]))
+
+ async def _find_blob_by_backup_id(self, backup_id: str) -> BlobProperties | None:
+ """Find a blob by backup id."""
+ async for blob in self._client.list_blobs(include="metadata"):
+ if (
+ blob.metadata is not None
+ and backup_id == blob.metadata.get("backup_id", "")
+ and blob.metadata.get("metadata_version") == METADATA_VERSION
+ ):
+ return blob
+ return None
diff --git a/homeassistant/components/azure_storage/config_flow.py b/homeassistant/components/azure_storage/config_flow.py
new file mode 100644
index 00000000000..2862d290f95
--- /dev/null
+++ b/homeassistant/components/azure_storage/config_flow.py
@@ -0,0 +1,160 @@
+"""Config flow for Azure Storage integration."""
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError
+from azure.core.pipeline.transport._aiohttp import (
+ AioHttpTransport,
+) # need to import from private file, as it is not properly imported in the init
+from azure.storage.blob.aio import ContainerClient
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import (
+ CONF_ACCOUNT_NAME,
+ CONF_CONTAINER_NAME,
+ CONF_STORAGE_ACCOUNT_KEY,
+ DOMAIN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for azure storage."""
+
+ def get_account_url(self, account_name: str) -> str:
+ """Get the account URL."""
+ return f"https://{account_name}.blob.core.windows.net/"
+
+ async def validate_config(
+ self, container_client: ContainerClient
+ ) -> dict[str, str]:
+ """Validate the configuration."""
+ errors: dict[str, str] = {}
+ try:
+ await container_client.exists()
+ except ResourceNotFoundError:
+ errors["base"] = "cannot_connect"
+ except ClientAuthenticationError:
+ errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth"
+ except Exception:
+ _LOGGER.exception("Unknown exception occurred")
+ errors["base"] = "unknown"
+ return errors
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """User step for Azure Storage."""
+
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ self._async_abort_entries_match(
+ {CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]}
+ )
+ container_client = ContainerClient(
+ account_url=self.get_account_url(user_input[CONF_ACCOUNT_NAME]),
+ container_name=user_input[CONF_CONTAINER_NAME],
+ credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
+ transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
+ )
+ errors = await self.validate_config(container_client)
+
+ if not errors:
+ return self.async_create_entry(
+ title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}",
+ data=user_input,
+ )
+
+ return self.async_show_form(
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_ACCOUNT_NAME): str,
+ vol.Required(
+ CONF_CONTAINER_NAME, default="home-assistant-backups"
+ ): str,
+ vol.Required(CONF_STORAGE_ACCOUNT_KEY): str,
+ }
+ ),
+ errors=errors,
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth upon an API authentication error."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm reauth dialog."""
+ errors: dict[str, str] = {}
+ reauth_entry = self._get_reauth_entry()
+
+ if user_input is not None:
+ container_client = ContainerClient(
+ account_url=self.get_account_url(reauth_entry.data[CONF_ACCOUNT_NAME]),
+ container_name=reauth_entry.data[CONF_CONTAINER_NAME],
+ credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
+ transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
+ )
+ errors = await self.validate_config(container_client)
+ if not errors:
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data={**reauth_entry.data, **user_input},
+ )
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_STORAGE_ACCOUNT_KEY): str,
+ }
+ ),
+ errors=errors,
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Reconfigure the entry."""
+ errors: dict[str, str] = {}
+ reconfigure_entry = self._get_reconfigure_entry()
+
+ if user_input is not None:
+ container_client = ContainerClient(
+ account_url=self.get_account_url(
+ reconfigure_entry.data[CONF_ACCOUNT_NAME]
+ ),
+ container_name=user_input[CONF_CONTAINER_NAME],
+ credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
+ transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
+ )
+ errors = await self.validate_config(container_client)
+ if not errors:
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data={**reconfigure_entry.data, **user_input},
+ )
+ return self.async_show_form(
+ data_schema=vol.Schema(
+ {
+ vol.Required(
+ CONF_CONTAINER_NAME,
+ default=reconfigure_entry.data[CONF_CONTAINER_NAME],
+ ): str,
+ vol.Required(
+ CONF_STORAGE_ACCOUNT_KEY,
+ default=reconfigure_entry.data[CONF_STORAGE_ACCOUNT_KEY],
+ ): str,
+ }
+ ),
+ errors=errors,
+ )
diff --git a/homeassistant/components/azure_storage/const.py b/homeassistant/components/azure_storage/const.py
new file mode 100644
index 00000000000..efcb338a096
--- /dev/null
+++ b/homeassistant/components/azure_storage/const.py
@@ -0,0 +1,16 @@
+"""Constants for the Azure Storage integration."""
+
+from collections.abc import Callable
+from typing import Final
+
+from homeassistant.util.hass_dict import HassKey
+
+DOMAIN: Final = "azure_storage"
+
+CONF_STORAGE_ACCOUNT_KEY: Final = "storage_account_key"
+CONF_ACCOUNT_NAME: Final = "account_name"
+CONF_CONTAINER_NAME: Final = "container_name"
+
+DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
+ f"{DOMAIN}.backup_agent_listeners"
+)
diff --git a/homeassistant/components/azure_storage/manifest.json b/homeassistant/components/azure_storage/manifest.json
new file mode 100644
index 00000000000..729334f851d
--- /dev/null
+++ b/homeassistant/components/azure_storage/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "azure_storage",
+ "name": "Azure Storage",
+ "codeowners": ["@zweckj"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/azure_storage",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "loggers": ["azure-storage-blob"],
+ "quality_scale": "platinum",
+ "requirements": ["azure-storage-blob==12.24.0"]
+}
diff --git a/homeassistant/components/azure_storage/quality_scale.yaml b/homeassistant/components/azure_storage/quality_scale.yaml
new file mode 100644
index 00000000000..6199ba514a3
--- /dev/null
+++ b/homeassistant/components/azure_storage/quality_scale.yaml
@@ -0,0 +1,133 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration does not register custom actions.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration does not poll.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not have any custom actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ has-entity-name:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not have any configuration parameters.
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ integration-owner: done
+ log-when-unavailable:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ parallel-updates:
+ status: exempt
+ comment: |
+ This integration does not have platforms.
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+ diagnostics:
+ status: exempt
+ comment: |
+ There is no data to diagnose.
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration is a cloud service and does not support discovery.
+ discovery:
+ status: exempt
+ comment: |
+ This integration is a cloud service and does not support discovery.
+ docs-data-update:
+ status: exempt
+ comment: |
+ This integration does not poll or push.
+ docs-examples:
+ status: exempt
+ comment: |
+ This integration only serves backup.
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: |
+ This integration is a cloud service.
+ docs-supported-functions:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+ entity-category:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-device-class:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-translations:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ reconfiguration-flow: done
+ repair-issues: done
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/azure_storage/strings.json b/homeassistant/components/azure_storage/strings.json
new file mode 100644
index 00000000000..e9053f113cc
--- /dev/null
+++ b/homeassistant/components/azure_storage/strings.json
@@ -0,0 +1,72 @@
+{
+ "config": {
+ "error": {
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "storage_account_key": "Storage account key",
+ "account_name": "Account name",
+ "container_name": "Container name"
+ },
+ "data_description": {
+ "storage_account_key": "Storage account access key used for authorization",
+ "account_name": "Name of the storage account",
+ "container_name": "Name of the storage container to be used (will be created if it does not exist)"
+ },
+ "description": "Set up an Azure (Blob) storage account to be used for backups.",
+ "title": "Add Azure storage account"
+ },
+ "reauth_confirm": {
+ "data": {
+ "storage_account_key": "[%key:component::azure_storage::config::step::user::data::storage_account_key%]"
+ },
+ "data_description": {
+ "storage_account_key": "[%key:component::azure_storage::config::step::user::data_description::storage_account_key%]"
+ },
+ "description": "Provide a new storage account key.",
+ "title": "Reauthenticate Azure storage account"
+ },
+ "reconfigure": {
+ "data": {
+ "container_name": "[%key:component::azure_storage::config::step::user::data::container_name%]",
+ "storage_account_key": "[%key:component::azure_storage::config::step::user::data::storage_account_key%]"
+ },
+ "data_description": {
+ "container_name": "[%key:component::azure_storage::config::step::user::data_description::container_name%]",
+ "storage_account_key": "[%key:component::azure_storage::config::step::user::data_description::storage_account_key%]"
+ },
+ "description": "Change the settings of the Azure storage integration.",
+ "title": "Reconfigure Azure storage account"
+ }
+ },
+ "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%]"
+ }
+ },
+ "issues": {
+ "container_not_found": {
+ "title": "Storage container not found",
+ "description": "The storage container {container_name} has not been found in the storage account. Please re-create it manually, then fix this issue."
+ }
+ },
+ "exceptions": {
+ "account_not_found": {
+ "message": "Storage account {account_name} not found"
+ },
+ "cannot_connect": {
+ "message": "Can not connect to storage account {account_name}"
+ },
+ "invalid_auth": {
+ "message": "Authentication failed for storage account {account_name}"
+ },
+ "container_not_found": {
+ "message": "Storage container {container_name} not found"
+ }
+ }
+}
diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py
index 71a4f5ea41a..124ce8b872c 100644
--- a/homeassistant/components/backup/__init__.py
+++ b/homeassistant/components/backup/__init__.py
@@ -1,8 +1,10 @@
"""The Backup integration."""
-from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import config_validation as cv
+from homeassistant.config_entries import SOURCE_SYSTEM
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.helpers import config_validation as cv, discovery_flow
+from homeassistant.helpers.backup import DATA_BACKUP
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.typing import ConfigType
@@ -16,11 +18,14 @@ from .agent import (
BackupAgentPlatformProtocol,
LocalBackupAgent,
)
+from .config import BackupConfig, CreateBackupParametersDict
from .const import DATA_MANAGER, DOMAIN
+from .coordinator import BackupConfigEntry, BackupDataUpdateCoordinator
from .http import async_register_http_views
from .manager import (
BackupManager,
BackupManagerError,
+ BackupPlatformEvent,
BackupPlatformProtocol,
BackupReaderWriter,
BackupReaderWriterError,
@@ -31,6 +36,7 @@ from .manager import (
IdleEvent,
IncorrectPasswordError,
ManagerBackup,
+ ManagerStateEvent,
NewBackup,
RestoreBackupEvent,
RestoreBackupStage,
@@ -47,12 +53,15 @@ __all__ = [
"BackupAgent",
"BackupAgentError",
"BackupAgentPlatformProtocol",
+ "BackupConfig",
"BackupManagerError",
"BackupNotFound",
+ "BackupPlatformEvent",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
"CreateBackupEvent",
+ "CreateBackupParametersDict",
"CreateBackupStage",
"CreateBackupState",
"Folder",
@@ -60,16 +69,18 @@ __all__ = [
"IncorrectPasswordError",
"LocalBackupAgent",
"ManagerBackup",
+ "ManagerStateEvent",
"NewBackup",
"RestoreBackupEvent",
"RestoreBackupStage",
"RestoreBackupState",
"WrittenBackup",
- "async_get_manager",
"suggested_filename",
"suggested_filename_from_name_date",
]
+PLATFORMS = [Platform.SENSOR]
+
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
@@ -88,7 +99,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
backup_manager = BackupManager(hass, reader_writer)
hass.data[DATA_MANAGER] = backup_manager
- await backup_manager.async_setup()
+ try:
+ await backup_manager.async_setup()
+ except Exception as err:
+ hass.data[DATA_BACKUP].manager_ready.set_exception(err)
+ raise
+ else:
+ hass.data[DATA_BACKUP].manager_ready.set_result(None)
async_register_websocket_handlers(hass, with_hassio)
@@ -118,16 +135,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_http_views(hass)
+ discovery_flow.async_create_flow(
+ hass, DOMAIN, context={"source": SOURCE_SYSTEM}, data={}
+ )
+
return True
-@callback
-def async_get_manager(hass: HomeAssistant) -> BackupManager:
- """Get the backup manager instance.
+async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
+ """Set up a config entry."""
+ backup_manager: BackupManager = hass.data[DATA_MANAGER]
+ coordinator = BackupDataUpdateCoordinator(hass, entry, backup_manager)
+ await coordinator.async_config_entry_first_refresh()
- Raises HomeAssistantError if the backup integration is not available.
- """
- if DATA_MANAGER not in hass.data:
- raise HomeAssistantError("Backup integration is not available")
+ entry.async_on_unload(coordinator.async_unsubscribe)
- return hass.data[DATA_MANAGER]
+ entry.runtime_data = coordinator
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/backup/agent.py b/homeassistant/components/backup/agent.py
index 9530f386c7b..8093ac88338 100644
--- a/homeassistant/components/backup/agent.py
+++ b/homeassistant/components/backup/agent.py
@@ -41,6 +41,8 @@ class BackupAgent(abc.ABC):
) -> AsyncIterator[bytes]:
"""Download a backup file.
+ Raises BackupNotFound if the backup does not exist.
+
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: An async iterator that yields bytes.
"""
@@ -67,6 +69,8 @@ class BackupAgent(abc.ABC):
) -> None:
"""Delete a backup file.
+ Raises BackupNotFound if the backup does not exist.
+
:param backup_id: The ID of the backup that was returned in async_list_backups.
"""
@@ -79,8 +83,11 @@ class BackupAgent(abc.ABC):
self,
backup_id: str,
**kwargs: Any,
- ) -> AgentBackup | None:
- """Return a backup."""
+ ) -> AgentBackup:
+ """Return a backup.
+
+ Raises BackupNotFound if the backup does not exist.
+ """
class LocalBackupAgent(BackupAgent):
diff --git a/homeassistant/components/backup/backup.py b/homeassistant/components/backup/backup.py
index c3a46a6ab1f..de2cfecb1a5 100644
--- a/homeassistant/components/backup/backup.py
+++ b/homeassistant/components/backup/backup.py
@@ -88,13 +88,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
self,
backup_id: str,
**kwargs: Any,
- ) -> AgentBackup | None:
+ ) -> AgentBackup:
"""Return a backup."""
if not self._loaded_backups:
await self._load_backups()
if backup_id not in self._backups:
- return None
+ raise BackupNotFound(f"Backup {backup_id} not found")
backup, backup_path = self._backups[backup_id]
if not await self._hass.async_add_executor_job(backup_path.exists):
@@ -107,7 +107,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
backup_path,
)
self._backups.pop(backup_id)
- return None
+ raise BackupNotFound(f"Backup {backup_id} not found")
return backup
@@ -130,10 +130,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
if not self._loaded_backups:
await self._load_backups()
- try:
- backup_path = self.get_backup_path(backup_id)
- except BackupNotFound:
- return
+ backup_path = self.get_backup_path(backup_id)
await self._hass.async_add_executor_job(backup_path.unlink, True)
LOGGER.debug("Deleted backup located at %s", backup_path)
self._backups.pop(backup_id)
diff --git a/homeassistant/components/backup/basic_websocket.py b/homeassistant/components/backup/basic_websocket.py
new file mode 100644
index 00000000000..614dc23a927
--- /dev/null
+++ b/homeassistant/components/backup/basic_websocket.py
@@ -0,0 +1,38 @@
+"""Websocket commands for the Backup integration."""
+
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.backup import async_subscribe_events
+
+from .const import DATA_MANAGER
+from .manager import ManagerStateEvent
+
+
+@callback
+def async_register_websocket_handlers(hass: HomeAssistant) -> None:
+ """Register websocket commands."""
+ websocket_api.async_register_command(hass, handle_subscribe_events)
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
+@websocket_api.async_response
+async def handle_subscribe_events(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Subscribe to backup events."""
+
+ def on_event(event: ManagerStateEvent) -> None:
+ connection.send_message(websocket_api.event_message(msg["id"], event))
+
+ if DATA_MANAGER in hass.data:
+ manager = hass.data[DATA_MANAGER]
+ on_event(manager.last_event)
+ connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event)
+ connection.send_result(msg["id"])
diff --git a/homeassistant/components/backup/config.py b/homeassistant/components/backup/config.py
index 4d0cd82bc44..f4fa2e8bac6 100644
--- a/homeassistant/components/backup/config.py
+++ b/homeassistant/components/backup/config.py
@@ -12,16 +12,19 @@ from typing import TYPE_CHECKING, Self, TypedDict
from cronsim import CronSim
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.event import async_call_later, async_track_point_in_time
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
-from .const import LOGGER
+from .const import DOMAIN, LOGGER
from .models import BackupManagerError, Folder
if TYPE_CHECKING:
from .manager import BackupManager, ManagerBackup
+AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID = "automatic_backup_agents_unavailable"
+
CRON_PATTERN_DAILY = "{m} {h} * * *"
CRON_PATTERN_WEEKLY = "{m} {h} * * {d}"
@@ -39,6 +42,7 @@ class StoredBackupConfig(TypedDict):
"""Represent the stored backup config."""
agents: dict[str, StoredAgentConfig]
+ automatic_backups_configured: bool
create_backup: StoredCreateBackupConfig
last_attempted_automatic_backup: str | None
last_completed_automatic_backup: str | None
@@ -51,6 +55,7 @@ class BackupConfigData:
"""Represent loaded backup config data."""
agents: dict[str, AgentConfig]
+ automatic_backups_configured: bool # only used by frontend
create_backup: CreateBackupConfig
last_attempted_automatic_backup: datetime | None = None
last_completed_automatic_backup: datetime | None = None
@@ -88,6 +93,7 @@ class BackupConfigData:
agent_id: AgentConfig(protected=agent_data["protected"])
for agent_id, agent_data in data["agents"].items()
},
+ automatic_backups_configured=data["automatic_backups_configured"],
create_backup=CreateBackupConfig(
agent_ids=data["create_backup"]["agent_ids"],
include_addons=data["create_backup"]["include_addons"],
@@ -127,6 +133,7 @@ class BackupConfigData:
agents={
agent_id: agent.to_dict() for agent_id, agent in self.agents.items()
},
+ automatic_backups_configured=self.automatic_backups_configured,
create_backup=self.create_backup.to_dict(),
last_attempted_automatic_backup=last_attempted,
last_completed_automatic_backup=last_completed,
@@ -142,10 +149,12 @@ class BackupConfig:
"""Initialize backup config."""
self.data = BackupConfigData(
agents={},
+ automatic_backups_configured=False,
create_backup=CreateBackupConfig(),
retention=RetentionConfig(),
schedule=BackupSchedule(),
)
+ self._hass = hass
self._manager = manager
def load(self, stored_config: StoredBackupConfig) -> None:
@@ -154,10 +163,12 @@ class BackupConfig:
self.data.retention.apply(self._manager)
self.data.schedule.apply(self._manager)
- async def update(
+ @callback
+ def update(
self,
*,
agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
+ automatic_backups_configured: bool | UndefinedType = UNDEFINED,
create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
retention: RetentionParametersDict | UndefinedType = UNDEFINED,
schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
@@ -171,8 +182,12 @@ class BackupConfig:
self.data.agents[agent_id] = replace(
self.data.agents[agent_id], **agent_config
)
+ if automatic_backups_configured is not UNDEFINED:
+ self.data.automatic_backups_configured = automatic_backups_configured
if create_backup is not UNDEFINED:
self.data.create_backup = replace(self.data.create_backup, **create_backup)
+ if "agent_ids" in create_backup:
+ check_unavailable_agents(self._hass, self._manager)
if retention is not UNDEFINED:
new_retention = RetentionConfig(**retention)
if new_retention != self.data.retention:
@@ -553,3 +568,46 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
await manager.async_delete_filtered_backups(
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
)
+
+
+@callback
+def check_unavailable_agents(hass: HomeAssistant, manager: BackupManager) -> None:
+ """Check for unavailable agents."""
+ if missing_agent_ids := set(manager.config.data.create_backup.agent_ids) - set(
+ manager.backup_agents
+ ):
+ LOGGER.debug(
+ "Agents %s are configured for automatic backup but are unavailable",
+ missing_agent_ids,
+ )
+
+ # Remove issues for unavailable agents that are not unavailable anymore.
+ issue_registry = ir.async_get(hass)
+ existing_missing_agent_issue_ids = {
+ issue_id
+ for domain, issue_id in issue_registry.issues
+ if domain == DOMAIN
+ and issue_id.startswith(AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID)
+ }
+ current_missing_agent_issue_ids = {
+ f"{AUTOMATIC_BACKUP_AGENTS_UNAVAILABLE_ISSUE_ID}_{agent_id}": agent_id
+ for agent_id in missing_agent_ids
+ }
+ for issue_id in existing_missing_agent_issue_ids - set(
+ current_missing_agent_issue_ids
+ ):
+ ir.async_delete_issue(hass, DOMAIN, issue_id)
+ for issue_id, agent_id in current_missing_agent_issue_ids.items():
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ issue_id,
+ is_fixable=False,
+ learn_more_url="homeassistant://config/backup",
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="automatic_backup_agents_unavailable",
+ translation_placeholders={
+ "agent_id": agent_id,
+ "backup_settings": "/config/backup/settings",
+ },
+ )
diff --git a/homeassistant/components/backup/config_flow.py b/homeassistant/components/backup/config_flow.py
new file mode 100644
index 00000000000..ab1f884ea86
--- /dev/null
+++ b/homeassistant/components/backup/config_flow.py
@@ -0,0 +1,21 @@
+"""Config flow for Home Assistant Backup integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+
+from .const import DOMAIN
+
+
+class BackupConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Home Assistant Backup."""
+
+ VERSION = 1
+
+ async def async_step_system(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ return self.async_create_entry(title="Backup", data={})
diff --git a/homeassistant/components/backup/const.py b/homeassistant/components/backup/const.py
index c2070a37b2d..773deaef174 100644
--- a/homeassistant/components/backup/const.py
+++ b/homeassistant/components/backup/const.py
@@ -16,8 +16,8 @@ DATA_MANAGER: HassKey[BackupManager] = HassKey(DOMAIN)
LOGGER = getLogger(__package__)
EXCLUDE_FROM_BACKUP = [
- "__pycache__/*",
- ".DS_Store",
+ "**/__pycache__/*",
+ "**/.DS_Store",
".HA_RESTORE",
"*.db-shm",
"*.log.*",
diff --git a/homeassistant/components/backup/coordinator.py b/homeassistant/components/backup/coordinator.py
new file mode 100644
index 00000000000..377f23567e0
--- /dev/null
+++ b/homeassistant/components/backup/coordinator.py
@@ -0,0 +1,81 @@
+"""Coordinator for Home Assistant Backup integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.backup import (
+ async_subscribe_events,
+ async_subscribe_platform_events,
+)
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .const import DOMAIN, LOGGER
+from .manager import (
+ BackupManager,
+ BackupManagerState,
+ BackupPlatformEvent,
+ ManagerStateEvent,
+)
+
+type BackupConfigEntry = ConfigEntry[BackupDataUpdateCoordinator]
+
+
+@dataclass
+class BackupCoordinatorData:
+ """Class to hold backup data."""
+
+ backup_manager_state: BackupManagerState
+ last_successful_automatic_backup: datetime | None
+ next_scheduled_automatic_backup: datetime | None
+
+
+class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
+ """Class to retrieve backup status."""
+
+ config_entry: ConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ backup_manager: BackupManager,
+ ) -> None:
+ """Initialize coordinator."""
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=config_entry,
+ name=DOMAIN,
+ update_interval=None,
+ )
+ self.unsubscribe: list[Callable[[], None]] = [
+ async_subscribe_events(hass, self._on_event),
+ async_subscribe_platform_events(hass, self._on_event),
+ ]
+
+ self.backup_manager = backup_manager
+
+ @callback
+ def _on_event(self, event: ManagerStateEvent | BackupPlatformEvent) -> None:
+ """Handle new event."""
+ LOGGER.debug("Received backup event: %s", event)
+ self.config_entry.async_create_task(self.hass, self.async_refresh())
+
+ async def _async_update_data(self) -> BackupCoordinatorData:
+ """Update backup manager data."""
+ return BackupCoordinatorData(
+ self.backup_manager.state,
+ self.backup_manager.config.data.last_completed_automatic_backup,
+ self.backup_manager.config.data.schedule.next_automatic_backup,
+ )
+
+ @callback
+ def async_unsubscribe(self) -> None:
+ """Unsubscribe from events."""
+ for unsub in self.unsubscribe:
+ unsub()
diff --git a/homeassistant/components/backup/diagnostics.py b/homeassistant/components/backup/diagnostics.py
new file mode 100644
index 00000000000..9c3e28bde5b
--- /dev/null
+++ b/homeassistant/components/backup/diagnostics.py
@@ -0,0 +1,27 @@
+"""Diagnostics support for Home Assistant Backup integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.const import CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+
+from .coordinator import BackupConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: BackupConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ coordinator = entry.runtime_data
+ return {
+ "backup_agents": [
+ {"name": agent.name, "agent_id": agent.agent_id}
+ for agent in coordinator.backup_manager.backup_agents.values()
+ ],
+ "backup_config": async_redact_data(
+ coordinator.backup_manager.config.data.to_dict(), [CONF_PASSWORD]
+ ),
+ }
diff --git a/homeassistant/components/backup/entity.py b/homeassistant/components/backup/entity.py
new file mode 100644
index 00000000000..ff7c7889dc5
--- /dev/null
+++ b/homeassistant/components/backup/entity.py
@@ -0,0 +1,36 @@
+"""Base for backup entities."""
+
+from __future__ import annotations
+
+from homeassistant.const import __version__ as HA_VERSION
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import BackupDataUpdateCoordinator
+
+
+class BackupManagerEntity(CoordinatorEntity[BackupDataUpdateCoordinator]):
+ """Base entity for backup manager."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: BackupDataUpdateCoordinator,
+ entity_description: EntityDescription,
+ ) -> None:
+ """Initialize base entity."""
+ super().__init__(coordinator)
+ self.entity_description = entity_description
+ self._attr_unique_id = entity_description.key
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, "backup_manager")},
+ manufacturer="Home Assistant",
+ model="Home Assistant Backup",
+ sw_version=HA_VERSION,
+ name="Backup",
+ entry_type=DeviceEntryType.SERVICE,
+ configuration_url="homeassistant://config/backup",
+ )
diff --git a/homeassistant/components/backup/http.py b/homeassistant/components/backup/http.py
index 58f44d4a449..8f241e6363d 100644
--- a/homeassistant/components/backup/http.py
+++ b/homeassistant/components/backup/http.py
@@ -15,6 +15,7 @@ from multidict import istr
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import frame
from homeassistant.util import slugify
from . import util
@@ -59,11 +60,19 @@ class DownloadBackupView(HomeAssistantView):
if agent_id not in manager.backup_agents:
return Response(status=HTTPStatus.BAD_REQUEST)
agent = manager.backup_agents[agent_id]
- backup = await agent.async_get_backup(backup_id)
+ try:
+ backup = await agent.async_get_backup(backup_id)
+ except BackupNotFound:
+ return Response(status=HTTPStatus.NOT_FOUND)
- # We don't need to check if the path exists, aiohttp.FileResponse will handle
- # that
- if backup is None:
+ # Check for None to be backwards compatible with the old BackupAgent API,
+ # this can be removed in HA Core 2025.10
+ if not backup:
+ frame.report_usage(
+ "returns None from BackupAgent.async_get_backup",
+ breaks_in_ha_version="2025.10",
+ integration_domain=agent_id.partition(".")[0],
+ )
return Response(status=HTTPStatus.NOT_FOUND)
headers = {
@@ -92,6 +101,8 @@ class DownloadBackupView(HomeAssistantView):
) -> StreamResponse | FileResponse | Response:
if agent_id in manager.local_backup_agents:
local_agent = manager.local_backup_agents[agent_id]
+ # We don't need to check if the path exists, aiohttp.FileResponse will
+ # handle that
path = local_agent.get_backup_path(backup_id)
return FileResponse(path=path.as_posix(), headers=headers)
diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py
index 25393a872cc..43a7be6db8d 100644
--- a/homeassistant/components/backup/manager.py
+++ b/homeassistant/components/backup/manager.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import abc
import asyncio
+from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Coroutine
from dataclasses import dataclass, replace
from enum import StrEnum
@@ -13,6 +14,7 @@ from itertools import chain
import json
from pathlib import Path, PurePath
import shutil
+import sys
import tarfile
import time
from typing import IO, TYPE_CHECKING, Any, Protocol, TypedDict, cast
@@ -28,10 +30,13 @@ from homeassistant.backup_restore import (
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import (
+ frame,
instance_id,
integration_platform,
issue_registry as ir,
+ start,
)
+from homeassistant.helpers.backup import DATA_BACKUP
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util, json as json_util
@@ -42,7 +47,12 @@ from .agent import (
BackupAgentPlatformProtocol,
LocalBackupAgent,
)
-from .config import BackupConfig, delete_backups_exceeding_configured_count
+from .config import (
+ BackupConfig,
+ CreateBackupParametersDict,
+ check_unavailable_agents,
+ delete_backups_exceeding_configured_count,
+)
from .const import (
BUF_SIZE,
DATA_MANAGER,
@@ -55,6 +65,7 @@ from .models import (
AgentBackup,
BackupError,
BackupManagerError,
+ BackupNotFound,
BackupReaderWriterError,
BaseBackup,
Folder,
@@ -109,6 +120,7 @@ class BackupManagerState(StrEnum):
IDLE = "idle"
CREATE_BACKUP = "create_backup"
+ BLOCKED = "blocked"
RECEIVE_BACKUP = "receive_backup"
RESTORE_BACKUP = "restore_backup"
@@ -217,6 +229,20 @@ class RestoreBackupEvent(ManagerStateEvent):
state: RestoreBackupState
+@dataclass(frozen=True, kw_only=True, slots=True)
+class BackupPlatformEvent:
+ """Backup platform class."""
+
+ domain: str
+
+
+@dataclass(frozen=True, kw_only=True, slots=True)
+class BlockedEvent(ManagerStateEvent):
+ """Backup manager blocked, Home Assistant is starting."""
+
+ manager_state: BackupManagerState = BackupManagerState.BLOCKED
+
+
class BackupPlatformProtocol(Protocol):
"""Define the format that backup platforms can have."""
@@ -281,6 +307,10 @@ class BackupReaderWriter(abc.ABC):
) -> None:
"""Get restore events after core restart."""
+ @abc.abstractmethod
+ async def async_validate_config(self, *, config: BackupConfig) -> None:
+ """Validate backup config."""
+
class IncorrectPasswordError(BackupReaderWriterError):
"""Raised when the password is incorrect."""
@@ -296,6 +326,12 @@ class DecryptOnDowloadNotSupported(BackupManagerError):
_message = "On-the-fly decryption is not supported for this backup."
+class BackupManagerExceptionGroup(BackupManagerError, ExceptionGroup):
+ """Raised when multiple exceptions occur."""
+
+ error_code = "multiple_errors"
+
+
class BackupManager:
"""Define the format that backup managers can have."""
@@ -321,9 +357,14 @@ class BackupManager:
self.remove_next_delete_event: Callable[[], None] | None = None
# Latest backup event and backup event subscribers
- self.last_event: ManagerStateEvent = IdleEvent()
- self.last_non_idle_event: ManagerStateEvent | None = None
- self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
+ self.last_event: ManagerStateEvent = BlockedEvent()
+ self.last_action_event: ManagerStateEvent | None = None
+ self._backup_event_subscriptions = hass.data[
+ DATA_BACKUP
+ ].backup_event_subscriptions
+ self._backup_platform_event_subscriptions = hass.data[
+ DATA_BACKUP
+ ].backup_platform_event_subscriptions
async def async_setup(self) -> None:
"""Set up the backup manager."""
@@ -332,10 +373,20 @@ class BackupManager:
self.config.load(stored["config"])
self.known_backups.load(stored["backups"])
+ await self._reader_writer.async_validate_config(config=self.config)
+
await self._reader_writer.async_resume_restore_progress_after_restart(
on_progress=self.async_on_backup_event
)
+ async def set_manager_idle_after_start(hass: HomeAssistant) -> None:
+ """Set manager to idle after start."""
+ self.async_on_backup_event(IdleEvent())
+
+ if self.state == BackupManagerState.BLOCKED:
+ # If we're not finishing a restore job, set the manager to idle after start
+ start.async_at_started(self.hass, set_manager_idle_after_start)
+
await self.load_platforms()
@property
@@ -404,6 +455,13 @@ class BackupManager:
}
)
+ @callback
+ def check_unavailable_agents_after_start(hass: HomeAssistant) -> None:
+ """Check unavailable agents after start."""
+ check_unavailable_agents(hass, self)
+
+ start.async_at_started(self.hass, check_unavailable_agents_after_start)
+
async def _add_platform(
self,
hass: HomeAssistant,
@@ -417,6 +475,9 @@ class BackupManager:
LOGGER.debug("%s platforms loaded in total", len(self.platforms))
LOGGER.debug("%s agents loaded in total", len(self.backup_agents))
LOGGER.debug("%s local agents loaded in total", len(self.local_backup_agents))
+ event = BackupPlatformEvent(domain=integration_domain)
+ for subscription in self._backup_platform_event_subscriptions:
+ subscription(event)
async def async_pre_backup_actions(self) -> None:
"""Perform pre backup actions."""
@@ -560,8 +621,15 @@ class BackupManager:
return_exceptions=True,
)
for idx, result in enumerate(list_backups_results):
+ agent_id = agent_ids[idx]
if isinstance(result, BackupAgentError):
- agent_errors[agent_ids[idx]] = result
+ agent_errors[agent_id] = result
+ continue
+ if isinstance(result, Exception):
+ agent_errors[agent_id] = result
+ LOGGER.error(
+ "Unexpected error for %s: %s", agent_id, result, exc_info=result
+ )
continue
if isinstance(result, BaseException):
raise result # unexpected error
@@ -588,7 +656,7 @@ class BackupManager:
name=agent_backup.name,
with_automatic_settings=with_automatic_settings,
)
- backups[backup_id].agents[agent_ids[idx]] = AgentBackupStatus(
+ backups[backup_id].agents[agent_id] = AgentBackupStatus(
protected=agent_backup.protected,
size=agent_backup.size,
)
@@ -611,12 +679,28 @@ class BackupManager:
return_exceptions=True,
)
for idx, result in enumerate(get_backup_results):
+ agent_id = agent_ids[idx]
+ if isinstance(result, BackupNotFound):
+ continue
if isinstance(result, BackupAgentError):
- agent_errors[agent_ids[idx]] = result
+ agent_errors[agent_id] = result
+ continue
+ if isinstance(result, Exception):
+ agent_errors[agent_id] = result
+ LOGGER.error(
+ "Unexpected error for %s: %s", agent_id, result, exc_info=result
+ )
continue
if isinstance(result, BaseException):
raise result # unexpected error
+ # Check for None to be backwards compatible with the old BackupAgent API,
+ # this can be removed in HA Core 2025.10
if not result:
+ frame.report_usage(
+ "returns None from BackupAgent.async_get_backup",
+ breaks_in_ha_version="2025.10",
+ integration_domain=agent_id.partition(".")[0],
+ )
continue
if backup is None:
if known_backup := self.known_backups.get(backup_id):
@@ -640,7 +724,7 @@ class BackupManager:
name=result.name,
with_automatic_settings=with_automatic_settings,
)
- backup.agents[agent_ids[idx]] = AgentBackupStatus(
+ backup.agents[agent_id] = AgentBackupStatus(
protected=result.protected,
size=result.size,
)
@@ -663,21 +747,33 @@ class BackupManager:
return None
return with_automatic_settings
- async def async_delete_backup(self, backup_id: str) -> dict[str, Exception]:
+ async def async_delete_backup(
+ self, backup_id: str, *, agent_ids: list[str] | None = None
+ ) -> dict[str, Exception]:
"""Delete a backup."""
agent_errors: dict[str, Exception] = {}
- agent_ids = list(self.backup_agents)
+ if agent_ids is None:
+ agent_ids = list(self.backup_agents)
delete_backup_results = await asyncio.gather(
*(
- agent.async_delete_backup(backup_id)
- for agent in self.backup_agents.values()
+ self.backup_agents[agent_id].async_delete_backup(backup_id)
+ for agent_id in agent_ids
),
return_exceptions=True,
)
for idx, result in enumerate(delete_backup_results):
+ agent_id = agent_ids[idx]
+ if isinstance(result, BackupNotFound):
+ continue
if isinstance(result, BackupAgentError):
- agent_errors[agent_ids[idx]] = result
+ agent_errors[agent_id] = result
+ continue
+ if isinstance(result, Exception):
+ agent_errors[agent_id] = result
+ LOGGER.error(
+ "Unexpected error for %s: %s", agent_id, result, exc_info=result
+ )
continue
if isinstance(result, BaseException):
raise result # unexpected error
@@ -710,40 +806,76 @@ class BackupManager:
# Run the include filter first to ensure we only consider backups that
# should be included in the deletion process.
backups = include_filter(backups)
+ backups_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(dict)
+ for backup_id, backup in backups.items():
+ for agent_id in backup.agents:
+ backups_by_agent[agent_id][backup_id] = backup
- LOGGER.debug("Total automatic backups: %s", backups)
+ LOGGER.debug("Backups returned by include filter: %s", backups)
+ LOGGER.debug(
+ "Backups returned by include filter by agent: %s",
+ {agent_id: list(backups) for agent_id, backups in backups_by_agent.items()},
+ )
backups_to_delete = delete_filter(backups)
+ LOGGER.debug("Backups returned by delete filter: %s", backups_to_delete)
+
if not backups_to_delete:
return
# always delete oldest backup first
- backups_to_delete = dict(
- sorted(
- backups_to_delete.items(),
- key=lambda backup_item: backup_item[1].date,
- )
+ backups_to_delete_by_agent: dict[str, dict[str, ManagerBackup]] = defaultdict(
+ dict
+ )
+ for backup_id, backup in sorted(
+ backups_to_delete.items(),
+ key=lambda backup_item: backup_item[1].date,
+ ):
+ for agent_id in backup.agents:
+ backups_to_delete_by_agent[agent_id][backup_id] = backup
+ LOGGER.debug(
+ "Backups returned by delete filter by agent: %s",
+ {
+ agent_id: list(backups)
+ for agent_id, backups in backups_to_delete_by_agent.items()
+ },
+ )
+ for agent_id, to_delete_from_agent in backups_to_delete_by_agent.items():
+ if len(to_delete_from_agent) >= len(backups_by_agent[agent_id]):
+ # Never delete the last backup.
+ last_backup = to_delete_from_agent.popitem()
+ LOGGER.debug(
+ "Keeping the last backup %s for agent %s", last_backup, agent_id
+ )
+
+ LOGGER.debug(
+ "Backups to delete by agent: %s",
+ {
+ agent_id: list(backups)
+ for agent_id, backups in backups_to_delete_by_agent.items()
+ },
)
- if len(backups_to_delete) >= len(backups):
- # Never delete the last backup.
- last_backup = backups_to_delete.popitem()
- LOGGER.debug("Keeping the last backup: %s", last_backup)
+ backup_ids_to_delete: dict[str, set[str]] = defaultdict(set)
+ for agent_id, to_delete in backups_to_delete_by_agent.items():
+ for backup_id in to_delete:
+ backup_ids_to_delete[backup_id].add(agent_id)
- LOGGER.debug("Backups to delete: %s", backups_to_delete)
-
- if not backups_to_delete:
+ if not backup_ids_to_delete:
return
- backup_ids = list(backups_to_delete)
+ backup_ids = list(backup_ids_to_delete)
delete_results = await asyncio.gather(
- *(self.async_delete_backup(backup_id) for backup_id in backups_to_delete)
+ *(
+ self.async_delete_backup(backup_id, agent_ids=list(agent_ids))
+ for backup_id, agent_ids in backup_ids_to_delete.items()
+ )
)
agent_errors = {
backup_id: error
for backup_id, error in zip(backup_ids, delete_results, strict=True)
- if error
+ if error and not isinstance(error, BackupNotFound)
}
if agent_errors:
LOGGER.error(
@@ -1175,7 +1307,20 @@ class BackupManager:
) -> None:
"""Initiate restoring a backup."""
agent = self.backup_agents[agent_id]
- if not await agent.async_get_backup(backup_id):
+ try:
+ backup = await agent.async_get_backup(backup_id)
+ except BackupNotFound as err:
+ raise BackupManagerError(
+ f"Backup {backup_id} not found in agent {agent_id}"
+ ) from err
+ # Check for None to be backwards compatible with the old BackupAgent API,
+ # this can be removed in HA Core 2025.10
+ if not backup:
+ frame.report_usage(
+ "returns None from BackupAgent.async_get_backup",
+ breaks_in_ha_version="2025.10",
+ integration_domain=agent_id.partition(".")[0],
+ )
raise BackupManagerError(
f"Backup {backup_id} not found in agent {agent_id}"
)
@@ -1204,24 +1349,11 @@ class BackupManager:
if (current_state := self.state) != (new_state := event.manager_state):
LOGGER.debug("Backup state: %s -> %s", current_state, new_state)
self.last_event = event
- if not isinstance(event, IdleEvent):
- self.last_non_idle_event = event
+ if not isinstance(event, (BlockedEvent, IdleEvent)):
+ self.last_action_event = event
for subscription in self._backup_event_subscriptions:
subscription(event)
- @callback
- def async_subscribe_events(
- self,
- on_event: Callable[[ManagerStateEvent], None],
- ) -> Callable[[], None]:
- """Subscribe events."""
-
- def remove_subscription() -> None:
- self._backup_event_subscriptions.remove(on_event)
-
- self._backup_event_subscriptions.append(on_event)
- return remove_subscription
-
def _update_issue_backup_failed(self) -> None:
"""Update issue registry when a backup fails."""
ir.async_create_issue(
@@ -1276,7 +1408,20 @@ class BackupManager:
agent = self.backup_agents[agent_id]
except KeyError as err:
raise BackupManagerError(f"Invalid agent selected: {agent_id}") from err
- if not await agent.async_get_backup(backup_id):
+ try:
+ backup = await agent.async_get_backup(backup_id)
+ except BackupNotFound as err:
+ raise BackupManagerError(
+ f"Backup {backup_id} not found in agent {agent_id}"
+ ) from err
+ # Check for None to be backwards compatible with the old BackupAgent API,
+ # this can be removed in HA Core 2025.10
+ if not backup:
+ frame.report_usage(
+ "returns None from BackupAgent.async_get_backup",
+ breaks_in_ha_version="2025.10",
+ integration_domain=agent_id.partition(".")[0],
+ )
raise BackupManagerError(
f"Backup {backup_id} not found in agent {agent_id}"
)
@@ -1536,10 +1681,24 @@ class CoreBackupReaderWriter(BackupReaderWriter):
)
finally:
# Inform integrations the backup is done
+ # If there's an unhandled exception, we keep it so we can rethrow it in case
+ # the post backup actions also fail.
+ unhandled_exc = sys.exception()
try:
- await manager.async_post_backup_actions()
- except BackupManagerError as err:
- raise BackupReaderWriterError(str(err)) from err
+ try:
+ await manager.async_post_backup_actions()
+ except BackupManagerError as err:
+ raise BackupReaderWriterError(str(err)) from err
+ except Exception as err:
+ if not unhandled_exc:
+ raise
+ # If there's an unhandled exception, we wrap both that and the exception
+ # from the post backup actions in an ExceptionGroup so the caller is
+ # aware of both exceptions.
+ raise BackupManagerExceptionGroup(
+ f"Multiple errors when creating backup: {unhandled_exc}, {err}",
+ [unhandled_exc, err],
+ ) from None
def _mkdir_and_generate_backup_contents(
self,
@@ -1551,7 +1710,13 @@ class CoreBackupReaderWriter(BackupReaderWriter):
"""Generate backup contents and return the size."""
if not tar_file_path:
tar_file_path = self.temp_backup_dir / f"{backup_data['slug']}.tar"
- make_backup_dir(tar_file_path.parent)
+ try:
+ make_backup_dir(tar_file_path.parent)
+ except OSError as err:
+ raise BackupReaderWriterError(
+ f"Failed to create dir {tar_file_path.parent}: "
+ f"{err} ({err.__class__.__name__})"
+ ) from err
excludes = EXCLUDE_FROM_BACKUP
if not database_included:
@@ -1561,7 +1726,9 @@ class CoreBackupReaderWriter(BackupReaderWriter):
"""Filter to filter excludes."""
for exclude in excludes:
- if not path.match(exclude):
+ # The home assistant core configuration directory is added as "data"
+ # in the tar file, so we need to prefix that path to the filters.
+ if not path.full_match(f"data/{exclude}"):
continue
LOGGER.debug("Ignoring %s because of %s", path, exclude)
return True
@@ -1589,7 +1756,14 @@ class CoreBackupReaderWriter(BackupReaderWriter):
file_filter=is_excluded_by_filter,
arcname="data",
)
- return (tar_file_path, tar_file_path.stat().st_size)
+ try:
+ stat_result = tar_file_path.stat()
+ except OSError as err:
+ raise BackupReaderWriterError(
+ f"Error getting size of {tar_file_path}: "
+ f"{err} ({err.__class__.__name__})"
+ ) from err
+ return (tar_file_path, stat_result.st_size)
async def async_receive_backup(
self,
@@ -1771,6 +1945,44 @@ class CoreBackupReaderWriter(BackupReaderWriter):
)
on_progress(IdleEvent())
+ async def async_validate_config(self, *, config: BackupConfig) -> None:
+ """Validate backup config.
+
+ Update automatic backup settings to not include addons or folders and remove
+ hassio agents in case a backup created by supervisor was restored.
+ """
+ create_backup = config.data.create_backup
+ if (
+ not create_backup.include_addons
+ and not create_backup.include_all_addons
+ and not create_backup.include_folders
+ and not any(a_id.startswith("hassio.") for a_id in create_backup.agent_ids)
+ ):
+ LOGGER.debug("Backup settings don't need to be adjusted")
+ return
+
+ LOGGER.info(
+ "Adjusting backup settings to not include addons, folders or supervisor locations"
+ )
+ automatic_agents = [
+ agent_id
+ for agent_id in create_backup.agent_ids
+ if not agent_id.startswith("hassio.")
+ ]
+ if (
+ self._local_agent_id not in automatic_agents
+ and "hassio.local" in create_backup.agent_ids
+ ):
+ automatic_agents = [self._local_agent_id, *automatic_agents]
+ config.update(
+ create_backup=CreateBackupParametersDict(
+ agent_ids=automatic_agents,
+ include_addons=None,
+ include_all_addons=False,
+ include_folders=None,
+ )
+ )
+
def _generate_backup_id(date: str, name: str) -> str:
"""Generate a backup ID."""
diff --git a/homeassistant/components/backup/manifest.json b/homeassistant/components/backup/manifest.json
index 6cbfb834c7f..3c7b1e5e014 100644
--- a/homeassistant/components/backup/manifest.json
+++ b/homeassistant/components/backup/manifest.json
@@ -5,8 +5,9 @@
"codeowners": ["@home-assistant/core"],
"dependencies": ["http", "websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/backup",
- "integration_type": "system",
+ "integration_type": "service",
"iot_class": "calculated",
"quality_scale": "internal",
- "requirements": ["cronsim==2.6", "securetar==2025.1.4"]
+ "requirements": ["cronsim==2.6", "securetar==2025.2.1"],
+ "single_config_entry": true
}
diff --git a/homeassistant/components/backup/onboarding.py b/homeassistant/components/backup/onboarding.py
new file mode 100644
index 00000000000..ad7027c988c
--- /dev/null
+++ b/homeassistant/components/backup/onboarding.py
@@ -0,0 +1,136 @@
+"""Backup onboarding views."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from functools import wraps
+from http import HTTPStatus
+from typing import TYPE_CHECKING, Any, Concatenate
+
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPUnauthorized
+import voluptuous as vol
+
+from homeassistant.components.http import KEY_HASS
+from homeassistant.components.http.data_validator import RequestDataValidator
+from homeassistant.components.onboarding import (
+ BaseOnboardingView,
+ NoAuthBaseOnboardingView,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
+
+from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http
+
+if TYPE_CHECKING:
+ from homeassistant.components.onboarding import OnboardingStoreData
+
+
+async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None:
+ """Set up the backup views."""
+
+ hass.http.register_view(BackupInfoView(data))
+ hass.http.register_view(RestoreBackupView(data))
+ hass.http.register_view(UploadBackupView(data))
+
+
+def with_backup_manager[_ViewT: BaseOnboardingView, **_P](
+ func: Callable[
+ Concatenate[_ViewT, BackupManager, web.Request, _P],
+ Coroutine[Any, Any, web.Response],
+ ],
+) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]:
+ """Home Assistant API decorator to check onboarding and inject manager."""
+
+ @wraps(func)
+ async def with_backup(
+ self: _ViewT,
+ request: web.Request,
+ *args: _P.args,
+ **kwargs: _P.kwargs,
+ ) -> web.Response:
+ """Check admin and call function."""
+ if self._data["done"]:
+ raise HTTPUnauthorized
+
+ manager = await async_get_backup_manager(request.app[KEY_HASS])
+ return await func(self, manager, request, *args, **kwargs)
+
+ return with_backup
+
+
+class BackupInfoView(NoAuthBaseOnboardingView):
+ """Get backup info view."""
+
+ url = "/api/onboarding/backup/info"
+ name = "api:onboarding:backup:info"
+
+ @with_backup_manager
+ async def get(self, manager: BackupManager, request: web.Request) -> web.Response:
+ """Return backup info."""
+ backups, _ = await manager.async_get_backups()
+ return self.json(
+ {
+ "backups": list(backups.values()),
+ "state": manager.state,
+ "last_action_event": manager.last_action_event,
+ }
+ )
+
+
+class RestoreBackupView(NoAuthBaseOnboardingView):
+ """Restore backup view."""
+
+ url = "/api/onboarding/backup/restore"
+ name = "api:onboarding:backup:restore"
+
+ @RequestDataValidator(
+ vol.Schema(
+ {
+ vol.Required("backup_id"): str,
+ vol.Required("agent_id"): str,
+ vol.Optional("password"): str,
+ vol.Optional("restore_addons"): [str],
+ vol.Optional("restore_database", default=True): bool,
+ vol.Optional("restore_folders"): [vol.Coerce(Folder)],
+ }
+ )
+ )
+ @with_backup_manager
+ async def post(
+ self, manager: BackupManager, request: web.Request, data: dict[str, Any]
+ ) -> web.Response:
+ """Restore a backup."""
+ try:
+ await manager.async_restore_backup(
+ data["backup_id"],
+ agent_id=data["agent_id"],
+ password=data.get("password"),
+ restore_addons=data.get("restore_addons"),
+ restore_database=data["restore_database"],
+ restore_folders=data.get("restore_folders"),
+ restore_homeassistant=True,
+ )
+ except IncorrectPasswordError:
+ return self.json(
+ {"code": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST
+ )
+ except HomeAssistantError as err:
+ return self.json(
+ {"code": "restore_failed", "message": str(err)},
+ status_code=HTTPStatus.BAD_REQUEST,
+ )
+ return web.Response(status=HTTPStatus.OK)
+
+
+class UploadBackupView(NoAuthBaseOnboardingView, backup_http.UploadBackupView):
+ """Upload backup view."""
+
+ url = "/api/onboarding/backup/upload"
+ name = "api:onboarding:backup:upload"
+
+ @with_backup_manager
+ async def post(self, manager: BackupManager, request: web.Request) -> web.Response:
+ """Upload a backup file."""
+ return await self._post(request)
diff --git a/homeassistant/components/backup/sensor.py b/homeassistant/components/backup/sensor.py
new file mode 100644
index 00000000000..59e98ae7c2d
--- /dev/null
+++ b/homeassistant/components/backup/sensor.py
@@ -0,0 +1,75 @@
+"""Sensor platform for Home Assistant Backup integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import BackupConfigEntry, BackupCoordinatorData
+from .entity import BackupManagerEntity
+from .manager import BackupManagerState
+
+
+@dataclass(kw_only=True, frozen=True)
+class BackupSensorEntityDescription(SensorEntityDescription):
+ """Description for Home Assistant Backup sensor entities."""
+
+ value_fn: Callable[[BackupCoordinatorData], str | datetime | None]
+
+
+BACKUP_MANAGER_DESCRIPTIONS = (
+ BackupSensorEntityDescription(
+ key="backup_manager_state",
+ translation_key="backup_manager_state",
+ device_class=SensorDeviceClass.ENUM,
+ options=[state.value for state in BackupManagerState],
+ value_fn=lambda data: data.backup_manager_state,
+ ),
+ BackupSensorEntityDescription(
+ key="next_scheduled_automatic_backup",
+ translation_key="next_scheduled_automatic_backup",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=lambda data: data.next_scheduled_automatic_backup,
+ ),
+ BackupSensorEntityDescription(
+ key="last_successful_automatic_backup",
+ translation_key="last_successful_automatic_backup",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=lambda data: data.last_successful_automatic_backup,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: BackupConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Sensor set up for backup config entry."""
+
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ BackupManagerSensor(coordinator, description)
+ for description in BACKUP_MANAGER_DESCRIPTIONS
+ )
+
+
+class BackupManagerSensor(BackupManagerEntity, SensorEntity):
+ """Sensor to track backup manager state."""
+
+ entity_description: BackupSensorEntityDescription
+
+ @property
+ def native_value(self) -> str | datetime | None:
+ """Return native value of entity."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/backup/store.py b/homeassistant/components/backup/store.py
index 9b4af823c77..883447853e6 100644
--- a/homeassistant/components/backup/store.py
+++ b/homeassistant/components/backup/store.py
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
STORE_DELAY_SAVE = 30
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
-STORAGE_VERSION_MINOR = 3
+STORAGE_VERSION_MINOR = 5
class StoredBackupData(TypedDict):
@@ -60,6 +60,18 @@ class _BackupStore(Store[StoredBackupData]):
else:
data["config"]["schedule"]["days"] = [state]
data["config"]["schedule"]["recurrence"] = "custom_days"
+ if old_minor_version < 4:
+ # Workaround for a bug in frontend which incorrectly set days to 0
+ # instead of to None for unlimited retention.
+ if data["config"]["retention"]["copies"] == 0:
+ data["config"]["retention"]["copies"] = None
+ if data["config"]["retention"]["days"] == 0:
+ data["config"]["retention"]["days"] = None
+ if old_minor_version < 5:
+ # Version 1.5 adds automatic_backups_configured
+ data["config"]["automatic_backups_configured"] = (
+ data["config"]["create_backup"]["password"] is not None
+ )
# Note: We allow reading data with major version 2.
# Reject if major version is higher than 2.
diff --git a/homeassistant/components/backup/strings.json b/homeassistant/components/backup/strings.json
index 32d76ded049..357bcdbb72f 100644
--- a/homeassistant/components/backup/strings.json
+++ b/homeassistant/components/backup/strings.json
@@ -1,5 +1,9 @@
{
"issues": {
+ "automatic_backup_agents_unavailable": {
+ "title": "The backup location {agent_id} is unavailable",
+ "description": "The backup location `{agent_id}` is unavailable but is still configured for automatic backups.\n\nPlease visit the [automatic backup configuration page]({backup_settings}) to review and update your backup locations. Backups will not be uploaded to selected locations that are unavailable."
+ },
"automatic_backup_failed_create": {
"title": "Automatic backup could not be created",
"description": "The automatic backup could not be created. Please check the logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured."
@@ -18,5 +22,24 @@
"name": "Create automatic backup",
"description": "Creates a new backup with automatic backup settings."
}
+ },
+ "entity": {
+ "sensor": {
+ "backup_manager_state": {
+ "name": "Backup Manager state",
+ "state": {
+ "idle": "[%key:common::state::idle%]",
+ "create_backup": "Creating a backup",
+ "receive_backup": "Receiving a backup",
+ "restore_backup": "Restoring a backup"
+ }
+ },
+ "next_scheduled_automatic_backup": {
+ "name": "Next scheduled automatic backup"
+ },
+ "last_successful_automatic_backup": {
+ "name": "Last successful automatic backup"
+ }
+ }
}
}
diff --git a/homeassistant/components/backup/util.py b/homeassistant/components/backup/util.py
index 9d8f6e815dc..bd77880738e 100644
--- a/homeassistant/components/backup/util.py
+++ b/homeassistant/components/backup/util.py
@@ -104,12 +104,15 @@ def read_backup(backup_path: Path) -> AgentBackup:
bool, homeassistant.get("exclude_database", False)
)
+ extra_metadata = cast(dict[str, bool | str], data.get("extra", {}))
+ date = extra_metadata.get("supervisor.backup_request_date", data["date"])
+
return AgentBackup(
addons=addons,
backup_id=cast(str, data["slug"]),
database_included=database_included,
- date=cast(str, data["date"]),
- extra_metadata=cast(dict[str, bool | str], data.get("extra", {})),
+ date=cast(str, date),
+ extra_metadata=extra_metadata,
folders=folders,
homeassistant_included=homeassistant_included,
homeassistant_version=homeassistant_version,
diff --git a/homeassistant/components/backup/websocket.py b/homeassistant/components/backup/websocket.py
index b6d092e1913..4c370a4224d 100644
--- a/homeassistant/components/backup/websocket.py
+++ b/homeassistant/components/backup/websocket.py
@@ -10,11 +10,7 @@ from homeassistant.helpers import config_validation as cv
from .config import Day, ScheduleRecurrence
from .const import DATA_MANAGER, LOGGER
-from .manager import (
- DecryptOnDowloadNotSupported,
- IncorrectPasswordError,
- ManagerStateEvent,
-)
+from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError
from .models import BackupNotFound, Folder
@@ -34,7 +30,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
websocket_api.async_register_command(hass, handle_delete)
websocket_api.async_register_command(hass, handle_restore)
- websocket_api.async_register_command(hass, handle_subscribe_events)
websocket_api.async_register_command(hass, handle_config_info)
websocket_api.async_register_command(hass, handle_config_update)
@@ -60,7 +55,7 @@ async def handle_info(
"backups": list(backups.values()),
"last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
- "last_non_idle_event": manager.last_non_idle_event,
+ "last_action_event": manager.last_action_event,
"next_automatic_backup": manager.config.data.schedule.next_automatic_backup,
"next_automatic_backup_additional": manager.config.data.schedule.next_automatic_backup_additional,
"state": manager.state,
@@ -346,11 +341,13 @@ async def handle_config_info(
)
+@callback
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required("type"): "backup/config/update",
vol.Optional("agents"): vol.Schema({str: {"protected": bool}}),
+ vol.Optional("automatic_backups_configured"): bool,
vol.Optional("create_backup"): vol.Schema(
{
vol.Optional("agent_ids"): vol.All([str], vol.Unique()),
@@ -368,8 +365,10 @@ async def handle_config_info(
),
vol.Optional("retention"): vol.Schema(
{
- vol.Optional("copies"): vol.Any(int, None),
- vol.Optional("days"): vol.Any(int, None),
+ # Note: We can't use cv.positive_int because it allows 0 even
+ # though 0 is not positive.
+ vol.Optional("copies"): vol.Any(vol.All(int, vol.Range(min=1)), None),
+ vol.Optional("days"): vol.Any(vol.All(int, vol.Range(min=1)), None),
},
),
vol.Optional("schedule"): vol.Schema(
@@ -385,8 +384,7 @@ async def handle_config_info(
),
}
)
-@websocket_api.async_response
-async def handle_config_update(
+def handle_config_update(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
@@ -396,24 +394,5 @@ async def handle_config_update(
changes = dict(msg)
changes.pop("id")
changes.pop("type")
- await manager.config.update(**changes)
- connection.send_result(msg["id"])
-
-
-@websocket_api.require_admin
-@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
-@websocket_api.async_response
-async def handle_subscribe_events(
- hass: HomeAssistant,
- connection: websocket_api.ActiveConnection,
- msg: dict[str, Any],
-) -> None:
- """Subscribe to backup events."""
-
- def on_event(event: ManagerStateEvent) -> None:
- connection.send_message(websocket_api.event_message(msg["id"], event))
-
- manager = hass.data[DATA_MANAGER]
- on_event(manager.last_event)
- connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event)
+ manager.config.update(**changes)
connection.send_result(msg["id"])
diff --git a/homeassistant/components/baf/binary_sensor.py b/homeassistant/components/baf/binary_sensor.py
index 7c855711712..e12bfd8b90c 100644
--- a/homeassistant/components/baf/binary_sensor.py
+++ b/homeassistant/components/baf/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BAFConfigEntry
from .entity import BAFDescriptionEntity
@@ -41,7 +41,7 @@ OCCUPANCY_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: BAFConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BAF binary sensors."""
device = entry.runtime_data
diff --git a/homeassistant/components/baf/climate.py b/homeassistant/components/baf/climate.py
index c30d49e8c9d..abcc2afe254 100644
--- a/homeassistant/components/baf/climate.py
+++ b/homeassistant/components/baf/climate.py
@@ -12,7 +12,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BAFConfigEntry
from .entity import BAFEntity
@@ -21,7 +21,7 @@ from .entity import BAFEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: BAFConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BAF fan auto comfort."""
device = entry.runtime_data
diff --git a/homeassistant/components/baf/fan.py b/homeassistant/components/baf/fan.py
index 8f7aab40b79..c990a248588 100644
--- a/homeassistant/components/baf/fan.py
+++ b/homeassistant/components/baf/fan.py
@@ -14,7 +14,7 @@ from homeassistant.components.fan import (
FanEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -28,7 +28,7 @@ from .entity import BAFEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: BAFConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SenseME fans."""
device = entry.runtime_data
diff --git a/homeassistant/components/baf/light.py b/homeassistant/components/baf/light.py
index 4c0b1e353fe..e8298a8e4d4 100644
--- a/homeassistant/components/baf/light.py
+++ b/homeassistant/components/baf/light.py
@@ -13,7 +13,7 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BAFConfigEntry
from .entity import BAFEntity
@@ -22,7 +22,7 @@ from .entity import BAFEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: BAFConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BAF lights."""
device = entry.runtime_data
diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py
index a2e5e704e4d..87b5cdc095b 100644
--- a/homeassistant/components/baf/number.py
+++ b/homeassistant/components/baf/number.py
@@ -15,7 +15,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BAFConfigEntry
from .const import HALF_DAY_SECS, ONE_DAY_SECS, ONE_MIN_SECS, SPEED_RANGE
@@ -116,7 +116,7 @@ LIGHT_NUMBER_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: BAFConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BAF numbers."""
device = entry.runtime_data
diff --git a/homeassistant/components/baf/sensor.py b/homeassistant/components/baf/sensor.py
index 7e664254a38..e9b8965b7c4 100644
--- a/homeassistant/components/baf/sensor.py
+++ b/homeassistant/components/baf/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BAFConfigEntry
from .entity import BAFDescriptionEntity
@@ -93,7 +93,7 @@ FAN_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: BAFConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BAF fan sensors."""
device = entry.runtime_data
diff --git a/homeassistant/components/baf/strings.json b/homeassistant/components/baf/strings.json
index e2f02a6095e..629a3041df5 100644
--- a/homeassistant/components/baf/strings.json
+++ b/homeassistant/components/baf/strings.json
@@ -23,7 +23,7 @@
"entity": {
"climate": {
"auto_comfort": {
- "name": "Auto comfort"
+ "name": "Auto Comfort"
}
},
"fan": {
@@ -31,7 +31,7 @@
"state_attributes": {
"preset_mode": {
"state": {
- "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]"
+ "auto": "[%key:common::state::auto%]"
}
}
}
@@ -39,25 +39,25 @@
},
"number": {
"comfort_min_speed": {
- "name": "Auto Comfort Minimum Speed"
+ "name": "Auto Comfort minimum speed"
},
"comfort_max_speed": {
- "name": "Auto Comfort Maximum Speed"
+ "name": "Auto Comfort maximum speed"
},
"comfort_heat_assist_speed": {
- "name": "Auto Comfort Heat Assist Speed"
+ "name": "Auto Comfort Heat Assist speed"
},
"return_to_auto_timeout": {
- "name": "Return to Auto Timeout"
+ "name": "Return to Auto timeout"
},
"motion_sense_timeout": {
- "name": "Motion Sense Timeout"
+ "name": "Motion sense timeout"
},
"light_return_to_auto_timeout": {
- "name": "Light Return to Auto Timeout"
+ "name": "Light return to Auto timeout"
},
"light_auto_motion_timeout": {
- "name": "Light Motion Sense Timeout"
+ "name": "Light motion sense timeout"
}
},
"sensor": {
@@ -76,10 +76,10 @@
},
"switch": {
"legacy_ir_remote_enable": {
- "name": "Legacy IR Remote"
+ "name": "Legacy IR remote"
},
"led_indicators_enable": {
- "name": "Led Indicators"
+ "name": "LED indicators"
},
"comfort_heat_assist_enable": {
"name": "Auto Comfort Heat Assist"
@@ -88,10 +88,10 @@
"name": "Beep"
},
"eco_enable": {
- "name": "Eco Mode"
+ "name": "Eco mode"
},
"motion_sense_enable": {
- "name": "Motion Sense"
+ "name": "Motion sense"
},
"return_to_auto_enable": {
"name": "Return to Auto"
@@ -103,7 +103,7 @@
"name": "Dim to Warm"
},
"light_return_to_auto_enable": {
- "name": "Light Return to Auto"
+ "name": "Light return to Auto"
}
}
}
diff --git a/homeassistant/components/baf/switch.py b/homeassistant/components/baf/switch.py
index e18e26ddcaa..50bd90a6107 100644
--- a/homeassistant/components/baf/switch.py
+++ b/homeassistant/components/baf/switch.py
@@ -11,7 +11,7 @@ from aiobafi6 import Device
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BAFConfigEntry
from .entity import BAFDescriptionEntity
@@ -103,7 +103,7 @@ LIGHT_SWITCHES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: BAFConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BAF fan switches."""
device = entry.runtime_data
diff --git a/homeassistant/components/balay/__init__.py b/homeassistant/components/balay/__init__.py
new file mode 100644
index 00000000000..e7fa8bba86d
--- /dev/null
+++ b/homeassistant/components/balay/__init__.py
@@ -0,0 +1 @@
+"""Balay virtual integration."""
diff --git a/homeassistant/components/balay/manifest.json b/homeassistant/components/balay/manifest.json
new file mode 100644
index 00000000000..98e4f521c7a
--- /dev/null
+++ b/homeassistant/components/balay/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "balay",
+ "name": "Balay",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+}
diff --git a/homeassistant/components/balboa/__init__.py b/homeassistant/components/balboa/__init__.py
index c982d59d513..54ae569bb78 100644
--- a/homeassistant/components/balboa/__init__.py
+++ b/homeassistant/components/balboa/__init__.py
@@ -21,12 +21,14 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
+ Platform.EVENT,
Platform.FAN,
Platform.LIGHT,
Platform.SELECT,
+ Platform.SWITCH,
+ Platform.TIME,
]
-
KEEP_ALIVE_INTERVAL = timedelta(minutes=1)
SYNC_TIME_INTERVAL = timedelta(hours=1)
diff --git a/homeassistant/components/balboa/binary_sensor.py b/homeassistant/components/balboa/binary_sensor.py
index b8c62ce8abf..437a01866b8 100644
--- a/homeassistant/components/balboa/binary_sensor.py
+++ b/homeassistant/components/balboa/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BalboaConfigEntry
from .entity import BalboaEntity
@@ -22,7 +22,7 @@ from .entity import BalboaEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: BalboaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the spa's binary sensors."""
spa = entry.runtime_data
diff --git a/homeassistant/components/balboa/climate.py b/homeassistant/components/balboa/climate.py
index 76b02f0e165..3fb2457d610 100644
--- a/homeassistant/components/balboa/climate.py
+++ b/homeassistant/components/balboa/climate.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BalboaConfigEntry
from .const import DOMAIN
@@ -47,7 +47,7 @@ TEMPERATURE_UNIT_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
entry: BalboaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the spa climate entity."""
async_add_entities([BalboaClimateEntity(entry.runtime_data)])
diff --git a/homeassistant/components/balboa/event.py b/homeassistant/components/balboa/event.py
new file mode 100644
index 00000000000..57263c34783
--- /dev/null
+++ b/homeassistant/components/balboa/event.py
@@ -0,0 +1,91 @@
+"""Support for Balboa events."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+
+from pybalboa import EVENT_UPDATE, SpaClient
+
+from homeassistant.components.event import EventEntity
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.event import async_track_time_interval
+
+from . import BalboaConfigEntry
+from .entity import BalboaEntity
+
+FAULT = "fault"
+FAULT_DATE = "fault_date"
+REQUEST_FAULT_LOG_INTERVAL = timedelta(minutes=5)
+
+FAULT_MESSAGE_CODE_MAP: dict[int, str] = {
+ 15: "sensor_out_of_sync",
+ 16: "low_flow",
+ 17: "flow_failed",
+ 18: "settings_reset",
+ 19: "priming_mode",
+ 20: "clock_failed",
+ 21: "settings_reset",
+ 22: "memory_failure",
+ 26: "service_sensor_sync",
+ 27: "heater_dry",
+ 28: "heater_may_be_dry",
+ 29: "water_too_hot",
+ 30: "heater_too_hot",
+ 31: "sensor_a_fault",
+ 32: "sensor_b_fault",
+ 34: "pump_stuck",
+ 35: "hot_fault",
+ 36: "gfci_test_failed",
+ 37: "standby_mode",
+}
+FAULT_EVENT_TYPES = sorted(set(FAULT_MESSAGE_CODE_MAP.values()))
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: BalboaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the spa's events."""
+ async_add_entities([BalboaEventEntity(entry.runtime_data)])
+
+
+class BalboaEventEntity(BalboaEntity, EventEntity):
+ """Representation of a Balboa event entity."""
+
+ _attr_event_types = FAULT_EVENT_TYPES
+ _attr_translation_key = FAULT
+
+ def __init__(self, spa: SpaClient) -> None:
+ """Initialize a Balboa event entity."""
+ super().__init__(spa, FAULT)
+
+ @callback
+ def _async_handle_event(self) -> None:
+ """Handle the fault event."""
+ if not (fault := self._client.fault):
+ return
+ fault_date = fault.fault_datetime.isoformat()
+ if self.state_attributes.get(FAULT_DATE) != fault_date:
+ self._trigger_event(
+ FAULT_MESSAGE_CODE_MAP.get(fault.message_code, fault.message),
+ {FAULT_DATE: fault_date, "code": fault.message_code},
+ )
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ self.async_on_remove(self._client.on(EVENT_UPDATE, self._async_handle_event))
+
+ async def request_fault_log(now: datetime | None = None) -> None:
+ """Request the most recent fault log."""
+ await self._client.request_fault_log()
+
+ await request_fault_log()
+ self.async_on_remove(
+ async_track_time_interval(
+ self.hass, request_fault_log, REQUEST_FAULT_LOG_INTERVAL
+ )
+ )
diff --git a/homeassistant/components/balboa/fan.py b/homeassistant/components/balboa/fan.py
index 3ecfec53a1e..b0d4379594b 100644
--- a/homeassistant/components/balboa/fan.py
+++ b/homeassistant/components/balboa/fan.py
@@ -10,7 +10,7 @@ from pybalboa.enums import OffOnState, UnknownState
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -23,7 +23,7 @@ from .entity import BalboaEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: BalboaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the spa's pumps."""
spa = entry.runtime_data
diff --git a/homeassistant/components/balboa/light.py b/homeassistant/components/balboa/light.py
index 21e4dfc5e08..2f48747c084 100644
--- a/homeassistant/components/balboa/light.py
+++ b/homeassistant/components/balboa/light.py
@@ -9,7 +9,7 @@ from pybalboa.enums import OffOnState, UnknownState
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BalboaConfigEntry
from .entity import BalboaEntity
@@ -18,7 +18,7 @@ from .entity import BalboaEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: BalboaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the spa's lights."""
spa = entry.runtime_data
diff --git a/homeassistant/components/balboa/manifest.json b/homeassistant/components/balboa/manifest.json
index 867e277358c..38d32adc4af 100644
--- a/homeassistant/components/balboa/manifest.json
+++ b/homeassistant/components/balboa/manifest.json
@@ -14,5 +14,5 @@
"documentation": "https://www.home-assistant.io/integrations/balboa",
"iot_class": "local_push",
"loggers": ["pybalboa"],
- "requirements": ["pybalboa==1.0.2"]
+ "requirements": ["pybalboa==1.1.3"]
}
diff --git a/homeassistant/components/balboa/select.py b/homeassistant/components/balboa/select.py
index e88e40ab063..ea82760744c 100644
--- a/homeassistant/components/balboa/select.py
+++ b/homeassistant/components/balboa/select.py
@@ -5,7 +5,7 @@ from pybalboa.enums import LowHighRange
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BalboaConfigEntry
from .entity import BalboaEntity
@@ -14,7 +14,7 @@ from .entity import BalboaEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: BalboaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the spa select entity."""
spa = entry.runtime_data
diff --git a/homeassistant/components/balboa/strings.json b/homeassistant/components/balboa/strings.json
index c00567a6052..8297e2e3b9f 100644
--- a/homeassistant/components/balboa/strings.json
+++ b/homeassistant/components/balboa/strings.json
@@ -57,6 +57,35 @@
}
}
},
+ "event": {
+ "fault": {
+ "name": "Fault",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "sensor_out_of_sync": "Sensors are out of sync",
+ "low_flow": "The water flow is low",
+ "flow_failed": "The water flow has failed",
+ "settings_reset": "The settings have been reset",
+ "priming_mode": "Priming mode",
+ "clock_failed": "The clock has failed",
+ "memory_failure": "Program memory failure",
+ "service_sensor_sync": "Sensors are out of sync -- call for service",
+ "heater_dry": "The heater is dry",
+ "heater_may_be_dry": "The heater may be dry",
+ "water_too_hot": "The water is too hot",
+ "heater_too_hot": "The heater is too hot",
+ "sensor_a_fault": "Sensor A fault",
+ "sensor_b_fault": "Sensor B fault",
+ "pump_stuck": "A pump may be stuck on",
+ "hot_fault": "Hot fault",
+ "gfci_test_failed": "The GFCI test failed",
+ "standby_mode": "Standby mode (hold mode)"
+ }
+ }
+ }
+ }
+ },
"fan": {
"pump": {
"name": "Pump {index}"
@@ -74,10 +103,23 @@
"temperature_range": {
"name": "Temperature range",
"state": {
- "low": "Low",
- "high": "High"
+ "low": "[%key:common::state::low%]",
+ "high": "[%key:common::state::high%]"
}
}
+ },
+ "switch": {
+ "filter_cycle_2_enabled": {
+ "name": "Filter cycle 2 enabled"
+ }
+ },
+ "time": {
+ "filter_cycle_start": {
+ "name": "Filter cycle {index} start"
+ },
+ "filter_cycle_end": {
+ "name": "Filter cycle {index} end"
+ }
}
}
}
diff --git a/homeassistant/components/balboa/switch.py b/homeassistant/components/balboa/switch.py
new file mode 100644
index 00000000000..c8c947f499d
--- /dev/null
+++ b/homeassistant/components/balboa/switch.py
@@ -0,0 +1,48 @@
+"""Support for Balboa switches."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pybalboa import SpaClient
+
+from homeassistant.components.switch import SwitchEntity
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import BalboaConfigEntry
+from .entity import BalboaEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: BalboaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the spa's switches."""
+ spa = entry.runtime_data
+ async_add_entities([BalboaSwitchEntity(spa)])
+
+
+class BalboaSwitchEntity(BalboaEntity, SwitchEntity):
+ """Representation of a Balboa switch entity."""
+
+ def __init__(self, spa: SpaClient) -> None:
+ """Initialize a Balboa switch entity."""
+ super().__init__(spa, "filter_cycle_2_enabled")
+ self._attr_entity_category = EntityCategory.CONFIG
+ self._attr_translation_key = "filter_cycle_2_enabled"
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if entity is on."""
+ return self._client.filter_cycle_2_enabled
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ await self._client.configure_filter_cycle(2, enabled=True)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ await self._client.configure_filter_cycle(2, enabled=False)
diff --git a/homeassistant/components/balboa/time.py b/homeassistant/components/balboa/time.py
new file mode 100644
index 00000000000..83467de8777
--- /dev/null
+++ b/homeassistant/components/balboa/time.py
@@ -0,0 +1,56 @@
+"""Support for Balboa times."""
+
+from __future__ import annotations
+
+from datetime import time
+import itertools
+from typing import Any
+
+from pybalboa import SpaClient
+
+from homeassistant.components.time import TimeEntity
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import BalboaConfigEntry
+from .entity import BalboaEntity
+
+FILTER_CYCLE = "filter_cycle_"
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: BalboaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the spa's times."""
+ spa = entry.runtime_data
+ async_add_entities(
+ BalboaTimeEntity(spa, index, period)
+ for index, period in itertools.product((1, 2), ("start", "end"))
+ )
+
+
+class BalboaTimeEntity(BalboaEntity, TimeEntity):
+ """Representation of a Balboa time entity."""
+
+ entity_category = EntityCategory.CONFIG
+
+ def __init__(self, spa: SpaClient, index: int, period: str) -> None:
+ """Initialize a Balboa time entity."""
+ super().__init__(spa, f"{FILTER_CYCLE}{index}_{period}")
+ self.index = index
+ self.period = period
+ self._attr_translation_key = f"{FILTER_CYCLE}{period}"
+ self._attr_translation_placeholders = {"index": str(index)}
+
+ @property
+ def native_value(self) -> time | None:
+ """Return the value reported by the time."""
+ return getattr(self._client, f"{FILTER_CYCLE}{self.index}_{self.period}")
+
+ async def async_set_value(self, value: time) -> None:
+ """Change the time."""
+ args: dict[str, Any] = {self.period: value}
+ await self._client.configure_filter_cycle(self.index, **args)
diff --git a/homeassistant/components/bang_olufsen/diagnostics.py b/homeassistant/components/bang_olufsen/diagnostics.py
index bf7b06e694a..3835de7c551 100644
--- a/homeassistant/components/bang_olufsen/diagnostics.py
+++ b/homeassistant/components/bang_olufsen/diagnostics.py
@@ -4,12 +4,13 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
+from homeassistant.components.event import DOMAIN as EVENT_DOMAIN
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import BangOlufsenConfigEntry
-from .const import DOMAIN
+from .const import DEVICE_BUTTONS, DOMAIN
async def async_get_config_entry_diagnostics(
@@ -25,8 +26,9 @@ async def async_get_config_entry_diagnostics(
if TYPE_CHECKING:
assert config_entry.unique_id
- # Add media_player entity's state
entity_registry = er.async_get(hass)
+
+ # Add media_player entity's state
if entity_id := entity_registry.async_get_entity_id(
MEDIA_PLAYER_DOMAIN, DOMAIN, config_entry.unique_id
):
@@ -37,4 +39,16 @@ async def async_get_config_entry_diagnostics(
state_dict.pop("context")
data["media_player"] = state_dict
+ # Add button Event entity states (if enabled)
+ for device_button in DEVICE_BUTTONS:
+ if entity_id := entity_registry.async_get_entity_id(
+ EVENT_DOMAIN, DOMAIN, f"{config_entry.unique_id}_{device_button}"
+ ):
+ if state := hass.states.get(entity_id):
+ state_dict = dict(state.as_dict())
+
+ # Remove context as it is not relevant
+ state_dict.pop("context")
+ data[f"{device_button}_event"] = state_dict
+
return data
diff --git a/homeassistant/components/bang_olufsen/event.py b/homeassistant/components/bang_olufsen/event.py
index 99e5c8bb6fd..91e04b92330 100644
--- a/homeassistant/components/bang_olufsen/event.py
+++ b/homeassistant/components/bang_olufsen/event.py
@@ -6,7 +6,7 @@ from homeassistant.components.event import EventDeviceClass, EventEntity
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BangOlufsenConfigEntry
from .const import (
@@ -25,7 +25,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BangOlufsenConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensor entities from config entry."""
diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py
index 282ecdd2ae5..efb6843356b 100644
--- a/homeassistant/components/bang_olufsen/media_player.py
+++ b/homeassistant/components/bang_olufsen/media_player.py
@@ -64,7 +64,7 @@ from homeassistant.helpers import (
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.util.dt import utcnow
@@ -118,7 +118,7 @@ BANG_OLUFSEN_FEATURES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BangOlufsenConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Media Player entity from config entry."""
# Add MediaPlayer entity
diff --git a/homeassistant/components/bang_olufsen/strings.json b/homeassistant/components/bang_olufsen/strings.json
index 57ab828f9fb..422dc4be567 100644
--- a/homeassistant/components/bang_olufsen/strings.json
+++ b/homeassistant/components/bang_olufsen/strings.json
@@ -29,7 +29,7 @@
"description": "Manually configure your Bang & Olufsen device."
},
"zeroconf_confirm": {
- "title": "Setup Bang & Olufsen device",
+ "title": "Set up Bang & Olufsen device",
"description": "Confirm the configuration of the {model}-{serial_number} @ {host}."
}
}
@@ -197,11 +197,11 @@
"services": {
"beolink_allstandby": {
"name": "Beolink all standby",
- "description": "Set all Connected Beolink devices to standby."
+ "description": "Sets all connected Beolink devices to standby."
},
"beolink_expand": {
"name": "Beolink expand",
- "description": "Expand current Beolink experience.",
+ "description": "Adds devices to the current Beolink experience.",
"fields": {
"all_discovered": {
"name": "All discovered",
@@ -221,7 +221,7 @@
},
"beolink_join": {
"name": "Beolink join",
- "description": "Join a Beolink experience.",
+ "description": "Joins a Beolink experience.",
"fields": {
"beolink_jid": {
"name": "Beolink JID",
@@ -241,11 +241,11 @@
},
"beolink_leave": {
"name": "Beolink leave",
- "description": "Leave a Beolink experience."
+ "description": "Leaves a Beolink experience."
},
"beolink_unexpand": {
"name": "Beolink unexpand",
- "description": "Unexpand from current Beolink experience.",
+ "description": "Removes devices from the current Beolink experience.",
"fields": {
"beolink_jids": {
"name": "Beolink JIDs",
@@ -274,7 +274,7 @@
"message": "An error occurred while attempting to play {media_type}: {error_message}."
},
"invalid_grouping_entity": {
- "message": "Entity with id: {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?"
+ "message": "Entity with ID {entity_id} can't be added to the Beolink session. Is the entity a Bang & Olufsen media_player?"
},
"invalid_sound_mode": {
"message": "{invalid_sound_mode} is an invalid sound mode. Valid values are: {valid_sound_modes}."
diff --git a/homeassistant/components/bayesian/strings.json b/homeassistant/components/bayesian/strings.json
index 9ebccedc88d..00de79a2229 100644
--- a/homeassistant/components/bayesian/strings.json
+++ b/homeassistant/components/bayesian/strings.json
@@ -5,14 +5,14 @@
"title": "Manual YAML fix required for Bayesian"
},
"no_prob_given_false": {
- "description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yml` for `bayesian/{entity}`. These observations will be ignored until you do.",
+ "description": "In the Bayesian integration `prob_given_false` is now a required configuration variable as there was no mathematical rationale for the previous default value. Please add this to your `configuration.yaml` for `bayesian/{entity}`. These observations will be ignored until you do.",
"title": "Manual YAML addition required for Bayesian"
}
},
"services": {
"reload": {
"name": "[%key:common::action::reload%]",
- "description": "Reloads bayesian sensors from the YAML-configuration."
+ "description": "Reloads Bayesian sensors from the YAML-configuration."
}
}
}
diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json
index b86a6374f28..ea897ed1c49 100644
--- a/homeassistant/components/binary_sensor/strings.json
+++ b/homeassistant/components/binary_sensor/strings.json
@@ -124,15 +124,15 @@
"battery": {
"name": "Battery",
"state": {
- "off": "Normal",
- "on": "Low"
+ "off": "[%key:common::state::normal%]",
+ "on": "[%key:common::state::low%]"
}
},
"battery_charging": {
"name": "Charging",
"state": {
"off": "Not charging",
- "on": "Charging"
+ "on": "[%key:common::state::charging%]"
}
},
"carbon_monoxide": {
@@ -145,7 +145,7 @@
"cold": {
"name": "Cold",
"state": {
- "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]",
+ "off": "[%key:common::state::normal%]",
"on": "Cold"
}
},
@@ -180,7 +180,7 @@
"heat": {
"name": "Heat",
"state": {
- "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]",
+ "off": "[%key:common::state::normal%]",
"on": "Hot"
}
},
diff --git a/homeassistant/components/blebox/binary_sensor.py b/homeassistant/components/blebox/binary_sensor.py
index 2aa86059ee2..b9032e6e705 100644
--- a/homeassistant/components/blebox/binary_sensor.py
+++ b/homeassistant/components/blebox/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
@@ -24,7 +24,7 @@ BINARY_SENSOR_TYPES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BleBoxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
entities = [
diff --git a/homeassistant/components/blebox/button.py b/homeassistant/components/blebox/button.py
index 90356c8ae14..15867f84029 100644
--- a/homeassistant/components/blebox/button.py
+++ b/homeassistant/components/blebox/button.py
@@ -6,7 +6,7 @@ import blebox_uniapi.button
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
@@ -15,7 +15,7 @@ from .entity import BleBoxEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BleBoxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox button entry."""
entities = [
diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py
index 2c528d50e3e..dbf4a326990 100644
--- a/homeassistant/components/blebox/climate.py
+++ b/homeassistant/components/blebox/climate.py
@@ -13,7 +13,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
@@ -38,7 +38,7 @@ BLEBOX_TO_HVACACTION = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BleBoxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox climate entity."""
entities = [
diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py
index 4f2a7eeef11..c52c551bbac 100644
--- a/homeassistant/components/blebox/cover.py
+++ b/homeassistant/components/blebox/cover.py
@@ -16,7 +16,7 @@ from homeassistant.components.cover import (
CoverState,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
@@ -45,7 +45,7 @@ BLEBOX_TO_HASS_COVER_STATES = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BleBoxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
entities = [
diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py
index c3c9de8be51..86ec8993779 100644
--- a/homeassistant/components/blebox/light.py
+++ b/homeassistant/components/blebox/light.py
@@ -21,7 +21,7 @@ from homeassistant.components.light import (
LightEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from . import BleBoxConfigEntry
@@ -35,7 +35,7 @@ SCAN_INTERVAL = timedelta(seconds=5)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BleBoxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
entities = [
diff --git a/homeassistant/components/blebox/sensor.py b/homeassistant/components/blebox/sensor.py
index c0abff31257..5120a7a3c98 100644
--- a/homeassistant/components/blebox/sensor.py
+++ b/homeassistant/components/blebox/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
@@ -116,7 +116,7 @@ SENSOR_TYPES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BleBoxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox entry."""
entities = [
diff --git a/homeassistant/components/blebox/switch.py b/homeassistant/components/blebox/switch.py
index c6f439e27c5..1598d4db6fa 100644
--- a/homeassistant/components/blebox/switch.py
+++ b/homeassistant/components/blebox/switch.py
@@ -7,7 +7,7 @@ import blebox_uniapi.switch
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BleBoxConfigEntry
from .entity import BleBoxEntity
@@ -18,7 +18,7 @@ SCAN_INTERVAL = timedelta(seconds=5)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BleBoxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a BleBox switch entity."""
entities = [
diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py
index bfb8aa9a3a0..17fd003742f 100644
--- a/homeassistant/components/blink/alarm_control_panel.py
+++ b/homeassistant/components/blink/alarm_control_panel.py
@@ -15,7 +15,7 @@ from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BlinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Blink Alarm Control Panels."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py
index c11d4cfea23..3d5430c8d1f 100644
--- a/homeassistant/components/blink/binary_sensor.py
+++ b/homeassistant/components/blink/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -48,7 +48,7 @@ BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BlinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the blink binary sensors."""
diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py
index e35dd20eea7..04bd125d249 100644
--- a/homeassistant/components/blink/camera.py
+++ b/homeassistant/components/blink/camera.py
@@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -38,7 +38,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BlinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Blink Camera."""
diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py
index e0b5989cc80..1df708c3a10 100644
--- a/homeassistant/components/blink/sensor.py
+++ b/homeassistant/components/blink/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_BRAND, DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH
@@ -47,7 +47,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BlinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize a Blink sensor."""
diff --git a/homeassistant/components/blink/switch.py b/homeassistant/components/blink/switch.py
index 8eabd5c0e59..4f490e28310 100644
--- a/homeassistant/components/blink/switch.py
+++ b/homeassistant/components/blink/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_BRAND, DOMAIN, TYPE_CAMERA_ARMED
@@ -30,7 +30,7 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BlinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Blink switches."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/blue_current/sensor.py b/homeassistant/components/blue_current/sensor.py
index be39e9571ec..9ea444f9ec2 100644
--- a/homeassistant/components/blue_current/sensor.py
+++ b/homeassistant/components/blue_current/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BlueCurrentConfigEntry, Connector
from .const import DOMAIN
@@ -212,7 +212,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: BlueCurrentConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Blue Current sensors."""
connector = entry.runtime_data
diff --git a/homeassistant/components/blue_current/strings.json b/homeassistant/components/blue_current/strings.json
index 0154c794c33..a8a9aff7f08 100644
--- a/homeassistant/components/blue_current/strings.json
+++ b/homeassistant/components/blue_current/strings.json
@@ -28,20 +28,20 @@
"name": "Activity",
"state": {
"available": "Available",
- "charging": "Charging",
+ "charging": "[%key:common::state::charging%]",
"unavailable": "Unavailable",
- "error": "Error",
+ "error": "[%key:common::state::error%]",
"offline": "Offline"
}
},
"vehicle_status": {
"name": "Vehicle status",
"state": {
- "standby": "Standby",
+ "standby": "[%key:common::state::standby%]",
"vehicle_detected": "Detected",
"ready": "Ready",
"no_power": "No power",
- "vehicle_error": "Error"
+ "vehicle_error": "[%key:common::state::error%]"
}
},
"actual_v1": {
diff --git a/homeassistant/components/bluemaestro/sensor.py b/homeassistant/components/bluemaestro/sensor.py
index 57702d4ff31..1163f8a1ff6 100644
--- a/homeassistant/components/bluemaestro/sensor.py
+++ b/homeassistant/components/bluemaestro/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from . import BlueMaestroConfigEntry
@@ -116,7 +116,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: BlueMaestroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the BlueMaestro BLE sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/blueprint/importer.py b/homeassistant/components/blueprint/importer.py
index 8582761bafb..83afa511b68 100644
--- a/homeassistant/components/blueprint/importer.py
+++ b/homeassistant/components/blueprint/importer.py
@@ -6,6 +6,7 @@ from contextlib import suppress
from dataclasses import dataclass
import html
import re
+from typing import TYPE_CHECKING
import voluptuous as vol
import yarl
@@ -195,8 +196,8 @@ async def fetch_blueprint_from_github_gist_url(
)
gist = await resp.json()
- blueprint = None
- filename = None
+ blueprint: Blueprint | None = None
+ filename: str | None = None
content: str
for filename, info in gist["files"].items():
@@ -218,6 +219,8 @@ async def fetch_blueprint_from_github_gist_url(
"No valid blueprint found in the gist. The blueprint file needs to end with"
" '.yaml'"
)
+ if TYPE_CHECKING:
+ assert isinstance(filename, str)
return ImportedBlueprint(
f"{gist['owner']['login']}/{filename[:-5]}", content, blueprint
diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py
index 2f002b70e1d..cfb6646d829 100644
--- a/homeassistant/components/bluesound/config_flow.py
+++ b/homeassistant/components/bluesound/config_flow.py
@@ -75,6 +75,9 @@ class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN):
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a flow initialized by zeroconf discovery."""
+ # the player can have an ipv6 address, but the api is only available on ipv4
+ if discovery_info.ip_address.version != 4:
+ return self.async_abort(reason="no_ipv4_address")
if discovery_info.port is not None:
self._port = discovery_info.port
diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json
index 151c1512b74..caf5cc7541d 100644
--- a/homeassistant/components/bluesound/manifest.json
+++ b/homeassistant/components/bluesound/manifest.json
@@ -6,7 +6,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/bluesound",
"iot_class": "local_polling",
- "requirements": ["pyblu==2.0.0"],
+ "requirements": ["pyblu==2.0.1"],
"zeroconf": [
{
"type": "_musc._tcp.local."
diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py
index 6bb3c101cd1..337dc3d3a33 100644
--- a/homeassistant/components/bluesound/media_player.py
+++ b/homeassistant/components/bluesound/media_player.py
@@ -32,7 +32,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -61,7 +61,7 @@ POLL_TIMEOUT = 120
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BluesoundConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Bluesound entry."""
bluesound_player = BluesoundPlayer(
@@ -330,7 +330,12 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
if self._status.input_id is not None:
for input_ in self._inputs:
- if input_.id == self._status.input_id:
+ # the input might not have an id => also try to match on the stream_url/url
+ # we have to use both because neither matches all the time
+ if (
+ input_.id == self._status.input_id
+ or input_.url == self._status.stream_url
+ ):
return input_.text
for preset in self._presets:
@@ -501,18 +506,16 @@ class BluesoundPlayer(CoordinatorEntity[BluesoundCoordinator], MediaPlayerEntity
return
# presets and inputs might have the same name; presets have priority
- url: str | None = None
for input_ in self._inputs:
if input_.text == source:
- url = input_.url
+ await self._player.play_url(input_.url)
+ return
for preset in self._presets:
if preset.name == source:
- url = preset.url
+ await self._player.load_preset(preset.id)
+ return
- if url is None:
- raise ServiceValidationError(f"Source {source} not found")
-
- await self._player.play_url(url)
+ raise ServiceValidationError(f"Source {source} not found")
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""
diff --git a/homeassistant/components/bluesound/strings.json b/homeassistant/components/bluesound/strings.json
index b50c01a11bf..1170e0b92e0 100644
--- a/homeassistant/components/bluesound/strings.json
+++ b/homeassistant/components/bluesound/strings.json
@@ -19,7 +19,8 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "no_ipv4_address": "No IPv4 address found."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py
index c46ef22803e..7abc929fde5 100644
--- a/homeassistant/components/bluetooth/__init__.py
+++ b/homeassistant/components/bluetooth/__init__.py
@@ -311,11 +311,24 @@ async def async_update_device(
update the device with the new location so they can
figure out where the adapter is.
"""
+ address = details[ADAPTER_ADDRESS]
+ connections = {(dr.CONNECTION_BLUETOOTH, address)}
device_registry = dr.async_get(hass)
+ # We only have one device for the config entry
+ # so if the address has been corrected, make
+ # sure the device entry reflects the correct
+ # address
+ for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
+ for conn_type, conn_value in device.connections:
+ if conn_type == dr.CONNECTION_BLUETOOTH and conn_value != address:
+ device_registry.async_update_device(
+ device.id, new_connections=connections
+ )
+ break
device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
- name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
- connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
+ name=adapter_human_name(adapter, address),
+ connections=connections,
manufacturer=details[ADAPTER_MANUFACTURER],
model=adapter_model(details),
sw_version=details.get(ADAPTER_SW_VERSION),
@@ -342,9 +355,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
)
)
+ return True
address = entry.unique_id
assert address is not None
- assert source_entry is not None
source_domain = entry.data[CONF_SOURCE_DOMAIN]
if mac_manufacturer := await get_manufacturer_from_mac(address):
manufacturer = f"{mac_manufacturer} ({source_domain})"
diff --git a/homeassistant/components/bluetooth/config_flow.py b/homeassistant/components/bluetooth/config_flow.py
index e76277306f5..328707bd722 100644
--- a/homeassistant/components/bluetooth/config_flow.py
+++ b/homeassistant/components/bluetooth/config_flow.py
@@ -186,16 +186,28 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by an external scanner."""
source = user_input[CONF_SOURCE]
await self.async_set_unique_id(source)
+ source_config_entry_id = user_input[CONF_SOURCE_CONFIG_ENTRY_ID]
data = {
CONF_SOURCE: source,
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
- CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID],
+ CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id,
CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID],
}
self._abort_if_unique_id_configured(updates=data)
- manager = get_manager()
- scanner = manager.async_scanner_by_source(source)
+ for entry in self._async_current_entries(include_ignore=False):
+ # If the mac address needs to be corrected, migrate
+ # the config entry to the new mac address
+ if (
+ entry.data.get(CONF_SOURCE_CONFIG_ENTRY_ID) == source_config_entry_id
+ and entry.unique_id != source
+ ):
+ self.hass.config_entries.async_update_entry(
+ entry, unique_id=source, data={**entry.data, **data}
+ )
+ self.hass.config_entries.async_schedule_reload(entry.entry_id)
+ return self.async_abort(reason="already_configured")
+ scanner = get_manager().async_scanner_by_source(source)
assert scanner is not None
return self.async_create_entry(title=scanner.name, data=data)
diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json
index 5d2b8ab6285..b83bc37e473 100644
--- a/homeassistant/components/bluetooth/manifest.json
+++ b/homeassistant/components/bluetooth/manifest.json
@@ -16,11 +16,11 @@
"quality_scale": "internal",
"requirements": [
"bleak==0.22.3",
- "bleak-retry-connector==3.8.1",
+ "bleak-retry-connector==3.9.0",
"bluetooth-adapters==0.21.4",
- "bluetooth-auto-recovery==1.4.2",
- "bluetooth-data-tools==1.23.4",
- "dbus-fast==2.33.0",
- "habluetooth==3.21.1"
+ "bluetooth-auto-recovery==1.4.5",
+ "bluetooth-data-tools==1.27.0",
+ "dbus-fast==2.43.0",
+ "habluetooth==3.39.0"
]
}
diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py
index 8f66a3582ea..09e953a8676 100644
--- a/homeassistant/components/bluetooth/passive_update_processor.py
+++ b/homeassistant/components/bluetooth/passive_update_processor.py
@@ -374,6 +374,27 @@ class PassiveBluetoothProcessorCoordinator[_DataT](BasePassiveBluetoothCoordinat
self.logger.exception("Unexpected error updating %s data", self.name)
return
+ self._process_update(update, was_available)
+
+ @callback
+ def async_set_updated_data(self, update: _DataT) -> None:
+ """Manually update the processor with new data.
+
+ If the data comes in via a different method, like a
+ notification, this method can be used to update the
+ processor with the new data.
+
+ This is useful for devices that retrieve
+ some of their data via notifications.
+ """
+ was_available = self._available
+ self._available = True
+ self._process_update(update, was_available)
+
+ def _process_update(
+ self, update: _DataT, was_available: bool | None = None
+ ) -> None:
+ """Process the update from the bluetooth device."""
if not self.last_update_success:
self.last_update_success = True
self.logger.info("Coordinator %s recovered", self.name)
diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py
index 5a58c707d6a..01cdbbdc94d 100644
--- a/homeassistant/components/bmw_connected_drive/binary_sensor.py
+++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py
@@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_system import UnitSystem
from . import BMWConfigEntry
@@ -200,7 +200,7 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the BMW binary sensors from config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py
index a7c31d0ef79..f8980201f3f 100644
--- a/homeassistant/components/bmw_connected_drive/button.py
+++ b/homeassistant/components/bmw_connected_drive/button.py
@@ -14,7 +14,7 @@ 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 AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
from .entity import BMWBaseEntity
@@ -69,7 +69,7 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the BMW buttons from config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py
index 74df8693f7a..23273cc8ba9 100644
--- a/homeassistant/components/bmw_connected_drive/device_tracker.py
+++ b/homeassistant/components/bmw_connected_drive/device_tracker.py
@@ -9,7 +9,7 @@ from bimmer_connected.vehicle import MyBMWVehicle
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BMWConfigEntry
from .const import ATTR_DIRECTION
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW tracker from config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py
index 4bec12e796b..9d8965d6ebf 100644
--- a/homeassistant/components/bmw_connected_drive/lock.py
+++ b/homeassistant/components/bmw_connected_drive/lock.py
@@ -12,7 +12,7 @@ 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 AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
@@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW lock from config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py
index c6a328ecc20..8361306ba9d 100644
--- a/homeassistant/components/bmw_connected_drive/number.py
+++ b/homeassistant/components/bmw_connected_drive/number.py
@@ -16,7 +16,7 @@ from homeassistant.components.number import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
@@ -58,7 +58,7 @@ NUMBER_TYPES: list[BMWNumberEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW number from config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py
index 385b45fd9fa..f144d3a71df 100644
--- a/homeassistant/components/bmw_connected_drive/select.py
+++ b/homeassistant/components/bmw_connected_drive/select.py
@@ -13,7 +13,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.const import UnitOfElectricCurrent
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
@@ -65,7 +65,7 @@ SELECT_TYPES: tuple[BMWSelectEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW lock from config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py
index b7be367d57d..114412ef9f2 100644
--- a/homeassistant/components/bmw_connected_drive/sensor.py
+++ b/homeassistant/components/bmw_connected_drive/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import BMWConfigEntry
@@ -190,7 +190,7 @@ SENSOR_TYPES: list[BMWSensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW sensors from config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/bmw_connected_drive/strings.json b/homeassistant/components/bmw_connected_drive/strings.json
index edb0d5cfb12..bd9814476f5 100644
--- a/homeassistant/components/bmw_connected_drive/strings.json
+++ b/homeassistant/components/bmw_connected_drive/strings.json
@@ -6,7 +6,7 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
- "region": "ConnectedDrive Region"
+ "region": "ConnectedDrive region"
},
"data_description": {
"username": "The email address of your MyBMW/MINI Connected account.",
@@ -113,10 +113,10 @@
},
"select": {
"ac_limit": {
- "name": "AC Charging Limit"
+ "name": "AC charging limit"
},
"charging_mode": {
- "name": "Charging Mode",
+ "name": "Charging mode",
"state": {
"immediate_charging": "Immediate charging",
"delayed_charging": "Delayed charging",
@@ -138,7 +138,7 @@
"name": "Charging status",
"state": {
"default": "Default",
- "charging": "Charging",
+ "charging": "[%key:common::state::charging%]",
"error": "Error",
"complete": "Complete",
"fully_charged": "Fully charged",
@@ -181,7 +181,7 @@
"cooling": "Cooling",
"heating": "Heating",
"inactive": "Inactive",
- "standby": "Standby",
+ "standby": "[%key:common::state::standby%]",
"ventilation": "Ventilation"
}
},
diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py
index 600ad41165a..f46969f3e9b 100644
--- a/homeassistant/components/bmw_connected_drive/switch.py
+++ b/homeassistant/components/bmw_connected_drive/switch.py
@@ -12,7 +12,7 @@ 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 AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as BMW_DOMAIN, BMWConfigEntry
from .coordinator import BMWDataUpdateCoordinator
@@ -66,7 +66,7 @@ NUMBER_TYPES: list[BMWSwitchEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BMWConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the MyBMW switch from config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py
index 42915c7dc0b..9cea0251b41 100644
--- a/homeassistant/components/bond/button.py
+++ b/homeassistant/components/bond/button.py
@@ -8,7 +8,7 @@ from bond_async import Action
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BondConfigEntry
from .entity import BondEntity
@@ -91,6 +91,13 @@ BUTTONS: tuple[BondButtonEntityDescription, ...] = (
mutually_exclusive=Action.SET_BRIGHTNESS,
argument=None,
),
+ BondButtonEntityDescription(
+ key=Action.TOGGLE_LIGHT_TEMP,
+ name="Toggle Light Temperature",
+ translation_key="toggle_light_temp",
+ mutually_exclusive=None, # No mutually exclusive action
+ argument=None,
+ ),
BondButtonEntityDescription(
key=Action.START_UP_LIGHT_DIMMER,
name="Start Up Light Dimmer",
@@ -257,7 +264,7 @@ BUTTONS: tuple[BondButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: BondConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Bond button devices."""
data = entry.runtime_data
diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py
index 38abd63186a..ffa0098840c 100644
--- a/homeassistant/components/bond/config_flow.py
+++ b/homeassistant/components/bond/config_flow.py
@@ -16,6 +16,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -91,11 +92,22 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovered[CONF_ACCESS_TOKEN] = token
try:
- _, hub_name = await _validate_input(self.hass, self._discovered)
+ bond_id, hub_name = await _validate_input(self.hass, self._discovered)
except InputValidationError:
return
+ await self.async_set_unique_id(bond_id)
+ self._abort_if_unique_id_configured(updates={CONF_HOST: host})
self._discovered[CONF_NAME] = hub_name
+ async def async_step_dhcp(
+ self, discovery_info: DhcpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by dhcp discovery."""
+ host = discovery_info.ip
+ bond_id = discovery_info.hostname.partition("-")[2].upper()
+ await self.async_set_unique_id(bond_id)
+ return await self.async_step_any_discovery(bond_id, host)
+
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
@@ -104,11 +116,17 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
host: str = discovery_info.host
bond_id = name.partition(".")[0]
await self.async_set_unique_id(bond_id)
+ return await self.async_step_any_discovery(bond_id, host)
+
+ async def async_step_any_discovery(
+ self, bond_id: str, host: str
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by discovery."""
for entry in self._async_current_entries():
if entry.unique_id != bond_id:
continue
updates = {CONF_HOST: host}
- if entry.state == ConfigEntryState.SETUP_ERROR and (
+ if entry.state is ConfigEntryState.SETUP_ERROR and (
token := await async_get_token(self.hass, host)
):
updates[CONF_ACCESS_TOKEN] = token
@@ -153,10 +171,14 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: self._discovered[CONF_HOST],
}
try:
- _, hub_name = await _validate_input(self.hass, data)
+ bond_id, hub_name = await _validate_input(self.hass, data)
except InputValidationError as error:
errors["base"] = error.base
else:
+ await self.async_set_unique_id(bond_id)
+ self._abort_if_unique_id_configured(
+ updates={CONF_HOST: self._discovered[CONF_HOST]}
+ )
return self.async_create_entry(
title=hub_name,
data=data,
@@ -185,8 +207,10 @@ class BondConfigFlow(ConfigFlow, domain=DOMAIN):
except InputValidationError as error:
errors["base"] = error.base
else:
- await self.async_set_unique_id(bond_id)
- self._abort_if_unique_id_configured()
+ await self.async_set_unique_id(bond_id, raise_on_progress=False)
+ self._abort_if_unique_id_configured(
+ updates={CONF_HOST: user_input[CONF_HOST]}
+ )
return self.async_create_entry(title=hub_name, data=user_input)
return self.async_show_form(
diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py
index 66344a1913d..d2a78819fae 100644
--- a/homeassistant/components/bond/cover.py
+++ b/homeassistant/components/bond/cover.py
@@ -13,7 +13,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BondConfigEntry
from .entity import BondEntity
@@ -34,7 +34,7 @@ def _hass_to_bond_position(hass_position: int) -> int:
async def async_setup_entry(
hass: HomeAssistant,
entry: BondConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Bond cover devices."""
data = entry.runtime_data
diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py
index 76a0daa46f9..c228c7355dd 100644
--- a/homeassistant/components/bond/fan.py
+++ b/homeassistant/components/bond/fan.py
@@ -19,7 +19,7 @@ from homeassistant.components.fan import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -40,7 +40,7 @@ PRESET_MODE_BREEZE = "Breeze"
async def async_setup_entry(
hass: HomeAssistant,
entry: BondConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Bond fan devices."""
data = entry.runtime_data
diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py
index c3cf23e4fad..9c51165ebdb 100644
--- a/homeassistant/components/bond/light.py
+++ b/homeassistant/components/bond/light.py
@@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BondConfigEntry
from .const import (
@@ -42,7 +42,7 @@ ENTITY_SERVICES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: BondConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Bond light devices."""
data = entry.runtime_data
diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json
index 1d4c110f4fd..704b9934970 100644
--- a/homeassistant/components/bond/manifest.json
+++ b/homeassistant/components/bond/manifest.json
@@ -3,6 +3,16 @@
"name": "Bond",
"codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"],
"config_flow": true,
+ "dhcp": [
+ {
+ "hostname": "bond-*",
+ "macaddress": "3C6A2C1*"
+ },
+ {
+ "hostname": "bond-*",
+ "macaddress": "F44E38*"
+ }
+ ],
"documentation": "https://www.home-assistant.io/integrations/bond",
"iot_class": "local_push",
"loggers": ["bond_async"],
diff --git a/homeassistant/components/bond/strings.json b/homeassistant/components/bond/strings.json
index 8986905c6ee..d65966d7701 100644
--- a/homeassistant/components/bond/strings.json
+++ b/homeassistant/components/bond/strings.json
@@ -31,7 +31,7 @@
"services": {
"set_fan_speed_tracked_state": {
"name": "Set fan speed tracked state",
- "description": "Sets the tracked fan speed for a bond fan.",
+ "description": "Sets the tracked fan speed for a Bond fan.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -45,7 +45,7 @@
},
"set_switch_power_tracked_state": {
"name": "Set switch power tracked state",
- "description": "Sets the tracked power state of a bond switch.",
+ "description": "Sets the tracked power state of a Bond switch.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -59,7 +59,7 @@
},
"set_light_power_tracked_state": {
"name": "Set light power tracked state",
- "description": "Sets the tracked power state of a bond light.",
+ "description": "Sets the tracked power state of a Bond light.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -73,7 +73,7 @@
},
"set_light_brightness_tracked_state": {
"name": "Set light brightness tracked state",
- "description": "Sets the tracked brightness state of a bond light.",
+ "description": "Sets the tracked brightness state of a Bond light.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -87,15 +87,15 @@
},
"start_increasing_brightness": {
"name": "Start increasing brightness",
- "description": "Start increasing the brightness of the light. (deprecated)."
+ "description": "Starts increasing the brightness of the light (deprecated)."
},
"start_decreasing_brightness": {
"name": "Start decreasing brightness",
- "description": "Start decreasing the brightness of the light. (deprecated)."
+ "description": "Starts decreasing the brightness of the light (deprecated)."
},
"stop": {
"name": "[%key:common::action::stop%]",
- "description": "Stop any in-progress action and empty the queue. (deprecated)."
+ "description": "Stops any in-progress action and empty the queue (deprecated)."
}
}
}
diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py
index ace6d307e6d..fa2ccd2ca93 100644
--- a/homeassistant/components/bond/switch.py
+++ b/homeassistant/components/bond/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BondConfigEntry
from .const import ATTR_POWER_STATE, SERVICE_SET_POWER_TRACKED_STATE
@@ -22,7 +22,7 @@ from .entity import BondEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: BondConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Bond generic devices."""
data = entry.runtime_data
diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py
new file mode 100644
index 00000000000..602c801701d
--- /dev/null
+++ b/homeassistant/components/bosch_alarm/__init__.py
@@ -0,0 +1,67 @@
+"""The Bosch Alarm integration."""
+
+from __future__ import annotations
+
+from ssl import SSLError
+
+from bosch_alarm_mode2 import Panel
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
+
+from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
+
+PLATFORMS: list[Platform] = [Platform.ALARM_CONTROL_PANEL, Platform.SENSOR]
+
+type BoschAlarmConfigEntry = ConfigEntry[Panel]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool:
+ """Set up Bosch Alarm from a config entry."""
+
+ panel = Panel(
+ host=entry.data[CONF_HOST],
+ port=entry.data[CONF_PORT],
+ automation_code=entry.data.get(CONF_PASSWORD),
+ installer_or_user_code=entry.data.get(
+ CONF_INSTALLER_CODE, entry.data.get(CONF_USER_CODE)
+ ),
+ )
+ try:
+ await panel.connect()
+ except (PermissionError, ValueError) as err:
+ await panel.disconnect()
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN, translation_key="authentication_failed"
+ ) from err
+ except (TimeoutError, OSError, ConnectionRefusedError, SSLError) as err:
+ await panel.disconnect()
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ ) from err
+
+ entry.runtime_data = panel
+
+ device_registry = dr.async_get(hass)
+
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, entry.unique_id or entry.entry_id)},
+ name=f"Bosch {panel.model}",
+ manufacturer="Bosch Security Systems",
+ model=panel.model,
+ sw_version=panel.firmware_version,
+ )
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: BoschAlarmConfigEntry) -> bool:
+ """Unload a config entry."""
+ if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
+ await entry.runtime_data.disconnect()
+ return unload_ok
diff --git a/homeassistant/components/bosch_alarm/alarm_control_panel.py b/homeassistant/components/bosch_alarm/alarm_control_panel.py
new file mode 100644
index 00000000000..2854298f815
--- /dev/null
+++ b/homeassistant/components/bosch_alarm/alarm_control_panel.py
@@ -0,0 +1,80 @@
+"""Support for Bosch Alarm Panel."""
+
+from __future__ import annotations
+
+from bosch_alarm_mode2 import Panel
+
+from homeassistant.components.alarm_control_panel import (
+ AlarmControlPanelEntity,
+ AlarmControlPanelEntityFeature,
+ AlarmControlPanelState,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import BoschAlarmConfigEntry
+from .entity import BoschAlarmAreaEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: BoschAlarmConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up control panels for each area."""
+ panel = config_entry.runtime_data
+
+ async_add_entities(
+ AreaAlarmControlPanel(
+ panel,
+ area_id,
+ config_entry.unique_id or config_entry.entry_id,
+ )
+ for area_id in panel.areas
+ )
+
+
+class AreaAlarmControlPanel(BoschAlarmAreaEntity, AlarmControlPanelEntity):
+ """An alarm control panel entity for a bosch alarm panel."""
+
+ _attr_has_entity_name = True
+ _attr_supported_features = (
+ AlarmControlPanelEntityFeature.ARM_HOME
+ | AlarmControlPanelEntityFeature.ARM_AWAY
+ )
+ _attr_code_arm_required = False
+ _attr_name = None
+
+ def __init__(self, panel: Panel, area_id: int, unique_id: str) -> None:
+ """Initialise a Bosch Alarm control panel entity."""
+ super().__init__(panel, area_id, unique_id, False, False, True)
+ self._attr_unique_id = self._area_unique_id
+
+ @property
+ def alarm_state(self) -> AlarmControlPanelState | None:
+ """Return the state of the alarm."""
+ if self._area.is_triggered():
+ return AlarmControlPanelState.TRIGGERED
+ if self._area.is_disarmed():
+ return AlarmControlPanelState.DISARMED
+ if self._area.is_arming():
+ return AlarmControlPanelState.ARMING
+ if self._area.is_pending():
+ return AlarmControlPanelState.PENDING
+ if self._area.is_part_armed():
+ return AlarmControlPanelState.ARMED_HOME
+ if self._area.is_all_armed():
+ return AlarmControlPanelState.ARMED_AWAY
+ return None
+
+ async def async_alarm_disarm(self, code: str | None = None) -> None:
+ """Disarm this panel."""
+ await self.panel.area_disarm(self._area_id)
+
+ async def async_alarm_arm_home(self, code: str | None = None) -> None:
+ """Send arm home command."""
+ await self.panel.area_arm_part(self._area_id)
+
+ async def async_alarm_arm_away(self, code: str | None = None) -> None:
+ """Send arm away command."""
+ await self.panel.area_arm_all(self._area_id)
diff --git a/homeassistant/components/bosch_alarm/config_flow.py b/homeassistant/components/bosch_alarm/config_flow.py
new file mode 100644
index 00000000000..9e664e49ca9
--- /dev/null
+++ b/homeassistant/components/bosch_alarm/config_flow.py
@@ -0,0 +1,248 @@
+"""Config flow for Bosch Alarm integration."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Mapping
+import logging
+import ssl
+from typing import Any
+
+from bosch_alarm_mode2 import Panel
+import voluptuous as vol
+
+from homeassistant.config_entries import (
+ SOURCE_RECONFIGURE,
+ SOURCE_USER,
+ ConfigFlow,
+ ConfigFlowResult,
+)
+from homeassistant.const import (
+ CONF_CODE,
+ CONF_HOST,
+ CONF_MODEL,
+ CONF_PASSWORD,
+ CONF_PORT,
+)
+import homeassistant.helpers.config_validation as cv
+
+from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_PORT, default=7700): cv.positive_int,
+ }
+)
+
+STEP_AUTH_DATA_SCHEMA_SOLUTION = vol.Schema(
+ {
+ vol.Required(CONF_USER_CODE): str,
+ }
+)
+
+STEP_AUTH_DATA_SCHEMA_AMAX = vol.Schema(
+ {
+ vol.Required(CONF_INSTALLER_CODE): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
+STEP_AUTH_DATA_SCHEMA_BG = vol.Schema(
+ {
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
+STEP_INIT_DATA_SCHEMA = vol.Schema({vol.Optional(CONF_CODE): str})
+
+
+async def try_connect(
+ data: dict[str, Any], load_selector: int = 0
+) -> tuple[str, int | None]:
+ """Validate the user input allows us to connect.
+
+ Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
+ """
+ panel = Panel(
+ host=data[CONF_HOST],
+ port=data[CONF_PORT],
+ automation_code=data.get(CONF_PASSWORD),
+ installer_or_user_code=data.get(CONF_INSTALLER_CODE, data.get(CONF_USER_CODE)),
+ )
+
+ try:
+ await panel.connect(load_selector)
+ finally:
+ await panel.disconnect()
+
+ return (panel.model, panel.serial_number)
+
+
+class BoschAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Bosch Alarm."""
+
+ def __init__(self) -> None:
+ """Init config flow."""
+
+ self._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] = {}
+
+ if user_input is not None:
+ try:
+ # Use load_selector = 0 to fetch the panel model without authentication.
+ (model, serial) = await try_connect(user_input, 0)
+ except (
+ OSError,
+ ConnectionRefusedError,
+ ssl.SSLError,
+ asyncio.exceptions.TimeoutError,
+ ) as e:
+ _LOGGER.error("Connection Error: %s", e)
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ self._data = user_input
+ self._data[CONF_MODEL] = model
+
+ if self.source == SOURCE_RECONFIGURE:
+ if (
+ self._get_reconfigure_entry().data[CONF_MODEL]
+ != self._data[CONF_MODEL]
+ ):
+ return self.async_abort(reason="device_mismatch")
+ return await self.async_step_auth()
+ return self.async_show_form(
+ step_id="user",
+ data_schema=self.add_suggested_values_to_schema(
+ STEP_USER_DATA_SCHEMA, user_input
+ ),
+ errors=errors,
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the reconfigure step."""
+ return await self.async_step_user()
+
+ async def async_step_auth(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the auth step."""
+ errors: dict[str, str] = {}
+
+ # Each model variant requires a different authentication flow
+ if "Solution" in self._data[CONF_MODEL]:
+ schema = STEP_AUTH_DATA_SCHEMA_SOLUTION
+ elif "AMAX" in self._data[CONF_MODEL]:
+ schema = STEP_AUTH_DATA_SCHEMA_AMAX
+ else:
+ schema = STEP_AUTH_DATA_SCHEMA_BG
+
+ if user_input is not None:
+ self._data.update(user_input)
+ try:
+ (model, serial_number) = await try_connect(
+ self._data, Panel.LOAD_EXTENDED_INFO
+ )
+ except (PermissionError, ValueError) as e:
+ errors["base"] = "invalid_auth"
+ _LOGGER.error("Authentication Error: %s", e)
+ except (
+ OSError,
+ ConnectionRefusedError,
+ ssl.SSLError,
+ TimeoutError,
+ ) as e:
+ _LOGGER.error("Connection Error: %s", e)
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ if serial_number:
+ await self.async_set_unique_id(str(serial_number))
+ if self.source == SOURCE_USER:
+ if serial_number:
+ self._abort_if_unique_id_configured()
+ else:
+ self._async_abort_entries_match(
+ {CONF_HOST: self._data[CONF_HOST]}
+ )
+ return self.async_create_entry(
+ title=f"Bosch {model}", data=self._data
+ )
+ if serial_number:
+ self._abort_if_unique_id_mismatch(reason="device_mismatch")
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(),
+ data=self._data,
+ )
+
+ return self.async_show_form(
+ step_id="auth",
+ data_schema=self.add_suggested_values_to_schema(schema, user_input),
+ errors=errors,
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth upon an authentication error."""
+ self._data = dict(entry_data)
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the reauth step."""
+ errors: dict[str, str] = {}
+
+ # Each model variant requires a different authentication flow
+ if "Solution" in self._data[CONF_MODEL]:
+ schema = STEP_AUTH_DATA_SCHEMA_SOLUTION
+ elif "AMAX" in self._data[CONF_MODEL]:
+ schema = STEP_AUTH_DATA_SCHEMA_AMAX
+ else:
+ schema = STEP_AUTH_DATA_SCHEMA_BG
+
+ if user_input is not None:
+ reauth_entry = self._get_reauth_entry()
+ self._data.update(user_input)
+ try:
+ (_, _) = await try_connect(self._data, Panel.LOAD_EXTENDED_INFO)
+ except (PermissionError, ValueError) as e:
+ errors["base"] = "invalid_auth"
+ _LOGGER.error("Authentication Error: %s", e)
+ except (
+ OSError,
+ ConnectionRefusedError,
+ ssl.SSLError,
+ TimeoutError,
+ ) as e:
+ _LOGGER.error("Connection Error: %s", e)
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_update_reload_and_abort(
+ reauth_entry,
+ data_updates=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=self.add_suggested_values_to_schema(schema, user_input),
+ errors=errors,
+ )
diff --git a/homeassistant/components/bosch_alarm/const.py b/homeassistant/components/bosch_alarm/const.py
new file mode 100644
index 00000000000..7205831391c
--- /dev/null
+++ b/homeassistant/components/bosch_alarm/const.py
@@ -0,0 +1,6 @@
+"""Constants for the Bosch Alarm integration."""
+
+DOMAIN = "bosch_alarm"
+HISTORY_ATTR = "history"
+CONF_INSTALLER_CODE = "installer_code"
+CONF_USER_CODE = "user_code"
diff --git a/homeassistant/components/bosch_alarm/diagnostics.py b/homeassistant/components/bosch_alarm/diagnostics.py
new file mode 100644
index 00000000000..2e93052ea95
--- /dev/null
+++ b/homeassistant/components/bosch_alarm/diagnostics.py
@@ -0,0 +1,73 @@
+"""Diagnostics for bosch alarm."""
+
+from typing import Any
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.const import CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+
+from . import BoschAlarmConfigEntry
+from .const import CONF_INSTALLER_CODE, CONF_USER_CODE
+
+TO_REDACT = [CONF_INSTALLER_CODE, CONF_USER_CODE, CONF_PASSWORD]
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: BoschAlarmConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ return {
+ "entry_data": async_redact_data(entry.data, TO_REDACT),
+ "data": {
+ "model": entry.runtime_data.model,
+ "serial_number": entry.runtime_data.serial_number,
+ "protocol_version": entry.runtime_data.protocol_version,
+ "firmware_version": entry.runtime_data.firmware_version,
+ "areas": [
+ {
+ "id": area_id,
+ "name": area.name,
+ "all_ready": area.all_ready,
+ "part_ready": area.part_ready,
+ "faults": area.faults,
+ "alarms": area.alarms,
+ "disarmed": area.is_disarmed(),
+ "arming": area.is_arming(),
+ "pending": area.is_pending(),
+ "part_armed": area.is_part_armed(),
+ "all_armed": area.is_all_armed(),
+ "armed": area.is_armed(),
+ "triggered": area.is_triggered(),
+ }
+ for area_id, area in entry.runtime_data.areas.items()
+ ],
+ "points": [
+ {
+ "id": point_id,
+ "name": point.name,
+ "open": point.is_open(),
+ "normal": point.is_normal(),
+ }
+ for point_id, point in entry.runtime_data.points.items()
+ ],
+ "doors": [
+ {
+ "id": door_id,
+ "name": door.name,
+ "open": door.is_open(),
+ "locked": door.is_locked(),
+ }
+ for door_id, door in entry.runtime_data.doors.items()
+ ],
+ "outputs": [
+ {
+ "id": output_id,
+ "name": output.name,
+ "active": output.is_active(),
+ }
+ for output_id, output in entry.runtime_data.outputs.items()
+ ],
+ "history_events": entry.runtime_data.events,
+ },
+ }
diff --git a/homeassistant/components/bosch_alarm/entity.py b/homeassistant/components/bosch_alarm/entity.py
new file mode 100644
index 00000000000..f74634125c4
--- /dev/null
+++ b/homeassistant/components/bosch_alarm/entity.py
@@ -0,0 +1,88 @@
+"""Support for Bosch Alarm Panel History as a sensor."""
+
+from __future__ import annotations
+
+from bosch_alarm_mode2 import Panel
+
+from homeassistant.components.sensor import Entity
+from homeassistant.helpers.device_registry import DeviceInfo
+
+from .const import DOMAIN
+
+PARALLEL_UPDATES = 0
+
+
+class BoschAlarmEntity(Entity):
+ """A base entity for a bosch alarm panel."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, panel: Panel, unique_id: str) -> None:
+ """Set up a entity for a bosch alarm panel."""
+ self.panel = panel
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, unique_id)},
+ name=f"Bosch {panel.model}",
+ manufacturer="Bosch Security Systems",
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self.panel.connection_status()
+
+ async def async_added_to_hass(self) -> None:
+ """Observe state changes."""
+ self.panel.connection_status_observer.attach(self.schedule_update_ha_state)
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Stop observing state changes."""
+ self.panel.connection_status_observer.detach(self.schedule_update_ha_state)
+
+
+class BoschAlarmAreaEntity(BoschAlarmEntity):
+ """A base entity for area related entities within a bosch alarm panel."""
+
+ def __init__(
+ self,
+ panel: Panel,
+ area_id: int,
+ unique_id: str,
+ observe_alarms: bool,
+ observe_ready: bool,
+ observe_status: bool,
+ ) -> None:
+ """Set up a area related entity for a bosch alarm panel."""
+ super().__init__(panel, unique_id)
+ self._area_id = area_id
+ self._area_unique_id = f"{unique_id}_area_{area_id}"
+ self._observe_alarms = observe_alarms
+ self._observe_ready = observe_ready
+ self._observe_status = observe_status
+ self._area = panel.areas[area_id]
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, self._area_unique_id)},
+ name=self._area.name,
+ manufacturer="Bosch Security Systems",
+ via_device=(DOMAIN, unique_id),
+ )
+
+ async def async_added_to_hass(self) -> None:
+ """Observe state changes."""
+ await super().async_added_to_hass()
+ if self._observe_alarms:
+ self._area.alarm_observer.attach(self.schedule_update_ha_state)
+ if self._observe_ready:
+ self._area.ready_observer.attach(self.schedule_update_ha_state)
+ if self._observe_status:
+ self._area.status_observer.attach(self.schedule_update_ha_state)
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Stop observing state changes."""
+ await super().async_added_to_hass()
+ if self._observe_alarms:
+ self._area.alarm_observer.detach(self.schedule_update_ha_state)
+ if self._observe_ready:
+ self._area.ready_observer.detach(self.schedule_update_ha_state)
+ if self._observe_status:
+ self._area.status_observer.detach(self.schedule_update_ha_state)
diff --git a/homeassistant/components/bosch_alarm/icons.json b/homeassistant/components/bosch_alarm/icons.json
new file mode 100644
index 00000000000..1e207310713
--- /dev/null
+++ b/homeassistant/components/bosch_alarm/icons.json
@@ -0,0 +1,9 @@
+{
+ "entity": {
+ "sensor": {
+ "faulting_points": {
+ "default": "mdi:alert-circle-outline"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/bosch_alarm/manifest.json b/homeassistant/components/bosch_alarm/manifest.json
new file mode 100644
index 00000000000..eefcc400ee7
--- /dev/null
+++ b/homeassistant/components/bosch_alarm/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "bosch_alarm",
+ "name": "Bosch Alarm",
+ "codeowners": ["@mag1024", "@sanjay900"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/bosch_alarm",
+ "integration_type": "device",
+ "iot_class": "local_push",
+ "quality_scale": "bronze",
+ "requirements": ["bosch-alarm-mode2==0.4.6"]
+}
diff --git a/homeassistant/components/bosch_alarm/quality_scale.yaml b/homeassistant/components/bosch_alarm/quality_scale.yaml
new file mode 100644
index 00000000000..3a64667a407
--- /dev/null
+++ b/homeassistant/components/bosch_alarm/quality_scale.yaml
@@ -0,0 +1,84 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions defined
+ appropriate-polling:
+ status: exempt
+ comment: |
+ No polling
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ 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: |
+ No custom actions are defined.
+ config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ 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:
+ status: exempt
+ comment: |
+ Device type integration
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ No repairs
+ stale-devices:
+ status: exempt
+ comment: |
+ Device type integration
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: |
+ Integration does not make any HTTP requests.
+ strict-typing: done
diff --git a/homeassistant/components/bosch_alarm/sensor.py b/homeassistant/components/bosch_alarm/sensor.py
new file mode 100644
index 00000000000..3d61c72a883
--- /dev/null
+++ b/homeassistant/components/bosch_alarm/sensor.py
@@ -0,0 +1,86 @@
+"""Support for Bosch Alarm Panel History as a sensor."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from bosch_alarm_mode2 import Panel
+from bosch_alarm_mode2.panel import Area
+
+from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import BoschAlarmConfigEntry
+from .entity import BoschAlarmAreaEntity
+
+
+@dataclass(kw_only=True, frozen=True)
+class BoschAlarmSensorEntityDescription(SensorEntityDescription):
+ """Describes Bosch Alarm sensor entity."""
+
+ value_fn: Callable[[Area], int]
+ observe_alarms: bool = False
+ observe_ready: bool = False
+ observe_status: bool = False
+
+
+SENSOR_TYPES: list[BoschAlarmSensorEntityDescription] = [
+ BoschAlarmSensorEntityDescription(
+ key="faulting_points",
+ translation_key="faulting_points",
+ value_fn=lambda area: area.faults,
+ observe_ready=True,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: BoschAlarmConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up bosch alarm sensors."""
+
+ panel = config_entry.runtime_data
+ unique_id = config_entry.unique_id or config_entry.entry_id
+
+ async_add_entities(
+ BoschAreaSensor(panel, area_id, unique_id, template)
+ for area_id in panel.areas
+ for template in SENSOR_TYPES
+ )
+
+
+PARALLEL_UPDATES = 0
+
+
+class BoschAreaSensor(BoschAlarmAreaEntity, SensorEntity):
+ """An area sensor entity for a bosch alarm panel."""
+
+ entity_description: BoschAlarmSensorEntityDescription
+
+ def __init__(
+ self,
+ panel: Panel,
+ area_id: int,
+ unique_id: str,
+ entity_description: BoschAlarmSensorEntityDescription,
+ ) -> None:
+ """Set up an area sensor entity for a bosch alarm panel."""
+ super().__init__(
+ panel,
+ area_id,
+ unique_id,
+ entity_description.observe_alarms,
+ entity_description.observe_ready,
+ entity_description.observe_status,
+ )
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{self._area_unique_id}_{entity_description.key}"
+
+ @property
+ def native_value(self) -> int:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self._area)
diff --git a/homeassistant/components/bosch_alarm/strings.json b/homeassistant/components/bosch_alarm/strings.json
new file mode 100644
index 00000000000..6b916dad4fa
--- /dev/null
+++ b/homeassistant/components/bosch_alarm/strings.json
@@ -0,0 +1,67 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]"
+ },
+ "data_description": {
+ "host": "The hostname or IP address of your Bosch alarm panel",
+ "port": "The port used to connect to your Bosch alarm panel. This is usually 7700"
+ }
+ },
+ "auth": {
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]",
+ "installer_code": "Installer code",
+ "user_code": "User code"
+ },
+ "data_description": {
+ "password": "The Mode 2 automation code from your panel",
+ "installer_code": "The installer code from your panel",
+ "user_code": "The user code from your panel"
+ }
+ },
+ "reauth_confirm": {
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]",
+ "installer_code": "[%key:component::bosch_alarm::config::step::auth::data::installer_code%]",
+ "user_code": "[%key:component::bosch_alarm::config::step::auth::data::user_code%]"
+ },
+ "data_description": {
+ "password": "[%key:component::bosch_alarm::config::step::auth::data_description::password%]",
+ "installer_code": "[%key:component::bosch_alarm::config::step::auth::data_description::installer_code%]",
+ "user_code": "[%key:component::bosch_alarm::config::step::auth::data_description::user_code%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "device_mismatch": "Please ensure you reconfigure against the same device."
+ }
+ },
+ "exceptions": {
+ "cannot_connect": {
+ "message": "Could not connect to panel."
+ },
+ "authentication_failed": {
+ "message": "Incorrect credentials for panel."
+ }
+ },
+ "entity": {
+ "sensor": {
+ "faulting_points": {
+ "name": "Faulting points",
+ "unit_of_measurement": "points"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/bosch_shc/binary_sensor.py b/homeassistant/components/bosch_shc/binary_sensor.py
index dd0f31ea6f9..30d823fd608 100644
--- a/homeassistant/components/bosch_shc/binary_sensor.py
+++ b/homeassistant/components/bosch_shc/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschConfigEntry
from .entity import SHCEntity
@@ -19,7 +19,7 @@ from .entity import SHCEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SHC binary sensor platform."""
session = config_entry.runtime_data
diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py
index 55d6bfc35de..766dcf37ce9 100644
--- a/homeassistant/components/bosch_shc/cover.py
+++ b/homeassistant/components/bosch_shc/cover.py
@@ -11,7 +11,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BoschConfigEntry
from .entity import SHCEntity
@@ -20,7 +20,7 @@ from .entity import SHCEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SHC cover platform."""
session = config_entry.runtime_data
diff --git a/homeassistant/components/bosch_shc/sensor.py b/homeassistant/components/bosch_shc/sensor.py
index 6408e21654e..885908804c0 100644
--- a/homeassistant/components/bosch_shc/sensor.py
+++ b/homeassistant/components/bosch_shc/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import BoschConfigEntry
@@ -126,7 +126,7 @@ SENSOR_DESCRIPTIONS: dict[str, SHCSensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SHC sensor platform."""
session = config_entry.runtime_data
diff --git a/homeassistant/components/bosch_shc/switch.py b/homeassistant/components/bosch_shc/switch.py
index 76b1da3e534..bf1d5d39ee5 100644
--- a/homeassistant/components/bosch_shc/switch.py
+++ b/homeassistant/components/bosch_shc/switch.py
@@ -21,7 +21,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import BoschConfigEntry
@@ -79,7 +79,7 @@ SWITCH_TYPES: dict[str, SHCSwitchEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BoschConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SHC switch platform."""
session = config_entry.runtime_data
diff --git a/homeassistant/components/braviatv/button.py b/homeassistant/components/braviatv/button.py
index 626e5a225b7..20250949bcb 100644
--- a/homeassistant/components/braviatv/button.py
+++ b/homeassistant/components/braviatv/button.py
@@ -12,7 +12,7 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import BraviaTVConfigEntry, BraviaTVCoordinator
from .entity import BraviaTVEntity
@@ -44,7 +44,7 @@ BUTTONS: tuple[BraviaTVButtonDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BraviaTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Bravia TV Button entities."""
diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py
index ca48c6ee639..fe9c386b060 100644
--- a/homeassistant/components/braviatv/media_player.py
+++ b/homeassistant/components/braviatv/media_player.py
@@ -16,7 +16,7 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SourceType
from .coordinator import BraviaTVConfigEntry
@@ -26,7 +26,7 @@ from .entity import BraviaTVEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BraviaTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Bravia TV Media Player from a config_entry."""
diff --git a/homeassistant/components/braviatv/remote.py b/homeassistant/components/braviatv/remote.py
index 9f4a573827b..0611e367445 100644
--- a/homeassistant/components/braviatv/remote.py
+++ b/homeassistant/components/braviatv/remote.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import BraviaTVConfigEntry
from .entity import BraviaTVEntity
@@ -16,7 +16,7 @@ from .entity import BraviaTVEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BraviaTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Bravia TV Remote from a config entry."""
diff --git a/homeassistant/components/bring/diagnostics.py b/homeassistant/components/bring/diagnostics.py
index 6c2f779ef05..e5cafd30ab5 100644
--- a/homeassistant/components/bring/diagnostics.py
+++ b/homeassistant/components/bring/diagnostics.py
@@ -4,10 +4,14 @@ from __future__ import annotations
from typing import Any
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.const import CONF_EMAIL, CONF_NAME
from homeassistant.core import HomeAssistant
from .coordinator import BringConfigEntry
+TO_REDACT = {CONF_NAME, CONF_EMAIL}
+
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: BringConfigEntry
@@ -15,7 +19,10 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
return {
- "data": {k: v.to_dict() for k, v in config_entry.runtime_data.data.items()},
+ "data": {
+ k: async_redact_data(v.to_dict(), TO_REDACT)
+ for k, v in config_entry.runtime_data.data.items()
+ },
"lists": [lst.to_dict() for lst in config_entry.runtime_data.lists],
"user_settings": config_entry.runtime_data.user_settings.to_dict(),
}
diff --git a/homeassistant/components/bring/event.py b/homeassistant/components/bring/event.py
index 699dba9015a..403856405ce 100644
--- a/homeassistant/components/bring/event.py
+++ b/homeassistant/components/bring/event.py
@@ -9,7 +9,7 @@ from bring_api import ActivityType, BringList
from homeassistant.components.event import EventEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BringConfigEntry
from .coordinator import BringDataUpdateCoordinator
@@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BringConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the event platform."""
coordinator = config_entry.runtime_data
@@ -77,9 +77,12 @@ class BringEventEntity(BringBaseEntity, EventEntity):
attributes = asdict(activity.content)
attributes["last_activity_by"] = next(
- x.name
- for x in bring_list.users.users
- if x.publicUuid == activity.content.publicUserUuid
+ (
+ x.name
+ for x in bring_list.users.users
+ if x.publicUuid == activity.content.publicUserUuid
+ ),
+ None,
)
self._trigger_event(
diff --git a/homeassistant/components/bring/manifest.json b/homeassistant/components/bring/manifest.json
index b846cb1c5ca..b2d42835cce 100644
--- a/homeassistant/components/bring/manifest.json
+++ b/homeassistant/components/bring/manifest.json
@@ -7,5 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["bring_api"],
- "requirements": ["bring-api==1.0.2"]
+ "quality_scale": "platinum",
+ "requirements": ["bring-api==1.1.0"]
}
diff --git a/homeassistant/components/bring/quality_scale.yaml b/homeassistant/components/bring/quality_scale.yaml
index 58e67ab0e11..2d7d67be12e 100644
--- a/homeassistant/components/bring/quality_scale.yaml
+++ b/homeassistant/components/bring/quality_scale.yaml
@@ -10,9 +10,9 @@ rules:
config-flow: done
dependency-transparency: done
docs-actions: done
- docs-high-level-description: todo
- docs-installation-instructions: todo
- docs-removal-instructions: todo
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: The integration registers no events
@@ -26,8 +26,10 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
- docs-configuration-parameters: todo
- docs-installation-parameters: todo
+ docs-configuration-parameters:
+ status: exempt
+ comment: Integration has no configuration parameters
+ docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable:
@@ -46,13 +48,15 @@ rules:
discovery:
status: exempt
comment: Integration is a service and has no devices.
- 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
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: Integration is a service and has no devices.
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done
diff --git a/homeassistant/components/bring/sensor.py b/homeassistant/components/bring/sensor.py
index bfe93619dbb..2a09d574607 100644
--- a/homeassistant/components/bring/sensor.py
+++ b/homeassistant/components/bring/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import BringConfigEntry, BringData, BringDataUpdateCoordinator
@@ -85,7 +85,7 @@ SENSOR_DESCRIPTIONS: tuple[BringSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BringConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/bring/strings.json b/homeassistant/components/bring/strings.json
index 1dbe0adbf6c..2c30af5adce 100644
--- a/homeassistant/components/bring/strings.json
+++ b/homeassistant/components/bring/strings.json
@@ -13,7 +13,7 @@
},
"data_description": {
"email": "The email address associated with your Bring! account.",
- "password": "The password to login to your Bring! account."
+ "password": "The password to log in to your Bring! account."
}
},
"reauth_confirm": {
diff --git a/homeassistant/components/bring/todo.py b/homeassistant/components/bring/todo.py
index 4de306273f3..d1eb9e78341 100644
--- a/homeassistant/components/bring/todo.py
+++ b/homeassistant/components/bring/todo.py
@@ -24,7 +24,7 @@ from homeassistant.components.todo import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_ITEM_NAME,
@@ -41,7 +41,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BringConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor from a config entry created in the integrations UI."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/broadlink/climate.py b/homeassistant/components/broadlink/climate.py
index 25a6bbd60a5..5be04c24f0d 100644
--- a/homeassistant/components/broadlink/climate.py
+++ b/homeassistant/components/broadlink/climate.py
@@ -13,7 +13,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PRECISION_HALVES, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, DOMAINS_AND_TYPES
from .device import BroadlinkDevice
@@ -31,7 +31,7 @@ class SensorMode(IntEnum):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Broadlink climate entities."""
device = hass.data[DOMAIN].devices[config_entry.entry_id]
diff --git a/homeassistant/components/broadlink/entity.py b/homeassistant/components/broadlink/entity.py
index 6c956d8c80a..a97374680f9 100644
--- a/homeassistant/components/broadlink/entity.py
+++ b/homeassistant/components/broadlink/entity.py
@@ -17,13 +17,13 @@ class BroadlinkEntity(Entity):
self._device = device
self._coordinator = device.update_manager.coordinator
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Call when the entity is added to hass."""
self.async_on_remove(self._coordinator.async_add_listener(self._recv_data))
if self._coordinator.data:
self._update_state(self._coordinator.data)
- async def async_update(self):
+ async def async_update(self) -> None:
"""Update the state of the entity."""
await self._coordinator.async_request_refresh()
@@ -49,7 +49,7 @@ class BroadlinkEntity(Entity):
"""
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if the entity is available."""
return self._device.available
diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py
index 39d6caaa49f..64698e57249 100644
--- a/homeassistant/components/broadlink/light.py
+++ b/homeassistant/components/broadlink/light.py
@@ -14,7 +14,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import BroadlinkEntity
@@ -29,7 +29,7 @@ BROADLINK_COLOR_MODE_SCENES = 2
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Broadlink light."""
device = hass.data[DOMAIN].devices[config_entry.entry_id]
diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py
index 18a3a82017c..c1196b03310 100644
--- a/homeassistant/components/broadlink/remote.py
+++ b/homeassistant/components/broadlink/remote.py
@@ -37,7 +37,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_COMMAND, STATE_OFF
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.storage import Store
from homeassistant.util import dt as dt_util
@@ -92,7 +92,7 @@ SERVICE_DELETE_SCHEMA = COMMAND_SCHEMA.extend(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Broadlink remote."""
device = hass.data[DOMAIN].devices[config_entry.entry_id]
diff --git a/homeassistant/components/broadlink/select.py b/homeassistant/components/broadlink/select.py
index 6253adc308a..661fc62600d 100644
--- a/homeassistant/components/broadlink/select.py
+++ b/homeassistant/components/broadlink/select.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BroadlinkDevice
from .const import DOMAIN
@@ -28,7 +28,7 @@ DAY_NAME_TO_ID = {v: k for k, v in DAY_ID_TO_NAME.items()}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Broadlink select."""
device = hass.data[DOMAIN].devices[config_entry.entry_id]
diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py
index b7ae71ff803..e7d420f0c0e 100644
--- a/homeassistant/components/broadlink/sensor.py
+++ b/homeassistant/components/broadlink/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import BroadlinkEntity
@@ -86,7 +86,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Broadlink sensor."""
device = hass.data[DOMAIN].devices[config_entry.entry_id]
diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py
index 9098440a5c4..d6869ac4c9c 100644
--- a/homeassistant/components/broadlink/switch.py
+++ b/homeassistant/components/broadlink/switch.py
@@ -30,7 +30,10 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -85,7 +88,7 @@ async def async_setup_platform(
if switches := config.get(CONF_SWITCHES):
platform_data = hass.data[DOMAIN].platforms.get(Platform.SWITCH, {})
- async_add_entities_config_entry: AddEntitiesCallback
+ async_add_entities_config_entry: AddConfigEntryEntitiesCallback
device: BroadlinkDevice
async_add_entities_config_entry, device = platform_data.get(
mac_addr, (None, None)
@@ -111,7 +114,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Broadlink switch."""
device = hass.data[DOMAIN].devices[config_entry.entry_id]
diff --git a/homeassistant/components/broadlink/time.py b/homeassistant/components/broadlink/time.py
index 3dcb045fead..4687df6b8b6 100644
--- a/homeassistant/components/broadlink/time.py
+++ b/homeassistant/components/broadlink/time.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.time import TimeEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import BroadlinkDevice
@@ -19,7 +19,7 @@ from .entity import BroadlinkEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Broadlink time."""
device = hass.data[DOMAIN].devices[config_entry.entry_id]
diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py
index 464e6629224..1c1768b58fd 100644
--- a/homeassistant/components/brother/__init__.py
+++ b/homeassistant/components/brother/__init__.py
@@ -9,6 +9,7 @@ from homeassistant.const import CONF_HOST, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from .const import DOMAIN
from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
@@ -25,7 +26,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: BrotherConfigEntry) -> b
host, printer_type=printer_type, snmp_engine=snmp_engine
)
except (ConnectionError, SnmpError, TimeoutError) as error:
- raise ConfigEntryNotReady from error
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={
+ "device": entry.title,
+ "error": repr(error),
+ },
+ ) from error
coordinator = BrotherDataUpdateCoordinator(hass, entry, brother)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/brother/coordinator.py b/homeassistant/components/brother/coordinator.py
index 4f518ba8a25..a3c337f27f7 100644
--- a/homeassistant/components/brother/coordinator.py
+++ b/homeassistant/components/brother/coordinator.py
@@ -26,6 +26,7 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]):
) -> None:
"""Initialize."""
self.brother = brother
+ self.device_name = config_entry.title
super().__init__(
hass,
@@ -41,5 +42,12 @@ class BrotherDataUpdateCoordinator(DataUpdateCoordinator[BrotherSensors]):
async with timeout(20):
data = await self.brother.async_update()
except (ConnectionError, SnmpError, UnsupportedModelError) as error:
- raise UpdateFailed(error) from error
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ translation_placeholders={
+ "device": self.device_name,
+ "error": repr(error),
+ },
+ ) from error
return data
diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py
index 087a971f928..a09fe8ebc60 100644
--- a/homeassistant/components/brother/sensor.py
+++ b/homeassistant/components/brother/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -303,7 +303,7 @@ SENSOR_TYPES: tuple[BrotherSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: BrotherConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Brother entities from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/brother/strings.json b/homeassistant/components/brother/strings.json
index b502ed7e3b9..d0714a199c4 100644
--- a/homeassistant/components/brother/strings.json
+++ b/homeassistant/components/brother/strings.json
@@ -159,5 +159,13 @@
"name": "Last restart"
}
}
+ },
+ "exceptions": {
+ "cannot_connect": {
+ "message": "An error occurred while connecting to the {device} printer: {error}"
+ },
+ "update_error": {
+ "message": "An error occurred while retrieving data from the {device} printer: {error}"
+ }
}
}
diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py
index 6725a32bb40..60f9a8163de 100644
--- a/homeassistant/components/brottsplatskartan/sensor.py
+++ b/homeassistant/components/brottsplatskartan/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_APP_ID, CONF_AREA, DOMAIN, LOGGER
@@ -21,7 +21,9 @@ SCAN_INTERVAL = timedelta(minutes=30)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Brottsplatskartan sensor entry."""
diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py
index bb97f42bd36..95931d3449e 100644
--- a/homeassistant/components/brunt/cover.py
+++ b/homeassistant/components/brunt/cover.py
@@ -16,7 +16,7 @@ from homeassistant.components.cover import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -34,7 +34,7 @@ from .coordinator import BruntConfigEntry, BruntCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: BruntConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the brunt platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/bryant_evolution/climate.py b/homeassistant/components/bryant_evolution/climate.py
index 2d54ced8217..bd053229a1a 100644
--- a/homeassistant/components/bryant_evolution/climate.py
+++ b/homeassistant/components/bryant_evolution/climate.py
@@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BryantEvolutionConfigEntry, names
from .const import CONF_SYSTEM_ZONE, DOMAIN
@@ -31,7 +31,7 @@ SCAN_INTERVAL = timedelta(seconds=60)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: BryantEvolutionConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a config entry."""
diff --git a/homeassistant/components/bsblan/climate.py b/homeassistant/components/bsblan/climate.py
index 2833d6549b4..bef0388a57d 100644
--- a/homeassistant/components/bsblan/climate.py
+++ b/homeassistant/components/bsblan/climate.py
@@ -19,7 +19,7 @@ from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.device_registry import format_mac
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from . import BSBLanConfigEntry, BSBLanData
@@ -43,7 +43,7 @@ PRESET_MODES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: BSBLanConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BSBLAN device based on a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/bsblan/sensor.py b/homeassistant/components/bsblan/sensor.py
index c13b4ad7650..6a6784a4542 100644
--- a/homeassistant/components/bsblan/sensor.py
+++ b/homeassistant/components/bsblan/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import BSBLanConfigEntry, BSBLanData
@@ -51,7 +51,7 @@ SENSOR_TYPES: tuple[BSBLanSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: BSBLanConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BSB-Lan sensor based on a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/bsblan/strings.json b/homeassistant/components/bsblan/strings.json
index a73a89ca1cc..93562763999 100644
--- a/homeassistant/components/bsblan/strings.json
+++ b/homeassistant/components/bsblan/strings.json
@@ -30,7 +30,7 @@
"message": "Can't set preset mode to {preset_mode} when HVAC mode is not set to auto"
},
"set_data_error": {
- "message": "An error occurred while sending the data to the BSBLAN device"
+ "message": "An error occurred while sending the data to the BSB-Lan device"
},
"set_temperature_error": {
"message": "An error occurred while setting the temperature"
diff --git a/homeassistant/components/bsblan/water_heater.py b/homeassistant/components/bsblan/water_heater.py
index 318408a9124..a3aee4cdc15 100644
--- a/homeassistant/components/bsblan/water_heater.py
+++ b/homeassistant/components/bsblan/water_heater.py
@@ -16,7 +16,7 @@ from homeassistant.const import ATTR_TEMPERATURE, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import format_mac
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BSBLanConfigEntry, BSBLanData
from .const import DOMAIN
@@ -37,7 +37,7 @@ OPERATION_MODES_REVERSE = {v: k for k, v in OPERATION_MODES.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: BSBLanConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BSBLAN water heater based on a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/bthome/binary_sensor.py b/homeassistant/components/bthome/binary_sensor.py
index bcc420df4a8..97ed85c1204 100644
--- a/homeassistant/components/bthome/binary_sensor.py
+++ b/homeassistant/components/bthome/binary_sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .coordinator import BTHomePassiveBluetoothDataProcessor
@@ -169,7 +169,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: BTHomeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the BTHome BLE binary sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/bthome/event.py b/homeassistant/components/bthome/event.py
index a6ee79f4e05..99799819e43 100644
--- a/homeassistant/components/bthome/event.py
+++ b/homeassistant/components/bthome/event.py
@@ -12,7 +12,7 @@ from homeassistant.components.event import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import format_discovered_event_class, format_event_dispatcher_name
from .const import (
@@ -104,7 +104,7 @@ class BTHomeEventEntity(EventEntity):
async def async_setup_entry(
hass: HomeAssistant,
entry: BTHomeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up BTHome event."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py
index 23a058b0b0c..7025929abd8 100644
--- a/homeassistant/components/bthome/sensor.py
+++ b/homeassistant/components/bthome/sensor.py
@@ -42,7 +42,7 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .coordinator import BTHomePassiveBluetoothDataProcessor
@@ -423,7 +423,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: BTHomeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the BTHome BLE sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py
index 45ff2d6de52..15d08281911 100644
--- a/homeassistant/components/buienradar/camera.py
+++ b/homeassistant/components/buienradar/camera.py
@@ -13,7 +13,7 @@ from homeassistant.components.camera import Camera
from homeassistant.const import CONF_COUNTRY_CODE, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import BuienRadarConfigEntry
@@ -31,7 +31,7 @@ SUPPORTED_COUNTRY_CODES = ["NL", "BE"]
async def async_setup_entry(
hass: HomeAssistant,
entry: BuienRadarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up buienradar radar-loop camera component."""
config = entry.data
diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py
index 712f765237e..586543de129 100644
--- a/homeassistant/components/buienradar/sensor.py
+++ b/homeassistant/components/buienradar/sensor.py
@@ -45,7 +45,7 @@ from homeassistant.const import (
UnitOfVolumetricFlux,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import BuienRadarConfigEntry
@@ -169,6 +169,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
translation_key="windazimuth",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
SensorEntityDescription(
key="pressure",
@@ -530,30 +532,35 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
translation_key="windazimuth_1d",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
SensorEntityDescription(
key="windazimuth_2d",
translation_key="windazimuth_2d",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
SensorEntityDescription(
key="windazimuth_3d",
translation_key="windazimuth_3d",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
SensorEntityDescription(
key="windazimuth_4d",
translation_key="windazimuth_4d",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
SensorEntityDescription(
key="windazimuth_5d",
translation_key="windazimuth_5d",
native_unit_of_measurement=DEGREE,
icon="mdi:compass-outline",
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
SensorEntityDescription(
key="condition_1d",
@@ -691,7 +698,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: BuienRadarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the buienradar sensor."""
config = entry.data
diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py
index a7267320de3..4d54c95fd6c 100644
--- a/homeassistant/components/buienradar/util.py
+++ b/homeassistant/components/buienradar/util.py
@@ -12,6 +12,7 @@ from buienradar.constants import (
CONDITION,
CONTENT,
DATA,
+ FEELTEMPERATURE,
FORECAST,
HUMIDITY,
MESSAGE,
@@ -22,6 +23,7 @@ from buienradar.constants import (
TEMPERATURE,
VISIBILITY,
WINDAZIMUTH,
+ WINDGUST,
WINDSPEED,
)
from buienradar.urls import JSON_FEED_URL, json_precipitation_forecast_url
@@ -200,6 +202,14 @@ class BrData:
except (ValueError, TypeError):
return None
+ @property
+ def feeltemperature(self):
+ """Return the feeltemperature, or None."""
+ try:
+ return float(self.data.get(FEELTEMPERATURE))
+ except (ValueError, TypeError):
+ return None
+
@property
def pressure(self):
"""Return the pressure, or None."""
@@ -224,6 +234,14 @@ class BrData:
except (ValueError, TypeError):
return None
+ @property
+ def wind_gust(self):
+ """Return the windgust, or None."""
+ try:
+ return float(self.data.get(WINDGUST))
+ except (ValueError, TypeError):
+ return None
+
@property
def wind_speed(self):
"""Return the windspeed, or None."""
diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py
index 8b71032bace..568926ef0cd 100644
--- a/homeassistant/components/buienradar/weather.py
+++ b/homeassistant/components/buienradar/weather.py
@@ -9,6 +9,7 @@ from buienradar.constants import (
MAX_TEMP,
MIN_TEMP,
RAIN,
+ RAIN_CHANCE,
WINDAZIMUTH,
WINDSPEED,
)
@@ -33,6 +34,7 @@ from homeassistant.components.weather import (
ATTR_FORECAST_NATIVE_TEMP,
ATTR_FORECAST_NATIVE_TEMP_LOW,
ATTR_FORECAST_NATIVE_WIND_SPEED,
+ ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
Forecast,
@@ -51,7 +53,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BuienRadarConfigEntry
from .const import DEFAULT_TIMEFRAME
@@ -94,7 +96,7 @@ CONDITION_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
entry: BuienRadarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the buienradar platform."""
config = entry.data
@@ -153,7 +155,9 @@ class BrWeather(WeatherEntity):
)
self._attr_native_pressure = data.pressure
self._attr_native_temperature = data.temperature
+ self._attr_native_apparent_temperature = data.feeltemperature
self._attr_native_visibility = data.visibility
+ self._attr_native_wind_gust_speed = data.wind_gust
self._attr_native_wind_speed = data.wind_speed
self._attr_wind_bearing = data.wind_bearing
@@ -188,6 +192,7 @@ class BrWeather(WeatherEntity):
ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP),
ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP),
ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN),
+ ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.get(RAIN_CHANCE),
ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH),
ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED),
}
diff --git a/homeassistant/components/burbank_water_and_power/__init__.py b/homeassistant/components/burbank_water_and_power/__init__.py
new file mode 100644
index 00000000000..2b82c8bd56b
--- /dev/null
+++ b/homeassistant/components/burbank_water_and_power/__init__.py
@@ -0,0 +1 @@
+"""Virtual integration: Burbank Water and Power (BWP)."""
diff --git a/homeassistant/components/burbank_water_and_power/manifest.json b/homeassistant/components/burbank_water_and_power/manifest.json
new file mode 100644
index 00000000000..7b938d3b98b
--- /dev/null
+++ b/homeassistant/components/burbank_water_and_power/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "burbank_water_and_power",
+ "name": "Burbank Water and Power (BWP)",
+ "integration_type": "virtual",
+ "supported_by": "opower"
+}
diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py
index 7a426112d04..be909a02ea5 100644
--- a/homeassistant/components/caldav/calendar.py
+++ b/homeassistant/components/caldav/calendar.py
@@ -25,7 +25,10 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -143,7 +146,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
entry: CalDavConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the CalDav calendar platform for a config entry."""
calendars = await async_get_calendars(hass, entry.runtime_data, SUPPORTED_COMPONENT)
diff --git a/homeassistant/components/caldav/todo.py b/homeassistant/components/caldav/todo.py
index cbd7963b595..73f172dabec 100644
--- a/homeassistant/components/caldav/todo.py
+++ b/homeassistant/components/caldav/todo.py
@@ -20,7 +20,7 @@ from homeassistant.components.todo import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import CalDavConfigEntry
@@ -46,7 +46,7 @@ TODO_STATUS_MAP_INV: dict[TodoItemStatus, str] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: CalDavConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the CalDav todo platform for a config entry."""
calendars = await async_get_calendars(hass, entry.runtime_data, SUPPORTED_COMPONENT)
@@ -138,6 +138,8 @@ class WebDavTodoListEntity(TodoListEntity):
await self.hass.async_add_executor_job(
partial(self._calendar.save_todo, **item_data),
)
+ # refreshing async otherwise it would take too much time
+ self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err
@@ -172,6 +174,8 @@ class WebDavTodoListEntity(TodoListEntity):
obj_type="todo",
),
)
+ # refreshing async otherwise it would take too much time
+ self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV save error: {err}") from err
@@ -195,3 +199,5 @@ class WebDavTodoListEntity(TodoListEntity):
await self.hass.async_add_executor_job(item.delete)
except (requests.ConnectionError, DAVError) as err:
raise HomeAssistantError(f"CalDAV delete error: {err}") from err
+ # refreshing async otherwise it would take too much time
+ self.hass.async_create_task(self.async_update_ha_state(force_refresh=True))
diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py
index 40d6952fa64..96bf717c3ac 100644
--- a/homeassistant/components/calendar/__init__.py
+++ b/homeassistant/components/calendar/__init__.py
@@ -153,6 +153,27 @@ def _has_min_duration(
return validate
+def _has_positive_interval(
+ start_key: str, end_key: str, duration_key: str
+) -> Callable[[dict[str, Any]], dict[str, Any]]:
+ """Verify that the time span between start and end is greater than zero."""
+
+ def validate(obj: dict[str, Any]) -> dict[str, Any]:
+ if (duration := obj.get(duration_key)) is not None:
+ if duration <= datetime.timedelta(seconds=0):
+ raise vol.Invalid(f"Expected positive duration ({duration})")
+ return obj
+
+ if (start := obj.get(start_key)) and (end := obj.get(end_key)):
+ if start >= end:
+ raise vol.Invalid(
+ f"Expected end time to be after start time ({start}, {end})"
+ )
+ return obj
+
+ return validate
+
+
def _has_same_type(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]]:
"""Verify that all values are of the same type."""
@@ -281,6 +302,7 @@ SERVICE_GET_EVENTS_SCHEMA: Final = vol.All(
),
}
),
+ _has_positive_interval(EVENT_START_DATETIME, EVENT_END_DATETIME, EVENT_DURATION),
)
@@ -870,6 +892,7 @@ async def async_get_events_service(
end = start + service_call.data[EVENT_DURATION]
else:
end = service_call.data[EVENT_END_DATETIME]
+
calendar_event_list = await calendar.async_get_events(
calendar.hass, dt_util.as_local(start), dt_util.as_local(end)
)
diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json
index c0127c20d05..6612ea5209d 100644
--- a/homeassistant/components/calendar/strings.json
+++ b/homeassistant/components/calendar/strings.json
@@ -74,7 +74,7 @@
},
"get_events": {
"name": "Get events",
- "description": "Get events on a calendar within a time range.",
+ "description": "Retrieves events on a calendar within a time range.",
"fields": {
"start_date_time": {
"name": "Start time",
diff --git a/homeassistant/components/cambridge_audio/manifest.json b/homeassistant/components/cambridge_audio/manifest.json
index 14a389587d2..88d28e256aa 100644
--- a/homeassistant/components/cambridge_audio/manifest.json
+++ b/homeassistant/components/cambridge_audio/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["aiostreammagic"],
"quality_scale": "platinum",
- "requirements": ["aiostreammagic==2.10.0"],
+ "requirements": ["aiostreammagic==2.11.0"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
}
diff --git a/homeassistant/components/cambridge_audio/media_player.py b/homeassistant/components/cambridge_audio/media_player.py
index 042178d5781..5322ae7d9a2 100644
--- a/homeassistant/components/cambridge_audio/media_player.py
+++ b/homeassistant/components/cambridge_audio/media_player.py
@@ -23,7 +23,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import CambridgeAudioConfigEntry, media_browser
from .const import (
@@ -65,7 +65,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: CambridgeAudioConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cambridge Audio device based on a config entry."""
client: StreamMagicClient = entry.runtime_data
@@ -142,6 +142,12 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
@property
def media_artist(self) -> str | None:
"""Artist of current playing media, music track only."""
+ if (
+ not self.client.play_state.metadata.artist
+ and self.client.state.source == "IR"
+ ):
+ # Return channel instead of artist when playing internet radio
+ return self.client.play_state.metadata.station
return self.client.play_state.metadata.artist
@property
@@ -169,6 +175,11 @@ class CambridgeAudioDevice(CambridgeAudioEntity, MediaPlayerEntity):
"""Last time the media position was updated."""
return self.client.position_last_updated
+ @property
+ def media_channel(self) -> str | None:
+ """Channel currently playing."""
+ return self.client.play_state.metadata.station
+
@property
def is_volume_muted(self) -> bool | None:
"""Volume mute status."""
diff --git a/homeassistant/components/cambridge_audio/select.py b/homeassistant/components/cambridge_audio/select.py
index 6bfe83c2539..e7d9136711f 100644
--- a/homeassistant/components/cambridge_audio/select.py
+++ b/homeassistant/components/cambridge_audio/select.py
@@ -9,7 +9,7 @@ from aiostreammagic.models import DisplayBrightness
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import CambridgeAudioConfigEntry
from .entity import CambridgeAudioEntity, command
@@ -82,7 +82,7 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: CambridgeAudioConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cambridge Audio select entities based on a config entry."""
diff --git a/homeassistant/components/cambridge_audio/switch.py b/homeassistant/components/cambridge_audio/switch.py
index 065a1da4f94..0cebe8266c4 100644
--- a/homeassistant/components/cambridge_audio/switch.py
+++ b/homeassistant/components/cambridge_audio/switch.py
@@ -9,7 +9,7 @@ from aiostreammagic import StreamMagicClient
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import CambridgeAudioConfigEntry
from .entity import CambridgeAudioEntity, command
@@ -46,7 +46,7 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: CambridgeAudioConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cambridge Audio switch entities based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/camera/img_util.py b/homeassistant/components/camera/img_util.py
index bbe85bf82db..971e6804add 100644
--- a/homeassistant/components/camera/img_util.py
+++ b/homeassistant/components/camera/img_util.py
@@ -2,17 +2,10 @@
from __future__ import annotations
-from contextlib import suppress
import logging
from typing import TYPE_CHECKING, Literal, cast
-with suppress(Exception):
- # TurboJPEG imports numpy which may or may not work so
- # we have to guard the import here. We still want
- # to import it at top level so it gets loaded
- # in the import executor and not in the event loop.
- from turbojpeg import TurboJPEG
-
+from turbojpeg import TurboJPEG
if TYPE_CHECKING:
from . import Image
diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py
index 443944da8c3..9fe2dfb598d 100644
--- a/homeassistant/components/canary/alarm_control_panel.py
+++ b/homeassistant/components/canary/alarm_control_panel.py
@@ -13,7 +13,7 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator
@@ -22,7 +22,7 @@ from .coordinator import CanaryConfigEntry, CanaryDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: CanaryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Canary alarm control panels based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py
index 8f4a01c9968..07645f2f403 100644
--- a/homeassistant/components/canary/camera.py
+++ b/homeassistant/components/canary/camera.py
@@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -48,7 +48,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: CanaryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Canary sensors based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py
index 22f3eada2cb..d92166926e9 100644
--- a/homeassistant/components/canary/sensor.py
+++ b/homeassistant/components/canary/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
@@ -64,7 +64,7 @@ STATE_AIR_QUALITY_VERY_ABNORMAL: Final = "very_abnormal"
async def async_setup_entry(
hass: HomeAssistant,
entry: CanaryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Canary sensors based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/canary/strings.json b/homeassistant/components/canary/strings.json
index 699e8b25e11..8be11a48b5e 100644
--- a/homeassistant/components/canary/strings.json
+++ b/homeassistant/components/canary/strings.json
@@ -21,8 +21,8 @@
"step": {
"init": {
"data": {
- "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras",
- "timeout": "Request Timeout (seconds)"
+ "ffmpeg_arguments": "Arguments passed to FFmpeg for cameras",
+ "timeout": "Request timeout (seconds)"
}
}
}
diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py
index 034cf856023..6c33eac230f 100644
--- a/homeassistant/components/cast/config_flow.py
+++ b/homeassistant/components/cast/config_flow.py
@@ -16,12 +16,21 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_UUID
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import CONF_IGNORE_CEC, CONF_KNOWN_HOSTS, DOMAIN
IGNORE_CEC_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
-KNOWN_HOSTS_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
+KNOWN_HOSTS_SCHEMA = vol.Schema(
+ {
+ vol.Optional(
+ CONF_KNOWN_HOSTS,
+ ): SelectSelector(
+ SelectSelectorConfig(custom_value=True, options=[], multiple=True),
+ )
+ }
+)
WANTED_UUID_SCHEMA = vol.Schema(vol.All(cv.ensure_list, [cv.string]))
@@ -30,12 +39,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
- def __init__(self) -> None:
- """Initialize flow."""
- self._ignore_cec = set[str]()
- self._known_hosts = set[str]()
- self._wanted_uuid = set[str]()
-
@staticmethod
@callback
def async_get_options_flow(
@@ -62,48 +65,31 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup."""
- errors = {}
- data = {CONF_KNOWN_HOSTS: self._known_hosts}
-
if user_input is not None:
- bad_hosts = False
- known_hosts = user_input[CONF_KNOWN_HOSTS]
- known_hosts = [x.strip() for x in known_hosts.split(",") if x.strip()]
- try:
- known_hosts = KNOWN_HOSTS_SCHEMA(known_hosts)
- except vol.Invalid:
- errors["base"] = "invalid_known_hosts"
- bad_hosts = True
- else:
- self._known_hosts = known_hosts
- data = self._get_data()
- if not bad_hosts:
- return self.async_create_entry(title="Google Cast", data=data)
+ known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
+ return self.async_create_entry(
+ title="Google Cast",
+ data=self._get_data(known_hosts=known_hosts),
+ )
- fields = {}
- fields[vol.Optional(CONF_KNOWN_HOSTS, default="")] = str
-
- return self.async_show_form(
- step_id="config", data_schema=vol.Schema(fields), errors=errors
- )
+ return self.async_show_form(step_id="config", data_schema=KNOWN_HOSTS_SCHEMA)
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm the setup."""
-
- data = self._get_data()
-
if user_input is not None or not onboarding.async_is_onboarded(self.hass):
- return self.async_create_entry(title="Google Cast", data=data)
+ return self.async_create_entry(title="Google Cast", data=self._get_data())
return self.async_show_form(step_id="confirm")
- def _get_data(self):
+ def _get_data(
+ self, *, known_hosts: list[str] | None = None
+ ) -> dict[str, list[str]]:
return {
- CONF_IGNORE_CEC: list(self._ignore_cec),
- CONF_KNOWN_HOSTS: list(self._known_hosts),
- CONF_UUID: list(self._wanted_uuid),
+ CONF_IGNORE_CEC: [],
+ CONF_KNOWN_HOSTS: known_hosts or [],
+ CONF_UUID: [],
}
@@ -123,31 +109,24 @@ class CastOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the Google Cast options."""
errors: dict[str, str] = {}
- current_config = self.config_entry.data
if user_input is not None:
- bad_hosts, known_hosts = _string_to_list(
- user_input.get(CONF_KNOWN_HOSTS, ""), KNOWN_HOSTS_SCHEMA
+ known_hosts = _trim_items(user_input.get(CONF_KNOWN_HOSTS, []))
+ self.updated_config = dict(self.config_entry.data)
+ self.updated_config[CONF_KNOWN_HOSTS] = known_hosts
+
+ if self.show_advanced_options:
+ return await self.async_step_advanced_options()
+
+ self.hass.config_entries.async_update_entry(
+ self.config_entry, data=self.updated_config
)
-
- if not bad_hosts:
- self.updated_config = dict(current_config)
- self.updated_config[CONF_KNOWN_HOSTS] = known_hosts
-
- if self.show_advanced_options:
- return await self.async_step_advanced_options()
-
- self.hass.config_entries.async_update_entry(
- self.config_entry, data=self.updated_config
- )
- return self.async_create_entry(title="", data={})
-
- fields: dict[vol.Marker, type[str]] = {}
- suggested_value = _list_to_string(current_config.get(CONF_KNOWN_HOSTS))
- _add_with_suggestion(fields, CONF_KNOWN_HOSTS, suggested_value)
+ return self.async_create_entry(title="", data={})
return self.async_show_form(
step_id="basic_options",
- data_schema=vol.Schema(fields),
+ data_schema=self.add_suggested_values_to_schema(
+ KNOWN_HOSTS_SCHEMA, self.config_entry.data
+ ),
errors=errors,
last_step=not self.show_advanced_options,
)
@@ -206,6 +185,10 @@ def _string_to_list(string, schema):
return invalid, items
+def _trim_items(items: list[str]) -> list[str]:
+ return [x.strip() for x in items if x.strip()]
+
+
def _add_with_suggestion(
fields: dict[vol.Marker, type[str]], key: str, suggested_value: str
) -> None:
diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py
index 056ee054d1d..0a85a0007b3 100644
--- a/homeassistant/components/cast/const.py
+++ b/homeassistant/components/cast/const.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, TypedDict
+from typing import TYPE_CHECKING, NotRequired, TypedDict
from homeassistant.util.signal_type import SignalType
@@ -46,3 +46,4 @@ class HomeAssistantControllerData(TypedDict):
hass_uuid: str
client_id: str | None
refresh_token: str
+ app_id: NotRequired[str]
diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py
index 8f4af197b8e..c45bbb4fbbc 100644
--- a/homeassistant/components/cast/helpers.py
+++ b/homeassistant/components/cast/helpers.py
@@ -7,6 +7,7 @@ from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING, ClassVar
from urllib.parse import urlparse
+from uuid import UUID
import aiohttp
import attr
@@ -40,7 +41,7 @@ class ChromecastInfo:
is_dynamic_group = attr.ib(type=bool | None, default=None)
@property
- def friendly_name(self) -> str:
+ def friendly_name(self) -> str | None:
"""Return the Friendly Name."""
return self.cast_info.friendly_name
@@ -50,7 +51,7 @@ class ChromecastInfo:
return self.cast_info.cast_type == CAST_TYPE_GROUP
@property
- def uuid(self) -> bool:
+ def uuid(self) -> UUID:
"""Return the UUID."""
return self.cast_info.uuid
@@ -80,7 +81,7 @@ class ChromecastInfo:
"+label%3A%22integration%3A+cast%22"
)
- _LOGGER.debug(
+ _LOGGER.info(
(
"Fetched cast details for unknown model '%s' manufacturer:"
" '%s', type: '%s'. Please %s"
@@ -111,7 +112,10 @@ class ChromecastInfo:
is_dynamic_group = False
http_group_status = None
http_group_status = dial.get_multizone_status(
- None,
+ # We pass services which will be used for the HTTP request, and we
+ # don't care about the host in http_group_status.dynamic_groups so
+ # we pass an empty string to simplify the code.
+ "",
services=self.cast_info.services,
zconf=ChromeCastZeroconf.get_zeroconf(),
)
diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json
index 0650f267544..6c8b0536e2f 100644
--- a/homeassistant/components/cast/manifest.json
+++ b/homeassistant/components/cast/manifest.json
@@ -14,7 +14,7 @@
"documentation": "https://www.home-assistant.io/integrations/cast",
"iot_class": "local_polling",
"loggers": ["casttube", "pychromecast"],
- "requirements": ["PyChromecast==14.0.5"],
+ "requirements": ["PyChromecast==14.0.7"],
"single_config_entry": true,
"zeroconf": ["_googlecast._tcp.local."]
}
diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py
index 3cc17fae43b..8ff078dfafd 100644
--- a/homeassistant/components/cast/media_player.py
+++ b/homeassistant/components/cast/media_player.py
@@ -51,7 +51,7 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.network import NoURLAvailableError, get_url, is_hass_url
from homeassistant.util import dt as dt_util
from homeassistant.util.logging import async_create_catching_coro
@@ -140,7 +140,7 @@ def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cast from a config entry."""
hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set())
diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml
index e2e23ad40a2..45b36f6d983 100644
--- a/homeassistant/components/cast/services.yaml
+++ b/homeassistant/components/cast/services.yaml
@@ -7,11 +7,11 @@ show_lovelace_view:
integration: cast
domain: media_player
dashboard_path:
- required: true
example: lovelace-cast
selector:
text:
view_path:
+ required: true
example: downstairs
selector:
text:
diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json
index 9c49813bd83..8c7c7c0cff0 100644
--- a/homeassistant/components/cast/strings.json
+++ b/homeassistant/components/cast/strings.json
@@ -6,9 +6,11 @@
},
"config": {
"title": "Google Cast configuration",
- "description": "Known Hosts - A comma-separated list of hostnames or IP-addresses of cast devices, use if mDNS discovery is not working.",
"data": {
- "known_hosts": "Known hosts"
+ "known_hosts": "Add known host"
+ },
+ "data_description": {
+ "known_hosts": "Hostnames or IP-addresses of cast devices, use if mDNS discovery is not working"
}
}
},
@@ -20,9 +22,11 @@
"step": {
"basic_options": {
"title": "[%key:component::cast::config::step::config::title%]",
- "description": "[%key:component::cast::config::step::config::description%]",
"data": {
"known_hosts": "[%key:component::cast::config::step::config::data::known_hosts%]"
+ },
+ "data_description": {
+ "known_hosts": "[%key:component::cast::config::step::config::data_description::known_hosts%]"
}
},
"advanced_options": {
@@ -49,7 +53,7 @@
},
"dashboard_path": {
"name": "Dashboard path",
- "description": "The URL path of the dashboard to show."
+ "description": "The URL path of the dashboard to show, defaults to lovelace if not specified."
},
"view_path": {
"name": "View path",
diff --git a/homeassistant/components/ccm15/climate.py b/homeassistant/components/ccm15/climate.py
index 099b91ec02c..df321395b9e 100644
--- a/homeassistant/components/ccm15/climate.py
+++ b/homeassistant/components/ccm15/climate.py
@@ -20,7 +20,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONST_CMD_FAN_MAP, CONST_CMD_STATE_MAP, DOMAIN
@@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: CCM15ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all climate."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py
index a875e664fdd..3854dfc109e 100644
--- a/homeassistant/components/cert_expiry/sensor.py
+++ b/homeassistant/components/cert_expiry/sensor.py
@@ -7,7 +7,7 @@ from datetime import datetime
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import CertExpiryConfigEntry, CertExpiryDataUpdateCoordinator
@@ -17,7 +17,7 @@ from .entity import CertExpiryEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: CertExpiryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add cert-expiry entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/chacon_dio/config_flow.py b/homeassistant/components/chacon_dio/config_flow.py
index 54604b81153..daaf38e0edc 100644
--- a/homeassistant/components/chacon_dio/config_flow.py
+++ b/homeassistant/components/chacon_dio/config_flow.py
@@ -44,7 +44,7 @@ class ChaconDioConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except DIOChaconInvalidAuthError:
errors["base"] = "invalid_auth"
- except Exception: # pylint: disable=broad-except
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
diff --git a/homeassistant/components/chacon_dio/cover.py b/homeassistant/components/chacon_dio/cover.py
index 3a4955adf5c..ea80116be8a 100644
--- a/homeassistant/components/chacon_dio/cover.py
+++ b/homeassistant/components/chacon_dio/cover.py
@@ -12,7 +12,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ChaconDioConfigEntry
from .entity import ChaconDioEntity
@@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ChaconDioConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Chacon Dio cover devices."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/chacon_dio/switch.py b/homeassistant/components/chacon_dio/switch.py
index be178c3c3b5..05b55552615 100644
--- a/homeassistant/components/chacon_dio/switch.py
+++ b/homeassistant/components/chacon_dio/switch.py
@@ -7,7 +7,7 @@ from dio_chacon_wifi_api.const import DeviceTypeEnum
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ChaconDioConfigEntry
from .entity import ChaconDioEntity
@@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ChaconDioConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Chacon Dio switch devices."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py
index 0477ebb111c..6cc403817cf 100644
--- a/homeassistant/components/cisco_ios/device_tracker.py
+++ b/homeassistant/components/cisco_ios/device_tracker.py
@@ -104,7 +104,7 @@ class CiscoDeviceScanner(DeviceScanner):
"""Open connection to the router and get arp entries."""
try:
- cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="uft-8")
+ cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="utf-8")
cisco_ssh.login(
self.host,
self.username,
diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py
index 3ea0f887e76..287a2397121 100644
--- a/homeassistant/components/climate/__init__.py
+++ b/homeassistant/components/climate/__init__.py
@@ -68,7 +68,6 @@ from .const import ( # noqa: F401
FAN_ON,
FAN_TOP,
HVAC_MODES,
- INTENT_GET_TEMPERATURE,
INTENT_SET_TEMPERATURE,
PRESET_ACTIVITY,
PRESET_AWAY,
diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py
index d347ccbbb29..ecc0066cd93 100644
--- a/homeassistant/components/climate/const.py
+++ b/homeassistant/components/climate/const.py
@@ -126,7 +126,6 @@ DEFAULT_MAX_HUMIDITY = 99
DOMAIN = "climate"
-INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
INTENT_SET_TEMPERATURE = "HassClimateSetTemperature"
SERVICE_SET_AUX_HEAT = "set_aux_heat"
diff --git a/homeassistant/components/climate/intent.py b/homeassistant/components/climate/intent.py
index 9837a326188..7691a2db0f1 100644
--- a/homeassistant/components/climate/intent.py
+++ b/homeassistant/components/climate/intent.py
@@ -1,4 +1,4 @@
-"""Intents for the client integration."""
+"""Intents for the climate integration."""
from __future__ import annotations
@@ -11,7 +11,6 @@ from homeassistant.helpers import config_validation as cv, intent
from . import (
ATTR_TEMPERATURE,
DOMAIN,
- INTENT_GET_TEMPERATURE,
INTENT_SET_TEMPERATURE,
SERVICE_SET_TEMPERATURE,
ClimateEntityFeature,
@@ -20,49 +19,9 @@ from . import (
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the climate intents."""
- intent.async_register(hass, GetTemperatureIntent())
intent.async_register(hass, SetTemperatureIntent())
-class GetTemperatureIntent(intent.IntentHandler):
- """Handle GetTemperature intents."""
-
- intent_type = INTENT_GET_TEMPERATURE
- description = "Gets the current temperature of a climate device or entity"
- slot_schema = {
- vol.Optional("area"): intent.non_empty_string,
- vol.Optional("name"): intent.non_empty_string,
- }
- platforms = {DOMAIN}
-
- async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
- """Handle the intent."""
- hass = intent_obj.hass
- slots = self.async_validate_slots(intent_obj.slots)
-
- name: str | None = None
- if "name" in slots:
- name = slots["name"]["value"]
-
- area: str | None = None
- if "area" in slots:
- area = slots["area"]["value"]
-
- match_constraints = intent.MatchTargetsConstraints(
- name=name, area_name=area, domains=[DOMAIN], assistant=intent_obj.assistant
- )
- match_result = intent.async_match_targets(hass, match_constraints)
- if not match_result.is_match:
- raise intent.MatchFailedError(
- result=match_result, constraints=match_constraints
- )
-
- response = intent_obj.create_response()
- response.response_type = intent.IntentResponseType.QUERY_ANSWER
- response.async_set_states(matched_states=match_result.states)
- return response
-
-
class SetTemperatureIntent(intent.IntentHandler):
"""Handle SetTemperature intents."""
diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json
index 6d8b2c5449d..250b2a67efe 100644
--- a/homeassistant/components/climate/strings.json
+++ b/homeassistant/components/climate/strings.json
@@ -28,10 +28,10 @@
"name": "Thermostat",
"state": {
"off": "[%key:common::state::off%]",
+ "auto": "[%key:common::state::auto%]",
"heat": "Heat",
"cool": "Cool",
"heat_cool": "Heat/Cool",
- "auto": "Auto",
"dry": "Dry",
"fan_only": "Fan only"
},
@@ -50,10 +50,10 @@
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
- "auto": "Auto",
- "low": "Low",
- "medium": "Medium",
- "high": "High",
+ "auto": "[%key:common::state::auto%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
"top": "Top",
"middle": "Middle",
"focus": "Focus",
@@ -69,13 +69,13 @@
"hvac_action": {
"name": "Current action",
"state": {
+ "off": "[%key:common::state::off%]",
+ "idle": "[%key:common::state::idle%]",
"cooling": "Cooling",
"defrosting": "Defrosting",
"drying": "Drying",
"fan": "Fan",
"heating": "Heating",
- "idle": "[%key:common::state::idle%]",
- "off": "[%key:common::state::off%]",
"preheating": "Preheating"
}
},
@@ -98,13 +98,13 @@
"name": "Preset",
"state": {
"none": "None",
- "eco": "Eco",
- "away": "Away",
+ "home": "[%key:common::state::home%]",
+ "away": "[%key:common::state::not_home%]",
+ "activity": "Activity",
"boost": "Boost",
"comfort": "Comfort",
- "home": "[%key:common::state::home%]",
- "sleep": "Sleep",
- "activity": "Activity"
+ "eco": "Eco",
+ "sleep": "Sleep"
}
},
"preset_modes": {
@@ -257,8 +257,8 @@
"selector": {
"hvac_mode": {
"options": {
- "off": "Off",
- "auto": "Auto",
+ "off": "[%key:common::state::off%]",
+ "auto": "[%key:common::state::auto%]",
"cool": "Cool",
"dry": "Dry",
"fan_only": "Fan only",
diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py
index 55ffedd2781..97210b4197c 100644
--- a/homeassistant/components/cloud/__init__.py
+++ b/homeassistant/components/cloud/__init__.py
@@ -6,6 +6,7 @@ import asyncio
from collections.abc import Awaitable, Callable
from datetime import datetime, timedelta
from enum import Enum
+import logging
from typing import cast
from hass_nabucasa import Cloud
@@ -19,6 +20,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_REGION,
EVENT_HOMEASSISTANT_STOP,
+ FORMAT_DATETIME,
Platform,
)
from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback
@@ -33,7 +35,7 @@ from homeassistant.helpers.dispatcher import (
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.service import async_register_admin_service
from homeassistant.helpers.typing import ConfigType
-from homeassistant.loader import bind_hass
+from homeassistant.loader import async_get_integration, bind_hass
from homeassistant.util.signal_type import SignalType
# Pre-import backup to avoid it being imported
@@ -62,11 +64,13 @@ from .const import (
CONF_THINGTALK_SERVER,
CONF_USER_POOL_ID,
DATA_CLOUD,
+ DATA_CLOUD_LOG_HANDLER,
DATA_PLATFORMS_SETUP,
DOMAIN,
MODE_DEV,
MODE_PROD,
)
+from .helpers import FixedSizeQueueLogHandler
from .prefs import CloudPreferences
from .repairs import async_manage_legacy_subscription_issue
from .subscription import async_subscription_info
@@ -245,6 +249,8 @@ def async_remote_ui_url(hass: HomeAssistant) -> str:
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Initialize the Home Assistant cloud."""
+ log_handler = hass.data[DATA_CLOUD_LOG_HANDLER] = await _setup_log_handler(hass)
+
# Process configs
if DOMAIN in config:
kwargs = dict(config[DOMAIN])
@@ -267,6 +273,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def _shutdown(event: Event) -> None:
"""Shutdown event."""
await cloud.stop()
+ logging.root.removeHandler(log_handler)
+ del hass.data[DATA_CLOUD_LOG_HANDLER]
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
@@ -405,3 +413,19 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None:
async_register_admin_service(
hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler
)
+
+
+async def _setup_log_handler(hass: HomeAssistant) -> FixedSizeQueueLogHandler:
+ fmt = (
+ "%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s"
+ )
+ handler = FixedSizeQueueLogHandler()
+ handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME))
+
+ integration = await async_get_integration(hass, DOMAIN)
+ loggers: set[str] = {integration.pkg_path, *(integration.loggers or [])}
+
+ for logger_name in loggers:
+ logging.getLogger(logger_name).addHandler(handler)
+
+ return handler
diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py
index 851d658f8e0..3c3d944d479 100644
--- a/homeassistant/components/cloud/account_link.py
+++ b/homeassistant/components/cloud/account_link.py
@@ -127,7 +127,11 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement
flow_id=flow_id, user_input=tokens
)
- self.hass.async_create_task(await_tokens())
+ # It's a background task because it should be cancelled on shutdown and there's nothing else
+ # we can do in such case. There's also no need to wait for this during setup.
+ self.hass.async_create_background_task(
+ await_tokens(), name="Awaiting OAuth tokens"
+ )
return authorize_url
diff --git a/homeassistant/components/cloud/backup.py b/homeassistant/components/cloud/backup.py
index 9531604ccc7..f4426eabeed 100644
--- a/homeassistant/components/cloud/backup.py
+++ b/homeassistant/components/cloud/backup.py
@@ -3,19 +3,28 @@
from __future__ import annotations
import asyncio
-import base64
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
-import hashlib
+from http import HTTPStatus
import logging
import random
-from typing import Any, Literal
+from typing import Any
-from aiohttp import ClientError
+from aiohttp import ClientError, ClientResponseError
from hass_nabucasa import Cloud, CloudError
-from hass_nabucasa.api import CloudApiNonRetryableError
-from hass_nabucasa.cloud_api import async_files_delete_file, async_files_list
+from hass_nabucasa.api import CloudApiError, CloudApiNonRetryableError
+from hass_nabucasa.cloud_api import (
+ FilesHandlerListEntry,
+ async_files_delete_file,
+ async_files_list,
+)
+from hass_nabucasa.files import FilesError, StorageType, calculate_b64md5
-from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
+from homeassistant.components.backup import (
+ AgentBackup,
+ BackupAgent,
+ BackupAgentError,
+ BackupNotFound,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -24,20 +33,11 @@ from .client import CloudClient
from .const import DATA_CLOUD, DOMAIN, EVENT_CLOUD_EVENT
_LOGGER = logging.getLogger(__name__)
-_STORAGE_BACKUP: Literal["backup"] = "backup"
_RETRY_LIMIT = 5
_RETRY_SECONDS_MIN = 60
_RETRY_SECONDS_MAX = 600
-async def _b64md5(stream: AsyncIterator[bytes]) -> str:
- """Calculate the MD5 hash of a file."""
- file_hash = hashlib.md5()
- async for chunk in stream:
- file_hash.update(chunk)
- return base64.b64encode(file_hash.digest()).decode()
-
-
async def async_get_backup_agents(
hass: HomeAssistant,
**kwargs: Any,
@@ -86,11 +86,6 @@ class CloudBackupAgent(BackupAgent):
self._cloud = cloud
self._hass = hass
- @callback
- def _get_backup_filename(self) -> str:
- """Return the backup filename."""
- return f"{self._cloud.client.prefs.instance_id}.tar"
-
async def async_download_backup(
self,
backup_id: str,
@@ -101,13 +96,11 @@ class CloudBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: An async iterator that yields bytes.
"""
- if not await self.async_get_backup(backup_id):
- raise BackupAgentError("Backup not found")
-
+ backup = await self._async_get_backup(backup_id)
try:
content = await self._cloud.files.download(
- storage_type=_STORAGE_BACKUP,
- filename=self._get_backup_filename(),
+ storage_type=StorageType.BACKUP,
+ filename=backup["Key"],
)
except CloudError as err:
raise BackupAgentError(f"Failed to download backup: {err}") from err
@@ -128,17 +121,22 @@ class CloudBackupAgent(BackupAgent):
"""
if not backup.protected:
raise BackupAgentError("Cloud backups must be protected")
+ if self._cloud.subscription_expired:
+ raise BackupAgentError("Cloud subscription has expired")
- base64md5hash = await _b64md5(await open_stream())
- filename = self._get_backup_filename()
- metadata = backup.as_dict()
size = backup.size
+ try:
+ base64md5hash = await calculate_b64md5(open_stream, size)
+ except FilesError as err:
+ raise BackupAgentError(err) from err
+ filename = f"{self._cloud.client.prefs.instance_id}.tar"
+ metadata = backup.as_dict()
tries = 1
while tries <= _RETRY_LIMIT:
try:
await self._cloud.files.upload(
- storage_type=_STORAGE_BACKUP,
+ storage_type=StorageType.BACKUP,
open_stream=open_stream,
filename=filename,
base64md5hash=base64md5hash,
@@ -157,6 +155,13 @@ class CloudBackupAgent(BackupAgent):
) from err
raise BackupAgentError(f"Failed to upload backup {err}") from err
except CloudError as err:
+ if (
+ isinstance(err, CloudApiError)
+ and isinstance(err.orig_exc, ClientResponseError)
+ and err.orig_exc.status == HTTPStatus.FORBIDDEN
+ and self._cloud.subscription_expired
+ ):
+ raise BackupAgentError("Cloud subscription has expired") from err
if tries == _RETRY_LIMIT:
raise BackupAgentError(f"Failed to upload backup {err}") from err
tries += 1
@@ -179,38 +184,48 @@ class CloudBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups.
"""
- if not await self.async_get_backup(backup_id):
- return
-
+ backup = await self._async_get_backup(backup_id)
try:
await async_files_delete_file(
self._cloud,
- storage_type=_STORAGE_BACKUP,
- filename=self._get_backup_filename(),
+ storage_type=StorageType.BACKUP,
+ filename=backup["Key"],
)
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to delete backup") from err
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List backups."""
+ backups = await self._async_list_backups()
+ return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups]
+
+ async def _async_list_backups(self) -> list[FilesHandlerListEntry]:
"""List backups."""
try:
- backups = await async_files_list(self._cloud, storage_type=_STORAGE_BACKUP)
- _LOGGER.debug("Cloud backups: %s", backups)
+ backups = await async_files_list(
+ self._cloud, storage_type=StorageType.BACKUP
+ )
except (ClientError, CloudError) as err:
raise BackupAgentError("Failed to list backups") from err
- return [AgentBackup.from_dict(backup["Metadata"]) for backup in backups]
+ _LOGGER.debug("Cloud backups: %s", backups)
+ return backups
async def async_get_backup(
self,
backup_id: str,
**kwargs: Any,
- ) -> AgentBackup | None:
+ ) -> AgentBackup:
"""Return a backup."""
- backups = await self.async_list_backups()
+ backup = await self._async_get_backup(backup_id)
+ return AgentBackup.from_dict(backup["Metadata"])
+
+ async def _async_get_backup(self, backup_id: str) -> FilesHandlerListEntry:
+ """Return a backup."""
+ backups = await self._async_list_backups()
for backup in backups:
- if backup.backup_id == backup_id:
+ if backup["Metadata"]["backup_id"] == backup_id:
return backup
- return None
+ raise BackupNotFound(f"Backup {backup_id} not found")
diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py
index 75cbd3c9f3d..0df13fe4c7b 100644
--- a/homeassistant/components/cloud/binary_sensor.py
+++ b/homeassistant/components/cloud/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .client import CloudClient
from .const import DATA_CLOUD, DISPATCHER_REMOTE_UPDATE
@@ -26,7 +26,7 @@ WAIT_UNTIL_CHANGE = 3
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Assistant Cloud binary sensors."""
cloud = hass.data[DATA_CLOUD]
diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py
index 3883f19d1b7..e0c15c74cab 100644
--- a/homeassistant/components/cloud/const.py
+++ b/homeassistant/components/cloud/const.py
@@ -12,12 +12,14 @@ if TYPE_CHECKING:
from hass_nabucasa import Cloud
from .client import CloudClient
+ from .helpers import FixedSizeQueueLogHandler
DOMAIN = "cloud"
DATA_CLOUD: HassKey[Cloud[CloudClient]] = HassKey(DOMAIN)
DATA_PLATFORMS_SETUP: HassKey[dict[str, asyncio.Event]] = HassKey(
"cloud_platforms_setup"
)
+DATA_CLOUD_LOG_HANDLER: HassKey[FixedSizeQueueLogHandler] = HassKey("cloud_log_handler")
EVENT_CLOUD_EVENT = "cloud_event"
REQUEST_TIMEOUT = 10
diff --git a/homeassistant/components/cloud/helpers.py b/homeassistant/components/cloud/helpers.py
new file mode 100644
index 00000000000..7795a314fb7
--- /dev/null
+++ b/homeassistant/components/cloud/helpers.py
@@ -0,0 +1,31 @@
+"""Helpers for the cloud component."""
+
+from collections import deque
+import logging
+
+from homeassistant.core import HomeAssistant
+
+
+class FixedSizeQueueLogHandler(logging.Handler):
+ """Log handler to store messages, with auto rotation."""
+
+ MAX_RECORDS = 500
+
+ def __init__(self) -> None:
+ """Initialize a new LogHandler."""
+ super().__init__()
+ self._records: deque[logging.LogRecord] = deque(maxlen=self.MAX_RECORDS)
+
+ def emit(self, record: logging.LogRecord) -> None:
+ """Store log message."""
+ self._records.append(record)
+
+ async def get_logs(self, hass: HomeAssistant) -> list[str]:
+ """Get stored logs."""
+
+ def _get_logs() -> list[str]:
+ # copy the queue since it can mutate while iterating
+ records = self._records.copy()
+ return [self.format(record) for record in records]
+
+ return await hass.async_add_executor_job(_get_logs)
diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
index b1a845ef8b0..6f18cc424cd 100644
--- a/homeassistant/components/cloud/http_api.py
+++ b/homeassistant/components/cloud/http_api.py
@@ -8,14 +8,15 @@ from contextlib import suppress
import dataclasses
from functools import wraps
from http import HTTPStatus
+import json
import logging
import time
-from typing import Any, Concatenate
+from typing import Any, Concatenate, cast
import aiohttp
from aiohttp import web
import attr
-from hass_nabucasa import Cloud, auth, thingtalk
+from hass_nabucasa import AlreadyConnectedError, Cloud, auth, thingtalk
from hass_nabucasa.const import STATE_DISCONNECTED
from hass_nabucasa.voice import TTS_VOICES
import voluptuous as vol
@@ -43,6 +44,7 @@ from .assist_pipeline import async_create_cloud_pipeline
from .client import CloudClient
from .const import (
DATA_CLOUD,
+ DATA_CLOUD_LOG_HANDLER,
EVENT_CLOUD_EVENT,
LOGIN_MFA_TIMEOUT,
PREF_ALEXA_REPORT_STATE,
@@ -63,7 +65,9 @@ from .subscription import async_subscription_info
_LOGGER = logging.getLogger(__name__)
-_CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = {
+_CLOUD_ERRORS: dict[
+ type[Exception], tuple[HTTPStatus, Callable[[Exception], str] | str]
+] = {
TimeoutError: (
HTTPStatus.BAD_GATEWAY,
"Unable to reach the Home Assistant cloud.",
@@ -132,6 +136,10 @@ def async_setup(hass: HomeAssistant) -> None:
HTTPStatus.BAD_REQUEST,
"Multi-factor authentication expired, or not started. Please try again.",
),
+ AlreadyConnectedError: (
+ HTTPStatus.CONFLICT,
+ lambda x: json.dumps(cast(AlreadyConnectedError, x).details),
+ ),
}
)
@@ -196,7 +204,11 @@ def _process_cloud_exception(exc: Exception, where: str) -> tuple[HTTPStatus, st
for err, value_info in _CLOUD_ERRORS.items():
if isinstance(exc, err):
- err_info = value_info
+ status, content = value_info
+ err_info = (
+ status,
+ content if isinstance(content, str) else content(exc),
+ )
break
if err_info is None:
@@ -233,12 +245,17 @@ class CloudLoginView(HomeAssistantView):
name = "api:cloud:login"
@require_admin
+ async def post(self, request: web.Request) -> web.Response:
+ """Handle login request."""
+ return await self._post(request)
+
@_handle_cloud_errors
@RequestDataValidator(
vol.Schema(
vol.All(
{
vol.Required("email"): str,
+ vol.Optional("check_connection", default=False): bool,
vol.Exclusive("password", "login"): str,
vol.Exclusive("code", "login"): str,
},
@@ -246,7 +263,7 @@ class CloudLoginView(HomeAssistantView):
)
)
)
- async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
+ async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle login request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
@@ -257,7 +274,11 @@ class CloudLoginView(HomeAssistantView):
code = data.get("code")
if email and password:
- await cloud.login(email, password)
+ await cloud.login(
+ email,
+ password,
+ check_connection=data["check_connection"],
+ )
else:
if (
@@ -269,7 +290,12 @@ class CloudLoginView(HomeAssistantView):
# Voluptuous should ensure that code is not None because password is
assert code is not None
- await cloud.login_verify_totp(email, code, self._mfa_tokens)
+ await cloud.login_verify_totp(
+ email,
+ code,
+ self._mfa_tokens,
+ check_connection=data["check_connection"],
+ )
self._mfa_tokens = {}
self._mfa_tokens_set_time = 0
@@ -294,8 +320,12 @@ class CloudLogoutView(HomeAssistantView):
name = "api:cloud:logout"
@require_admin
- @_handle_cloud_errors
async def post(self, request: web.Request) -> web.Response:
+ """Handle logout request."""
+ return await self._post(request)
+
+ @_handle_cloud_errors
+ async def _post(self, request: web.Request) -> web.Response:
"""Handle logout request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
@@ -378,9 +408,13 @@ class CloudForgotPasswordView(HomeAssistantView):
name = "api:cloud:forgot_password"
@require_admin
+ async def post(self, request: web.Request) -> web.Response:
+ """Handle forgot password request."""
+ return await self._post(request)
+
@_handle_cloud_errors
@RequestDataValidator(vol.Schema({vol.Required("email"): str}))
- async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
+ async def _post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Handle forgot password request."""
hass = request.app[KEY_HASS]
cloud = hass.data[DATA_CLOUD]
@@ -397,8 +431,11 @@ class DownloadSupportPackageView(HomeAssistantView):
url = "/api/cloud/support_package"
name = "api:cloud:support_package"
- def _generate_markdown(
- self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]]
+ async def _generate_markdown(
+ self,
+ hass: HomeAssistant,
+ hass_info: dict[str, Any],
+ domains_info: dict[str, dict[str, str]],
) -> str:
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
if len(domain_info) == 0:
@@ -424,6 +461,17 @@ class DownloadSupportPackageView(HomeAssistantView):
"\n\n"
)
+ log_handler = hass.data[DATA_CLOUD_LOG_HANDLER]
+ logs = "\n".join(await log_handler.get_logs(hass))
+ markdown += (
+ "## Full logs\n\n"
+ "Logs
\n\n"
+ "```logs\n"
+ f"{logs}\n"
+ "```\n\n"
+ " \n"
+ )
+
return markdown
async def get(self, request: web.Request) -> web.Response:
@@ -433,7 +481,7 @@ class DownloadSupportPackageView(HomeAssistantView):
domain_health = await get_system_health_info(hass)
hass_info = domain_health.pop("homeassistant", {})
- markdown = self._generate_markdown(hass_info, domain_health)
+ markdown = await self._generate_markdown(hass, hass_info, domain_health)
return web.Response(
body=markdown,
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
index 8e8ff4335db..7f448f2f614 100644
--- a/homeassistant/components/cloud/manifest.json
+++ b/homeassistant/components/cloud/manifest.json
@@ -12,7 +12,7 @@
"documentation": "https://www.home-assistant.io/integrations/cloud",
"integration_type": "system",
"iot_class": "cloud_push",
- "loggers": ["hass_nabucasa"],
- "requirements": ["hass-nabucasa==0.89.0"],
+ "loggers": ["acme", "hass_nabucasa", "snitun"],
+ "requirements": ["hass-nabucasa==0.94.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/cloud/onboarding.py b/homeassistant/components/cloud/onboarding.py
new file mode 100644
index 00000000000..ab0a0fbe310
--- /dev/null
+++ b/homeassistant/components/cloud/onboarding.py
@@ -0,0 +1,110 @@
+"""Cloud onboarding views."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from functools import wraps
+from typing import TYPE_CHECKING, Any, Concatenate
+
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPUnauthorized
+
+from homeassistant.components.http import KEY_HASS
+from homeassistant.components.onboarding import (
+ BaseOnboardingView,
+ NoAuthBaseOnboardingView,
+)
+from homeassistant.core import HomeAssistant
+
+from . import http_api as cloud_http
+from .const import DATA_CLOUD
+
+if TYPE_CHECKING:
+ from homeassistant.components.onboarding import OnboardingStoreData
+
+
+async def async_setup_views(hass: HomeAssistant, data: OnboardingStoreData) -> None:
+ """Set up the cloud views."""
+
+ hass.http.register_view(CloudForgotPasswordView(data))
+ hass.http.register_view(CloudLoginView(data))
+ hass.http.register_view(CloudLogoutView(data))
+ hass.http.register_view(CloudStatusView(data))
+
+
+def ensure_not_done[_ViewT: BaseOnboardingView, **_P](
+ func: Callable[
+ Concatenate[_ViewT, web.Request, _P],
+ Coroutine[Any, Any, web.Response],
+ ],
+) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]:
+ """Home Assistant API decorator to check onboarding and cloud."""
+
+ @wraps(func)
+ async def _ensure_not_done(
+ self: _ViewT,
+ request: web.Request,
+ *args: _P.args,
+ **kwargs: _P.kwargs,
+ ) -> web.Response:
+ """Check onboarding status, cloud and call function."""
+ if self._data["done"]:
+ # If at least one onboarding step is done, we don't allow accessing
+ # the cloud onboarding views.
+ raise HTTPUnauthorized
+
+ return await func(self, request, *args, **kwargs)
+
+ return _ensure_not_done
+
+
+class CloudForgotPasswordView(
+ NoAuthBaseOnboardingView, cloud_http.CloudForgotPasswordView
+):
+ """View to start Forgot Password flow."""
+
+ url = "/api/onboarding/cloud/forgot_password"
+ name = "api:onboarding:cloud:forgot_password"
+
+ @ensure_not_done
+ async def post(self, request: web.Request) -> web.Response:
+ """Handle forgot password request."""
+ return await super()._post(request)
+
+
+class CloudLoginView(NoAuthBaseOnboardingView, cloud_http.CloudLoginView):
+ """Login to Home Assistant Cloud."""
+
+ url = "/api/onboarding/cloud/login"
+ name = "api:onboarding:cloud:login"
+
+ @ensure_not_done
+ async def post(self, request: web.Request) -> web.Response:
+ """Handle login request."""
+ return await super()._post(request)
+
+
+class CloudLogoutView(NoAuthBaseOnboardingView, cloud_http.CloudLogoutView):
+ """Log out of the Home Assistant cloud."""
+
+ url = "/api/onboarding/cloud/logout"
+ name = "api:onboarding:cloud:logout"
+
+ @ensure_not_done
+ async def post(self, request: web.Request) -> web.Response:
+ """Handle logout request."""
+ return await super()._post(request)
+
+
+class CloudStatusView(NoAuthBaseOnboardingView):
+ """Get cloud status view."""
+
+ url = "/api/onboarding/cloud/status"
+ name = "api:onboarding:cloud:status"
+
+ @ensure_not_done
+ async def get(self, request: web.Request) -> web.Response:
+ """Return cloud status."""
+ hass = request.app[KEY_HASS]
+ cloud = hass.data[DATA_CLOUD]
+ return self.json({"logged_in": cloud.is_logged_in})
diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py
index b2154448d3a..df377c9a410 100644
--- a/homeassistant/components/cloud/stt.py
+++ b/homeassistant/components/cloud/stt.py
@@ -22,7 +22,7 @@ from homeassistant.components.stt import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.setup import async_when_setup
from .assist_pipeline import async_migrate_cloud_pipeline_engine
@@ -35,7 +35,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Home Assistant Cloud speech platform via config entry."""
stt_platform_loaded = hass.data[DATA_PLATFORMS_SETUP][Platform.STT]
diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py
index 63f36554c65..f901adfa99e 100644
--- a/homeassistant/components/cloud/tts.py
+++ b/homeassistant/components/cloud/tts.py
@@ -23,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PLATFORM, Platform
from homeassistant.core import HomeAssistant, async_get_hass, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.setup import async_when_setup
@@ -256,7 +256,7 @@ async def async_get_engine(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Home Assistant Cloud text-to-speech platform."""
tts_platform_loaded = hass.data[DATA_PLATFORMS_SETUP][Platform.TTS]
@@ -286,7 +286,7 @@ class CloudTTSEntity(TextToSpeechEntity):
return self._language
@property
- def default_options(self) -> dict[str, Any]:
+ def default_options(self) -> dict[str, str]:
"""Return a dict include default options."""
return {
ATTR_AUDIO_OUTPUT: AudioOutput.MP3,
@@ -363,7 +363,7 @@ class CloudTTSEntity(TextToSpeechEntity):
_LOGGER.error("Voice error: %s", err)
return (None, None)
- return (str(options[ATTR_AUDIO_OUTPUT].value), data)
+ return (options[ATTR_AUDIO_OUTPUT], data)
class CloudProvider(Provider):
@@ -404,7 +404,7 @@ class CloudProvider(Provider):
return [Voice(voice, voice) for voice in voices]
@property
- def default_options(self) -> dict[str, Any]:
+ def default_options(self) -> dict[str, str]:
"""Return a dict include default options."""
return {
ATTR_AUDIO_OUTPUT: AudioOutput.MP3,
@@ -444,7 +444,7 @@ class CloudProvider(Provider):
_LOGGER.error("Voice error: %s", err)
return (None, None)
- return (str(options[ATTR_AUDIO_OUTPUT].value), data)
+ return options[ATTR_AUDIO_OUTPUT], data
@callback
diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py
index c3845a447e4..1fad38c5afc 100644
--- a/homeassistant/components/cloudflare/config_flow.py
+++ b/homeassistant/components/cloudflare/config_flow.py
@@ -9,7 +9,6 @@ from typing import Any
import pycfdns
import voluptuous as vol
-from homeassistant.components import persistent_notification
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN, CONF_ZONE
from homeassistant.core import HomeAssistant
@@ -118,8 +117,6 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initiated by the user."""
- persistent_notification.async_dismiss(self.hass, "cloudflare_setup")
-
errors: dict[str, str] = {}
if user_input is not None:
diff --git a/homeassistant/components/cloudflare/strings.json b/homeassistant/components/cloudflare/strings.json
index 8c8ec57b074..453135f47a0 100644
--- a/homeassistant/components/cloudflare/strings.json
+++ b/homeassistant/components/cloudflare/strings.json
@@ -4,19 +4,19 @@
"step": {
"user": {
"title": "Connect to Cloudflare",
- "description": "This integration requires an API Token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.",
+ "description": "This integration requires an API token created with Zone:Zone:Read and Zone:DNS:Edit permissions for all zones in your account.",
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
}
},
"zone": {
- "title": "Choose the Zone to Update",
+ "title": "Choose the zone to update",
"data": {
"zone": "Zone"
}
},
"records": {
- "title": "Choose the Records to Update",
+ "title": "Choose the records to update",
"data": {
"records": "Records"
}
@@ -40,7 +40,7 @@
"services": {
"update_records": {
"name": "Update records",
- "description": "Manually trigger update to Cloudflare records."
+ "description": "Manually triggers an update of Cloudflare records."
}
}
}
diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py
index 530496811d9..00acd2829a6 100644
--- a/homeassistant/components/co2signal/config_flow.py
+++ b/homeassistant/components/co2signal/config_flow.py
@@ -3,11 +3,11 @@
from __future__ import annotations
from collections.abc import Mapping
+import logging
from typing import Any
from aioelectricitymaps import (
ElectricityMaps,
- ElectricityMapsError,
ElectricityMapsInvalidTokenError,
ElectricityMapsNoDataError,
)
@@ -36,6 +36,8 @@ TYPE_USE_HOME = "use_home_location"
TYPE_SPECIFY_COORDINATES = "specify_coordinates"
TYPE_SPECIFY_COUNTRY = "specify_country_code"
+_LOGGER = logging.getLogger(__name__)
+
class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Co2signal."""
@@ -158,7 +160,8 @@ class ElectricityMapsConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except ElectricityMapsNoDataError:
errors["base"] = "no_data"
- except ElectricityMapsError:
+ except Exception:
+ _LOGGER.exception("Unexpected error occurred while checking API key")
errors["base"] = "unknown"
else:
if self.source == SOURCE_REAUTH:
diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py
index 92f88b8ae82..a8e962532b8 100644
--- a/homeassistant/components/co2signal/sensor.py
+++ b/homeassistant/components/co2signal/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, DOMAIN
@@ -54,7 +54,7 @@ SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: CO2SignalConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the CO2signal sensor."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py
index a29154d9c1b..317759f820d 100644
--- a/homeassistant/components/coinbase/__init__.py
+++ b/homeassistant/components/coinbase/__init__.py
@@ -140,8 +140,10 @@ def get_accounts(client, version):
API_ACCOUNT_ID: account[API_V3_ACCOUNT_ID],
API_ACCOUNT_NAME: account[API_ACCOUNT_NAME],
API_ACCOUNT_CURRENCY: account[API_ACCOUNT_CURRENCY],
- API_ACCOUNT_AMOUNT: account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE]
- + account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE],
+ API_ACCOUNT_AMOUNT: (
+ float(account[API_ACCOUNT_AVALIABLE][API_ACCOUNT_VALUE])
+ + float(account[API_ACCOUNT_HOLD][API_ACCOUNT_VALUE])
+ ),
ACCOUNT_IS_VAULT: account[API_RESOURCE_TYPE] == API_V3_TYPE_VAULT,
}
for account in accounts
diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py
index 37509160247..578877e7d90 100644
--- a/homeassistant/components/coinbase/sensor.py
+++ b/homeassistant/components/coinbase/sensor.py
@@ -7,7 +7,7 @@ import logging
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import CoinbaseConfigEntry, CoinbaseData
from .const import (
@@ -45,7 +45,7 @@ ATTRIBUTION = "Data provided by coinbase.com"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: CoinbaseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Coinbase sensor platform."""
instance = config_entry.runtime_data
diff --git a/homeassistant/components/comelit/alarm_control_panel.py b/homeassistant/components/comelit/alarm_control_panel.py
index f694c2b392b..53e767b4434 100644
--- a/homeassistant/components/comelit/alarm_control_panel.py
+++ b/homeassistant/components/comelit/alarm_control_panel.py
@@ -6,7 +6,7 @@ import logging
from typing import cast
from aiocomelit.api import ComelitVedoAreaObject
-from aiocomelit.const import ALARM_AREAS, AlarmAreaState
+from aiocomelit.const import AlarmAreaState
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@@ -15,11 +15,14 @@ from homeassistant.components.alarm_control_panel import (
CodeFormat,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
_LOGGER = logging.getLogger(__name__)
AWAY = "away"
@@ -38,6 +41,7 @@ ALARM_ACTIONS: dict[str, str] = {
ALARM_AREA_ARMED_STATUS: dict[str, int] = {
+ DISABLE: 0,
HOME_P1: 1,
HOME_P2: 2,
NIGHT: 3,
@@ -48,7 +52,7 @@ ALARM_AREA_ARMED_STATUS: dict[str, int] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Comelit VEDO system alarm control panel devices."""
@@ -56,7 +60,7 @@ async def async_setup_entry(
async_add_entities(
ComelitAlarmEntity(coordinator, device, config_entry.entry_id)
- for device in coordinator.data[ALARM_AREAS].values()
+ for device in coordinator.data["alarm_areas"].values()
)
@@ -79,7 +83,6 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
config_entry_entry_id: str,
) -> None:
"""Initialize the alarm panel."""
- self._api = coordinator.api
self._area_index = area.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
@@ -92,7 +95,7 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
@property
def _area(self) -> ComelitVedoAreaObject:
"""Return area object."""
- return self.coordinator.data[ALARM_AREAS][self._area_index]
+ return self.coordinator.data["alarm_areas"][self._area_index]
@property
def available(self) -> bool:
@@ -125,20 +128,46 @@ class ComelitAlarmEntity(CoordinatorEntity[ComelitVedoSystem], AlarmControlPanel
AlarmAreaState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
}.get(self._area.human_status)
+ async def _async_update_state(self, area_state: AlarmAreaState, armed: int) -> None:
+ """Update state after action."""
+ self._area.human_status = area_state
+ self._area.armed = armed
+ await self.async_update_ha_state()
+
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
- if code != str(self._api.device_pin):
+ if code != str(self.coordinator.api.device_pin):
return
- await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[DISABLE])
+ await self.coordinator.api.set_zone_status(
+ self._area.index, ALARM_ACTIONS[DISABLE]
+ )
+ await self._async_update_state(
+ AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
+ )
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
- await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[AWAY])
+ await self.coordinator.api.set_zone_status(
+ self._area.index, ALARM_ACTIONS[AWAY]
+ )
+ await self._async_update_state(
+ AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
+ )
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
- await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[HOME])
+ await self.coordinator.api.set_zone_status(
+ self._area.index, ALARM_ACTIONS[HOME]
+ )
+ await self._async_update_state(
+ AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
+ )
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
- await self._api.set_zone_status(self._area.index, ALARM_ACTIONS[NIGHT])
+ await self.coordinator.api.set_zone_status(
+ self._area.index, ALARM_ACTIONS[NIGHT]
+ )
+ await self._async_update_state(
+ AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
+ )
diff --git a/homeassistant/components/comelit/binary_sensor.py b/homeassistant/components/comelit/binary_sensor.py
index fa51e0b1fda..e1be330afae 100644
--- a/homeassistant/components/comelit/binary_sensor.py
+++ b/homeassistant/components/comelit/binary_sensor.py
@@ -5,23 +5,25 @@ from __future__ import annotations
from typing import cast
from aiocomelit import ComelitVedoZoneObject
-from aiocomelit.const import ALARM_ZONES
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitVedoSystem
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit VEDO presence sensors."""
@@ -29,7 +31,7 @@ async def async_setup_entry(
async_add_entities(
ComelitVedoBinarySensorEntity(coordinator, device, config_entry.entry_id)
- for device in coordinator.data[ALARM_ZONES].values()
+ for device in coordinator.data["alarm_zones"].values()
)
@@ -48,8 +50,7 @@ class ComelitVedoBinarySensorEntity(
config_entry_entry_id: str,
) -> None:
"""Init sensor entity."""
- self._api = coordinator.api
- self._zone = zone
+ self._zone_index = zone.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
@@ -59,4 +60,6 @@ class ComelitVedoBinarySensorEntity(
@property
def is_on(self) -> bool:
"""Presence detected."""
- return self.coordinator.data[ALARM_ZONES][self._zone.index].status_api == "0001"
+ return (
+ self.coordinator.data["alarm_zones"][self._zone_index].status_api == "0001"
+ )
diff --git a/homeassistant/components/comelit/climate.py b/homeassistant/components/comelit/climate.py
index 1baa777bf99..be5b892e53c 100644
--- a/homeassistant/components/comelit/climate.py
+++ b/homeassistant/components/comelit/climate.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from enum import StrEnum
-from typing import Any, cast
+from typing import Any, TypedDict, cast
from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import CLIMATE
@@ -16,11 +16,16 @@ from homeassistant.components.climate import (
UnitOfTemperature,
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from .const import DOMAIN
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
+from .entity import ComelitBridgeBaseEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
class ClimaComelitMode(StrEnum):
@@ -42,22 +47,23 @@ class ClimaComelitCommand(StrEnum):
AUTO = "auto"
-API_STATUS: dict[str, dict[str, Any]] = {
- ClimaComelitMode.OFF: {
- "action": "off",
- "hvac_mode": HVACMode.OFF,
- "hvac_action": HVACAction.OFF,
- },
- ClimaComelitMode.LOWER: {
- "action": "lower",
- "hvac_mode": HVACMode.COOL,
- "hvac_action": HVACAction.COOLING,
- },
- ClimaComelitMode.UPPER: {
- "action": "upper",
- "hvac_mode": HVACMode.HEAT,
- "hvac_action": HVACAction.HEATING,
- },
+class ClimaComelitApiStatus(TypedDict):
+ """Comelit Clima API status."""
+
+ hvac_mode: HVACMode
+ hvac_action: HVACAction
+
+
+API_STATUS: dict[str, ClimaComelitApiStatus] = {
+ ClimaComelitMode.OFF: ClimaComelitApiStatus(
+ hvac_mode=HVACMode.OFF, hvac_action=HVACAction.OFF
+ ),
+ ClimaComelitMode.LOWER: ClimaComelitApiStatus(
+ hvac_mode=HVACMode.COOL, hvac_action=HVACAction.COOLING
+ ),
+ ClimaComelitMode.UPPER: ClimaComelitApiStatus(
+ hvac_mode=HVACMode.HEAT, hvac_action=HVACAction.HEATING
+ ),
}
MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = {
@@ -71,7 +77,7 @@ MODE_TO_ACTION: dict[HVACMode, ClimaComelitCommand] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit climates."""
@@ -83,7 +89,7 @@ async def async_setup_entry(
)
-class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity):
+class ComelitClimateEntity(ComelitBridgeBaseEntity, ClimateEntity):
"""Climate device."""
_attr_hvac_modes = [HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF]
@@ -96,7 +102,6 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
)
_attr_target_temperature_step = PRECISION_TENTHS
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- _attr_has_entity_name = True
_attr_name = None
def __init__(
@@ -106,77 +111,51 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
config_entry_entry_id: str,
) -> None:
"""Init light entity."""
- self._api = coordinator.api
- self._device = device
- super().__init__(coordinator)
- # Use config_entry.entry_id as base for unique_id
- # because no serial number or mac is available
- self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
- self._attr_device_info = coordinator.platform_device_info(device, device.type)
+ super().__init__(coordinator, device, config_entry_entry_id)
+ self._update_attributes()
+
+ def _update_attributes(self) -> None:
+ """Update class attributes."""
+ device = self.coordinator.data[CLIMATE][self._device.index]
+ if not isinstance(device.val, list):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="invalid_clima_data"
+ )
- @property
- def _clima(self) -> list[Any]:
- """Return clima device data."""
# CLIMATE has a 2 item tuple:
# - first for Clima
# - second for Humidifier
- return self.coordinator.data[CLIMATE][self._device.index].val[0]
+ values = device.val[0]
- @property
- def _api_mode(self) -> str:
- """Return device mode."""
- # Values from API: "O", "L", "U"
- return self._clima[2]
+ _active = values[1]
+ _mode = values[2] # Values from API: "O", "L", "U"
+ _automatic = values[3] == ClimaComelitMode.AUTO
- @property
- def _api_active(self) -> bool:
- "Return device active/idle."
- return self._clima[1]
+ self._attr_current_temperature = values[0] / 10
- @property
- def _api_automatic(self) -> bool:
- """Return device in automatic/manual mode."""
- return self._clima[3] == ClimaComelitMode.AUTO
+ self._attr_hvac_action = None
+ if _mode == ClimaComelitMode.OFF:
+ self._attr_hvac_action = HVACAction.OFF
+ if not _active:
+ self._attr_hvac_action = HVACAction.IDLE
+ if _mode in API_STATUS:
+ self._attr_hvac_action = API_STATUS[_mode]["hvac_action"]
- @property
- def target_temperature(self) -> float:
- """Set target temperature."""
- return self._clima[4] / 10
+ self._attr_hvac_mode = None
+ if _mode == ClimaComelitMode.OFF:
+ self._attr_hvac_mode = HVACMode.OFF
+ if _automatic:
+ self._attr_hvac_mode = HVACMode.AUTO
+ if _mode in API_STATUS:
+ self._attr_hvac_mode = API_STATUS[_mode]["hvac_mode"]
- @property
- def current_temperature(self) -> float:
- """Return current temperature."""
- return self._clima[0] / 10
+ self._attr_target_temperature = values[4] / 10
- @property
- def hvac_mode(self) -> HVACMode | None:
- """HVAC current mode."""
-
- if self._api_mode == ClimaComelitMode.OFF:
- return HVACMode.OFF
-
- if self._api_automatic:
- return HVACMode.AUTO
-
- if self._api_mode in API_STATUS:
- return API_STATUS[self._api_mode]["hvac_mode"]
-
- return None
-
- @property
- def hvac_action(self) -> HVACAction | None:
- """HVAC current action."""
-
- if self._api_mode == ClimaComelitMode.OFF:
- return HVACAction.OFF
-
- if not self._api_active:
- return HVACAction.IDLE
-
- if self._api_mode in API_STATUS:
- return API_STATUS[self._api_mode]["hvac_action"]
-
- return None
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._update_attributes()
+ super()._handle_coordinator_update()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -191,6 +170,8 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
await self.coordinator.api.set_clima_status(
self._device.index, ClimaComelitCommand.SET, target_temp
)
+ self._attr_target_temperature = target_temp
+ self.async_write_ha_state()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set hvac mode."""
@@ -202,3 +183,5 @@ class ComelitClimateEntity(CoordinatorEntity[ComelitSerialBridge], ClimateEntity
await self.coordinator.api.set_clima_status(
self._device.index, MODE_TO_ACTION[hvac_mode]
)
+ self._attr_hvac_mode = hvac_mode
+ self.async_write_ha_state()
diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py
index f29cc62136b..5854bc1e324 100644
--- a/homeassistant/components/comelit/config_flow.py
+++ b/homeassistant/components/comelit/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from asyncio.exceptions import TimeoutError
from collections.abc import Mapping
from typing import Any
@@ -53,10 +54,18 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
try:
await api.login()
- except aiocomelit_exceptions.CannotConnect as err:
- raise CannotConnect from err
+ except (aiocomelit_exceptions.CannotConnect, TimeoutError) as err:
+ raise CannotConnect(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={"error": repr(err)},
+ ) from err
except aiocomelit_exceptions.CannotAuthenticate as err:
- raise InvalidAuth from err
+ raise InvalidAuth(
+ translation_domain=DOMAIN,
+ translation_key="cannot_authenticate",
+ translation_placeholders={"error": repr(err)},
+ ) from err
finally:
await api.logout()
await api.close()
diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py
index 84d8fbd6315..f52f33fd6da 100644
--- a/homeassistant/components/comelit/const.py
+++ b/homeassistant/components/comelit/const.py
@@ -9,3 +9,5 @@ _LOGGER = logging.getLogger(__package__)
DOMAIN = "comelit"
DEFAULT_PORT = 80
DEVICE_TYPE_LIST = [BRIDGE, VEDO]
+
+SCAN_INTERVAL = 5
diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py
index fcb149b21d6..df4965d9945 100644
--- a/homeassistant/components/comelit/coordinator.py
+++ b/homeassistant/components/comelit/coordinator.py
@@ -2,18 +2,19 @@
from abc import abstractmethod
from datetime import timedelta
-from typing import Any
+from typing import TypeVar
-from aiocomelit import (
+from aiocomelit.api import (
+ AlarmDataObject,
+ ComelitCommonApi,
ComeliteSerialBridgeApi,
ComelitSerialBridgeObject,
ComelitVedoApi,
ComelitVedoAreaObject,
ComelitVedoZoneObject,
- exceptions,
)
-from aiocomelit.api import ComelitCommonApi
from aiocomelit.const import BRIDGE, VEDO
+from aiocomelit.exceptions import CannotAuthenticate, CannotConnect, CannotRetrieveData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -21,12 +22,18 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import _LOGGER, DOMAIN
+from .const import _LOGGER, DOMAIN, SCAN_INTERVAL
type ComelitConfigEntry = ConfigEntry[ComelitBaseCoordinator]
-class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]):
+T = TypeVar(
+ "T",
+ bound=dict[str, dict[int, ComelitSerialBridgeObject]] | AlarmDataObject,
+)
+
+
+class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
"""Base coordinator for Comelit Devices."""
_hw_version: str
@@ -46,7 +53,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]):
logger=_LOGGER,
config_entry=entry,
name=f"{DOMAIN}-{host}-coordinator",
- update_interval=timedelta(seconds=5),
+ update_interval=timedelta(seconds=SCAN_INTERVAL),
)
device_registry = dr.async_get(self.hass)
device_registry.async_get_or_create(
@@ -81,23 +88,25 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[dict[str, Any]]):
hw_version=self._hw_version,
)
- async def _async_update_data(self) -> dict[str, Any]:
+ async def _async_update_data(self) -> T:
"""Update device data."""
_LOGGER.debug("Polling Comelit %s host: %s", self._device, self._host)
try:
await self.api.login()
return await self._async_update_system_data()
- except (exceptions.CannotConnect, exceptions.CannotRetrieveData) as err:
+ except (CannotConnect, CannotRetrieveData) as err:
raise UpdateFailed(repr(err)) from err
- except exceptions.CannotAuthenticate as err:
+ except CannotAuthenticate as err:
raise ConfigEntryAuthFailed from err
@abstractmethod
- async def _async_update_system_data(self) -> dict[str, Any]:
+ async def _async_update_system_data(self) -> T:
"""Class method for updating data."""
-class ComelitSerialBridge(ComelitBaseCoordinator):
+class ComelitSerialBridge(
+ ComelitBaseCoordinator[dict[str, dict[int, ComelitSerialBridgeObject]]]
+):
"""Queries Comelit Serial Bridge."""
_hw_version = "20003101"
@@ -115,12 +124,14 @@ class ComelitSerialBridge(ComelitBaseCoordinator):
self.api = ComeliteSerialBridgeApi(host, port, pin)
super().__init__(hass, entry, BRIDGE, host)
- async def _async_update_system_data(self) -> dict[str, Any]:
+ async def _async_update_system_data(
+ self,
+ ) -> dict[str, dict[int, ComelitSerialBridgeObject]]:
"""Specific method for updating data."""
return await self.api.get_all_devices()
-class ComelitVedoSystem(ComelitBaseCoordinator):
+class ComelitVedoSystem(ComelitBaseCoordinator[AlarmDataObject]):
"""Queries Comelit VEDO system."""
_hw_version = "VEDO IP"
@@ -138,6 +149,8 @@ class ComelitVedoSystem(ComelitBaseCoordinator):
self.api = ComelitVedoApi(host, port, pin)
super().__init__(hass, entry, VEDO, host)
- async def _async_update_system_data(self) -> dict[str, Any]:
+ async def _async_update_system_data(
+ self,
+ ) -> AlarmDataObject:
"""Specific method for updating data."""
return await self.api.get_all_areas_and_zones()
diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py
index abb84824621..d430952fabf 100644
--- a/homeassistant/components/comelit/cover.py
+++ b/homeassistant/components/comelit/cover.py
@@ -8,18 +8,21 @@ from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON
from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverState
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
+from .entity import ComelitBridgeBaseEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit covers."""
@@ -31,13 +34,10 @@ async def async_setup_entry(
)
-class ComelitCoverEntity(
- CoordinatorEntity[ComelitSerialBridge], RestoreEntity, CoverEntity
-):
+class ComelitCoverEntity(ComelitBridgeBaseEntity, RestoreEntity, CoverEntity):
"""Cover device."""
_attr_device_class = CoverDeviceClass.SHUTTER
- _attr_has_entity_name = True
_attr_name = None
def __init__(
@@ -47,13 +47,7 @@ class ComelitCoverEntity(
config_entry_entry_id: str,
) -> None:
"""Init cover entity."""
- self._api = coordinator.api
- self._device = device
- super().__init__(coordinator)
- # Use config_entry.entry_id as base for unique_id
- # because no serial number or mac is available
- self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
- self._attr_device_info = coordinator.platform_device_info(device, device.type)
+ super().__init__(coordinator, device, config_entry_entry_id)
# Device doesn't provide a status so we assume UNKNOWN at first startup
self._last_action: int | None = None
self._last_state: str | None = None
@@ -95,13 +89,20 @@ class ComelitCoverEntity(
"""Return if the cover is opening."""
return self._current_action("opening")
+ async def _cover_set_state(self, action: int, state: int) -> None:
+ """Set desired cover state."""
+ self._last_state = self.state
+ await self.coordinator.api.set_device_status(COVER, self._device.index, action)
+ self.coordinator.data[COVER][self._device.index].status = state
+ self.async_write_ha_state()
+
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
- await self._api.set_device_status(COVER, self._device.index, STATE_OFF)
+ await self._cover_set_state(STATE_OFF, 2)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open cover."""
- await self._api.set_device_status(COVER, self._device.index, STATE_ON)
+ await self._cover_set_state(STATE_ON, 1)
async def async_stop_cover(self, **_kwargs: Any) -> None:
"""Stop the cover."""
@@ -109,13 +110,7 @@ class ComelitCoverEntity(
return
action = STATE_ON if self.is_closing else STATE_OFF
- await self._api.set_device_status(COVER, self._device.index, action)
-
- @callback
- def _handle_coordinator_update(self) -> None:
- """Handle device update."""
- self._last_state = self.state
- self.async_write_ha_state()
+ await self._cover_set_state(action, 0)
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
diff --git a/homeassistant/components/comelit/entity.py b/homeassistant/components/comelit/entity.py
new file mode 100644
index 00000000000..409cd6a3f42
--- /dev/null
+++ b/homeassistant/components/comelit/entity.py
@@ -0,0 +1,29 @@
+"""Base entity for Comelit."""
+
+from __future__ import annotations
+
+from aiocomelit import ComelitSerialBridgeObject
+
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .coordinator import ComelitSerialBridge
+
+
+class ComelitBridgeBaseEntity(CoordinatorEntity[ComelitSerialBridge]):
+ """Comelit Bridge base entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: ComelitSerialBridge,
+ device: ComelitSerialBridgeObject,
+ config_entry_entry_id: str,
+ ) -> None:
+ """Init cover entity."""
+ self._device = device
+ super().__init__(coordinator)
+ # Use config_entry.entry_id as base for unique_id
+ # because no serial number or mac is available
+ self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
+ self._attr_device_info = coordinator.platform_device_info(device, device.type)
diff --git a/homeassistant/components/comelit/humidifier.py b/homeassistant/components/comelit/humidifier.py
index d8058074c16..816d5c6bb38 100644
--- a/homeassistant/components/comelit/humidifier.py
+++ b/homeassistant/components/comelit/humidifier.py
@@ -16,13 +16,16 @@ from homeassistant.components.humidifier import (
HumidifierEntity,
HumidifierEntityFeature,
)
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
+from .entity import ComelitBridgeBaseEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
class HumidifierComelitMode(StrEnum):
@@ -55,7 +58,7 @@ MODE_TO_ACTION: dict[str, HumidifierComelitCommand] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit humidifiers."""
@@ -89,14 +92,13 @@ async def async_setup_entry(
async_add_entities(entities)
-class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], HumidifierEntity):
+class ComelitHumidifierEntity(ComelitBridgeBaseEntity, HumidifierEntity):
"""Humidifier device."""
_attr_supported_features = HumidifierEntityFeature.MODES
_attr_available_modes = [MODE_NORMAL, MODE_AUTO]
_attr_min_humidity = 10
_attr_max_humidity = 90
- _attr_has_entity_name = True
def __init__(
self,
@@ -109,78 +111,52 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
device_class: HumidifierDeviceClass,
) -> None:
"""Init light entity."""
- self._api = coordinator.api
- self._device = device
- super().__init__(coordinator)
- # Use config_entry.entry_id as base for unique_id
- # because no serial number or mac is available
+ super().__init__(coordinator, device, config_entry_entry_id)
self._attr_unique_id = f"{config_entry_entry_id}-{device.index}-{device_class}"
- self._attr_device_info = coordinator.platform_device_info(device, device_class)
self._attr_device_class = device_class
self._attr_translation_key = device_class.value
self._active_mode = active_mode
self._active_action = active_action
self._set_command = set_command
+ self._update_attributes()
+
+ def _update_attributes(self) -> None:
+ """Update class attributes."""
+ device = self.coordinator.data[CLIMATE][self._device.index]
+ if not isinstance(device.val, list):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="invalid_clima_data"
+ )
- @property
- def _humidifier(self) -> list[Any]:
- """Return humidifier device data."""
# CLIMATE has a 2 item tuple:
# - first for Clima
# - second for Humidifier
- return self.coordinator.data[CLIMATE][self._device.index].val[1]
+ values = device.val[1]
- @property
- def _api_mode(self) -> str:
- """Return device mode."""
- # Values from API: "O", "L", "U"
- return self._humidifier[2]
+ _active = values[1]
+ _mode = values[2] # Values from API: "O", "L", "U"
+ _automatic = values[3] == HumidifierComelitMode.AUTO
- @property
- def _api_active(self) -> bool:
- "Return device active/idle."
- return self._humidifier[1]
+ self._attr_action = HumidifierAction.IDLE
+ if _mode == HumidifierComelitMode.OFF:
+ self._attr_action = HumidifierAction.OFF
+ if _active and _mode == self._active_mode:
+ self._attr_action = self._active_action
- @property
- def _api_automatic(self) -> bool:
- """Return device in automatic/manual mode."""
- return self._humidifier[3] == HumidifierComelitMode.AUTO
+ self._attr_current_humidity = values[0] / 10
+ self._attr_is_on = _mode == self._active_mode
+ self._attr_mode = MODE_AUTO if _automatic else MODE_NORMAL
+ self._attr_target_humidity = values[4] / 10
- @property
- def target_humidity(self) -> float:
- """Set target humidity."""
- return self._humidifier[4] / 10
-
- @property
- def current_humidity(self) -> float:
- """Return current humidity."""
- return self._humidifier[0] / 10
-
- @property
- def is_on(self) -> bool | None:
- """Return true is humidifier is on."""
- return self._api_mode == self._active_mode
-
- @property
- def mode(self) -> str | None:
- """Return current mode."""
- return MODE_AUTO if self._api_automatic else MODE_NORMAL
-
- @property
- def action(self) -> HumidifierAction | None:
- """Return current action."""
-
- if self._api_mode == HumidifierComelitMode.OFF:
- return HumidifierAction.OFF
-
- if self._api_active and self._api_mode == self._active_mode:
- return self._active_action
-
- return HumidifierAction.IDLE
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._update_attributes()
+ super()._handle_coordinator_update()
async def async_set_humidity(self, humidity: int) -> None:
"""Set new target humidity."""
- if self.mode == HumidifierComelitMode.OFF:
+ if not self._attr_is_on:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="humidity_while_off",
@@ -192,21 +168,29 @@ class ComelitHumidifierEntity(CoordinatorEntity[ComelitSerialBridge], Humidifier
await self.coordinator.api.set_humidity_status(
self._device.index, HumidifierComelitCommand.SET, humidity
)
+ self._attr_target_humidity = humidity
+ self.async_write_ha_state()
async def async_set_mode(self, mode: str) -> None:
"""Set humidifier mode."""
await self.coordinator.api.set_humidity_status(
self._device.index, MODE_TO_ACTION[mode]
)
+ self._attr_mode = mode
+ self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
await self.coordinator.api.set_humidity_status(
self._device.index, self._set_command
)
+ self._attr_is_on = True
+ self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off."""
await self.coordinator.api.set_humidity_status(
self._device.index, HumidifierComelitCommand.OFF
)
+ self._attr_is_on = False
+ self.async_write_ha_state()
diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py
index 9736c9ac2a0..27d9a8d57dd 100644
--- a/homeassistant/components/comelit/light.py
+++ b/homeassistant/components/comelit/light.py
@@ -4,21 +4,23 @@ from __future__ import annotations
from typing import Any, cast
-from aiocomelit import ComelitSerialBridgeObject
from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
+from .entity import ComelitBridgeBaseEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit lights."""
@@ -30,33 +32,18 @@ async def async_setup_entry(
)
-class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity):
+class ComelitLightEntity(ComelitBridgeBaseEntity, LightEntity):
"""Light device."""
_attr_color_mode = ColorMode.ONOFF
- _attr_has_entity_name = True
_attr_name = None
_attr_supported_color_modes = {ColorMode.ONOFF}
- def __init__(
- self,
- coordinator: ComelitSerialBridge,
- device: ComelitSerialBridgeObject,
- config_entry_entry_id: str,
- ) -> None:
- """Init light entity."""
- self._api = coordinator.api
- self._device = device
- super().__init__(coordinator)
- # Use config_entry.entry_id as base for unique_id
- # because no serial number or mac is available
- self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
- self._attr_device_info = coordinator.platform_device_info(device, device.type)
-
async def _light_set_state(self, state: int) -> None:
"""Set desired light state."""
await self.coordinator.api.set_device_status(LIGHT, self._device.index, state)
- await self.coordinator.async_request_refresh()
+ self.coordinator.data[LIGHT][self._device.index].status = state
+ self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json
index 238dede8546..303773ebc7d 100644
--- a/homeassistant/components/comelit/manifest.json
+++ b/homeassistant/components/comelit/manifest.json
@@ -7,5 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiocomelit"],
- "requirements": ["aiocomelit==0.10.1"]
+ "quality_scale": "bronze",
+ "requirements": ["aiocomelit==0.11.3"]
}
diff --git a/homeassistant/components/comelit/quality_scale.yaml b/homeassistant/components/comelit/quality_scale.yaml
new file mode 100644
index 00000000000..56922f175b9
--- /dev/null
+++ b/homeassistant/components/comelit/quality_scale.yaml
@@ -0,0 +1,92 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: no 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: no actions
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: no 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: todo
+ comment: wrap api calls in try block
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: no configuration parameters
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: device not discoverable
+ discovery:
+ status: exempt
+ comment: device not discoverable
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations:
+ status: exempt
+ comment: no known limitations, yet
+ docs-supported-devices:
+ status: todo
+ comment: review and complete missing ones
+ docs-supported-functions: todo
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: todo
+ comment: missing implementation
+ entity-category:
+ status: todo
+ comment: PR in progress
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations:
+ status: todo
+ comment: PR in progress
+ icon-translations: done
+ reconfiguration-flow:
+ status: todo
+ comment: PR in progress
+ repair-issues:
+ status: exempt
+ comment: no known use cases for repair issues or flows, yet
+ stale-devices:
+ status: todo
+ comment: missing implementation
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: todo
+ comment: implement aiohttp_client.async_create_clientsession
+ strict-typing: done
diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py
index efb2418244e..a11cac4e1c0 100644
--- a/homeassistant/components/comelit/sensor.py
+++ b/homeassistant/components/comelit/sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Final, cast
from aiocomelit import ComelitSerialBridgeObject, ComelitVedoZoneObject
-from aiocomelit.const import ALARM_ZONES, BRIDGE, OTHER, AlarmZoneState
+from aiocomelit.const import BRIDGE, OTHER, AlarmZoneState
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -14,11 +14,15 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_TYPE, UnitOfPower
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ComelitConfigEntry, ComelitSerialBridge, ComelitVedoSystem
+from .entity import ComelitBridgeBaseEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
SENSOR_BRIDGE_TYPES: Final = (
SensorEntityDescription(
@@ -42,7 +46,7 @@ SENSOR_VEDO_TYPES: Final = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit sensors."""
@@ -55,7 +59,7 @@ async def async_setup_entry(
async def async_setup_bridge_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit Bridge sensors."""
@@ -75,14 +79,14 @@ async def async_setup_bridge_entry(
async def async_setup_vedo_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit VEDO sensors."""
coordinator = cast(ComelitVedoSystem, config_entry.runtime_data)
entities: list[ComelitVedoSensorEntity] = []
- for device in coordinator.data[ALARM_ZONES].values():
+ for device in coordinator.data["alarm_zones"].values():
entities.extend(
ComelitVedoSensorEntity(
coordinator, device, config_entry.entry_id, sensor_desc
@@ -92,10 +96,9 @@ async def async_setup_vedo_entry(
async_add_entities(entities)
-class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity):
+class ComelitBridgeSensorEntity(ComelitBridgeBaseEntity, SensorEntity):
"""Sensor device."""
- _attr_has_entity_name = True
_attr_name = None
def __init__(
@@ -106,22 +109,19 @@ class ComelitBridgeSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEn
description: SensorEntityDescription,
) -> None:
"""Init sensor entity."""
- self._api = coordinator.api
- self._device = device
- super().__init__(coordinator)
- # Use config_entry.entry_id as base for unique_id
- # because no serial number or mac is available
- self._attr_unique_id = f"{config_entry_entry_id}-{device.index}"
- self._attr_device_info = coordinator.platform_device_info(device, device.type)
+ super().__init__(coordinator, device, config_entry_entry_id)
self.entity_description = description
@property
def native_value(self) -> StateType:
"""Sensor value."""
- return getattr(
- self.coordinator.data[OTHER][self._device.index],
- self.entity_description.key,
+ return cast(
+ StateType,
+ getattr(
+ self.coordinator.data[OTHER][self._device.index],
+ self.entity_description.key,
+ ),
)
@@ -138,8 +138,7 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
description: SensorEntityDescription,
) -> None:
"""Init sensor entity."""
- self._api = coordinator.api
- self._zone = zone
+ self._zone_index = zone.index
super().__init__(coordinator)
# Use config_entry.entry_id as base for unique_id
# because no serial number or mac is available
@@ -151,7 +150,7 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
@property
def _zone_object(self) -> ComelitVedoZoneObject:
"""Zone object."""
- return self.coordinator.data[ALARM_ZONES][self._zone.index]
+ return self.coordinator.data["alarm_zones"][self._zone_index]
@property
def available(self) -> bool:
@@ -164,4 +163,4 @@ class ComelitVedoSensorEntity(CoordinatorEntity[ComelitVedoSystem], SensorEntity
if (status := self._zone_object.human_status) == AlarmZoneState.UNKNOWN:
return None
- return status.value
+ return cast(str, status.value)
diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json
index 14d947c7323..55bae00e3d8 100644
--- a/homeassistant/components/comelit/strings.json
+++ b/homeassistant/components/comelit/strings.json
@@ -3,19 +3,25 @@
"flow_title": "{host}",
"step": {
"reauth_confirm": {
- "description": "Please enter the correct PIN for {host}",
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
+ },
+ "data_description": {
+ "pin": "The PIN of your Comelit device."
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
- "pin": "[%key:common::config_flow::data::pin%]"
+ "pin": "[%key:common::config_flow::data::pin%]",
+ "type": "Device type"
},
"data_description": {
- "host": "The hostname or IP address of your Comelit device."
+ "host": "The hostname or IP address of your Comelit device.",
+ "port": "The port of your Comelit device.",
+ "pin": "[%key:component::comelit::config::step::reauth_confirm::data_description::pin%]",
+ "type": "The type of your Comelit device."
}
}
},
@@ -36,9 +42,9 @@
"sensor": {
"zone_status": {
"state": {
+ "open": "[%key:common::state::open%]",
"alarm": "Alarm",
"armed": "Armed",
- "open": "Open",
"excluded": "Excluded",
"faulty": "Faulty",
"inhibited": "Inhibited",
@@ -46,7 +52,9 @@
"rest": "Rest",
"sabotated": "Sabotated"
}
- },
+ }
+ },
+ "humidifier": {
"humidifier": {
"name": "Humidifier"
},
@@ -58,6 +66,15 @@
"exceptions": {
"humidity_while_off": {
"message": "Cannot change humidity while off"
+ },
+ "invalid_clima_data": {
+ "message": "Invalid 'clima' data"
+ },
+ "cannot_connect": {
+ "message": "Error connecting: {error}"
+ },
+ "cannot_authenticate": {
+ "message": "Error authenticating: {error}"
}
}
}
diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py
index 26d3b81ebde..658f37f70af 100644
--- a/homeassistant/components/comelit/switch.py
+++ b/homeassistant/components/comelit/switch.py
@@ -9,16 +9,19 @@ from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ComelitConfigEntry, ComelitSerialBridge
+from .entity import ComelitBridgeBaseEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ComelitConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Comelit switches."""
@@ -36,10 +39,9 @@ async def async_setup_entry(
async_add_entities(entities)
-class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity):
+class ComelitSwitchEntity(ComelitBridgeBaseEntity, SwitchEntity):
"""Switch device."""
- _attr_has_entity_name = True
_attr_name = None
def __init__(
@@ -49,13 +51,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity):
config_entry_entry_id: str,
) -> None:
"""Init switch entity."""
- self._api = coordinator.api
- self._device = device
- super().__init__(coordinator)
- # Use config_entry.entry_id as base for unique_id
- # because no serial number or mac is available
+ super().__init__(coordinator, device, config_entry_entry_id)
self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}"
- self._attr_device_info = coordinator.platform_device_info(device, device.type)
if device.type == OTHER:
self._attr_device_class = SwitchDeviceClass.OUTLET
@@ -64,7 +61,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity):
await self.coordinator.api.set_device_status(
self._device.type, self._device.index, state
)
- await self.coordinator.async_request_refresh()
+ self.coordinator.data[self._device.type][self._device.index].status = state
+ self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py
index 52e3346002e..6e2d4a5da49 100644
--- a/homeassistant/components/config/config_entries.py
+++ b/homeassistant/components/config/config_entries.py
@@ -46,14 +46,25 @@ def async_setup(hass: HomeAssistant) -> bool:
hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options))
hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options))
+ hass.http.register_view(
+ SubentryManagerFlowIndexView(hass.config_entries.subentries)
+ )
+ hass.http.register_view(
+ SubentryManagerFlowResourceView(hass.config_entries.subentries)
+ )
+
websocket_api.async_register_command(hass, config_entries_get)
websocket_api.async_register_command(hass, config_entry_disable)
websocket_api.async_register_command(hass, config_entry_get_single)
websocket_api.async_register_command(hass, config_entry_update)
websocket_api.async_register_command(hass, config_entries_subscribe)
- websocket_api.async_register_command(hass, config_entries_progress)
+ websocket_api.async_register_command(hass, config_entries_flow_progress)
+ websocket_api.async_register_command(hass, config_entries_flow_subscribe)
websocket_api.async_register_command(hass, ignore_config_flow)
+ websocket_api.async_register_command(hass, config_subentry_delete)
+ websocket_api.async_register_command(hass, config_subentry_list)
+
return True
@@ -285,9 +296,69 @@ class OptionManagerFlowResourceView(
return await super().post(request, flow_id)
+class SubentryManagerFlowIndexView(
+ FlowManagerIndexView[config_entries.ConfigSubentryFlowManager]
+):
+ """View to create subentry flows."""
+
+ url = "/api/config/config_entries/subentries/flow"
+ name = "api:config:config_entries:subentries:flow"
+
+ @require_admin(
+ error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
+ )
+ @RequestDataValidator(
+ vol.Schema(
+ {
+ vol.Required("handler"): vol.All(vol.Coerce(tuple), (str, str)),
+ vol.Optional("show_advanced_options", default=False): cv.boolean,
+ },
+ extra=vol.ALLOW_EXTRA,
+ )
+ )
+ async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
+ """Handle a POST request.
+
+ handler in request is [entry_id, subentry_type].
+ """
+ return await super()._post_impl(request, data)
+
+ def get_context(self, data: dict[str, Any]) -> dict[str, Any]:
+ """Return context."""
+ context = super().get_context(data)
+ context["source"] = config_entries.SOURCE_USER
+ if subentry_id := data.get("subentry_id"):
+ context["source"] = config_entries.SOURCE_RECONFIGURE
+ context["subentry_id"] = subentry_id
+ return context
+
+
+class SubentryManagerFlowResourceView(
+ FlowManagerResourceView[config_entries.ConfigSubentryFlowManager]
+):
+ """View to interact with the subentry flow manager."""
+
+ url = "/api/config/config_entries/subentries/flow/{flow_id}"
+ name = "api:config:config_entries:subentries:flow:resource"
+
+ @require_admin(
+ error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
+ )
+ async def get(self, request: web.Request, /, flow_id: str) -> web.Response:
+ """Get the current state of a data_entry_flow."""
+ return await super().get(request, flow_id)
+
+ @require_admin(
+ error=Unauthorized(perm_category=CAT_CONFIG_ENTRIES, permission=POLICY_EDIT)
+ )
+ async def post(self, request: web.Request, flow_id: str) -> web.Response:
+ """Handle a POST request."""
+ return await super().post(request, flow_id)
+
+
@websocket_api.require_admin
@websocket_api.websocket_command({"type": "config_entries/flow/progress"})
-def config_entries_progress(
+def config_entries_flow_progress(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
@@ -308,6 +379,66 @@ def config_entries_progress(
)
+@websocket_api.require_admin
+@websocket_api.websocket_command({"type": "config_entries/flow/subscribe"})
+def config_entries_flow_subscribe(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Subscribe to non user created flows being initiated or removed.
+
+ When initiating the subscription, the current flows are sent to the client.
+
+ Example of a non-user initiated flow is a discovered Hue hub that
+ requires user interaction to finish setup.
+ """
+
+ @callback
+ def async_on_flow_init_remove(change_type: str, flow_id: str) -> None:
+ """Forward config entry state events to websocket."""
+ if change_type == "removed":
+ connection.send_message(
+ websocket_api.event_message(
+ msg["id"],
+ [{"type": change_type, "flow_id": flow_id}],
+ )
+ )
+ return
+ # change_type == "added"
+ connection.send_message(
+ websocket_api.event_message(
+ msg["id"],
+ [
+ {
+ "type": change_type,
+ "flow_id": flow_id,
+ "flow": hass.config_entries.flow.async_get(flow_id),
+ }
+ ],
+ )
+ )
+
+ connection.subscriptions[msg["id"]] = hass.config_entries.flow.async_subscribe_flow(
+ async_on_flow_init_remove
+ )
+ connection.send_message(
+ websocket_api.event_message(
+ msg["id"],
+ [
+ {"type": None, "flow_id": flw["flow_id"], "flow": flw}
+ for flw in hass.config_entries.flow.async_progress()
+ if flw["context"]["source"]
+ not in (
+ config_entries.SOURCE_RECONFIGURE,
+ config_entries.SOURCE_USER,
+ )
+ ],
+ )
+ )
+ connection.send_result(msg["id"])
+
+
def send_entry_not_found(
connection: websocket_api.ActiveConnection, msg_id: int
) -> None:
@@ -589,3 +720,63 @@ async def _async_matching_config_entries_json_fragments(
)
or (filter_is_not_helper and entry.domain not in integrations)
]
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ "type": "config_entries/subentries/list",
+ "entry_id": str,
+ }
+)
+@websocket_api.async_response
+async def config_subentry_list(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """List subentries of a config entry."""
+ entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
+ if entry is None:
+ return
+
+ result = [
+ {
+ "subentry_id": subentry.subentry_id,
+ "subentry_type": subentry.subentry_type,
+ "title": subentry.title,
+ "unique_id": subentry.unique_id,
+ }
+ for subentry in entry.subentries.values()
+ ]
+ connection.send_result(msg["id"], result)
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ "type": "config_entries/subentries/delete",
+ "entry_id": str,
+ "subentry_id": str,
+ }
+)
+@websocket_api.async_response
+async def config_subentry_delete(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Delete a subentry of a config entry."""
+ entry = get_entry(hass, connection, msg["entry_id"], msg["id"])
+ if entry is None:
+ return
+
+ try:
+ hass.config_entries.async_remove_subentry(entry, msg["subentry_id"])
+ except config_entries.UnknownSubEntry:
+ connection.send_error(
+ msg["id"], websocket_api.const.ERR_NOT_FOUND, "Config subentry not found"
+ )
+ return
+
+ connection.send_result(msg["id"])
diff --git a/homeassistant/components/constructa/__init__.py b/homeassistant/components/constructa/__init__.py
new file mode 100644
index 00000000000..1b3870860a0
--- /dev/null
+++ b/homeassistant/components/constructa/__init__.py
@@ -0,0 +1 @@
+"""Constructa virtual integration."""
diff --git a/homeassistant/components/constructa/manifest.json b/homeassistant/components/constructa/manifest.json
new file mode 100644
index 00000000000..7b73f2e2ed0
--- /dev/null
+++ b/homeassistant/components/constructa/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "constructa",
+ "name": "Constructa",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+}
diff --git a/homeassistant/components/control4/light.py b/homeassistant/components/control4/light.py
index cedfbeb49c3..d2d0f85f476 100644
--- a/homeassistant/components/control4/light.py
+++ b/homeassistant/components/control4/light.py
@@ -18,7 +18,7 @@ from homeassistant.components.light import (
LightEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from . import Control4ConfigEntry, Control4RuntimeData, get_items_of_category
@@ -36,7 +36,7 @@ CONTROL4_DIMMER_VARS = ["LIGHT_LEVEL", "Brightness Percent"]
async def async_setup_entry(
hass: HomeAssistant,
entry: Control4ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Control4 lights from a config entry."""
runtime_data = entry.runtime_data
diff --git a/homeassistant/components/control4/media_player.py b/homeassistant/components/control4/media_player.py
index bd8e3fb38fe..824ce431aea 100644
--- a/homeassistant/components/control4/media_player.py
+++ b/homeassistant/components/control4/media_player.py
@@ -19,7 +19,7 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from . import Control4ConfigEntry, Control4RuntimeData
@@ -77,7 +77,7 @@ async def get_rooms(hass: HomeAssistant, entry: Control4ConfigEntry):
async def async_setup_entry(
hass: HomeAssistant,
entry: Control4ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Control4 rooms from a config entry."""
runtime_data = entry.runtime_data
diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py
index 11de75801ba..25aaf6df290 100644
--- a/homeassistant/components/conversation/__init__.py
+++ b/homeassistant/components/conversation/__init__.py
@@ -2,10 +2,11 @@
from __future__ import annotations
+from collections.abc import Callable
import logging
-import re
from typing import Literal
+from hassil.recognize import RecognizeResult
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -89,8 +90,6 @@ __all__ = [
_LOGGER = logging.getLogger(__name__)
-REGEX_TYPE = type(re.compile(""))
-
SERVICE_PROCESS_SCHEMA = vol.Schema(
{
vol.Required(ATTR_TEXT): cv.string,
@@ -241,7 +240,10 @@ async def async_handle_sentence_triggers(
async def async_handle_intents(
- hass: HomeAssistant, user_input: ConversationInput
+ hass: HomeAssistant,
+ user_input: ConversationInput,
+ *,
+ intent_filter: Callable[[RecognizeResult], bool] | None = None,
) -> intent.IntentResponse | None:
"""Try to match input against registered intents and return response.
@@ -250,7 +252,9 @@ async def async_handle_intents(
default_agent = async_get_agent(hass)
assert isinstance(default_agent, DefaultAgent)
- return await default_agent.async_handle_intents(user_input)
+ return await default_agent.async_handle_intents(
+ user_input, intent_filter=intent_filter
+ )
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py
index 5dbd19ba275..c78f41f3c5c 100644
--- a/homeassistant/components/conversation/chat_log.py
+++ b/homeassistant/components/conversation/chat_log.py
@@ -3,12 +3,12 @@
from __future__ import annotations
import asyncio
-from collections.abc import AsyncGenerator, AsyncIterable, Generator
+from collections.abc import AsyncGenerator, AsyncIterable, Callable, Generator
from contextlib import contextmanager
from contextvars import ContextVar
-from dataclasses import dataclass, field, replace
+from dataclasses import asdict, dataclass, field, replace
import logging
-from typing import Literal, TypedDict
+from typing import Any, Literal, TypedDict
import voluptuous as vol
@@ -36,27 +36,41 @@ def async_get_chat_log(
hass: HomeAssistant,
session: chat_session.ChatSession,
user_input: ConversationInput | None = None,
+ *,
+ chat_log_delta_listener: Callable[[ChatLog, dict], None] | None = None,
) -> Generator[ChatLog]:
"""Return chat log for a specific chat session."""
- if chat_log := current_chat_log.get():
- # If a chat log is already active and it's the requested conversation ID,
- # return that. We won't update the last updated time in this case.
- if chat_log.conversation_id == session.conversation_id:
- yield chat_log
- return
+ # If a chat log is already active and it's the requested conversation ID,
+ # return that. We won't update the last updated time in this case.
+ if (
+ chat_log := current_chat_log.get()
+ ) and chat_log.conversation_id == session.conversation_id:
+ if chat_log_delta_listener is not None:
+ raise RuntimeError(
+ "Cannot attach chat log delta listener unless initial caller"
+ )
+ if user_input is not None and (
+ (content := chat_log.content[-1]).role != "user"
+ or content.content != user_input.text
+ ):
+ chat_log.async_add_user_content(UserContent(content=user_input.text))
+
+ yield chat_log
+ return
all_chat_logs = hass.data.get(DATA_CHAT_LOGS)
if all_chat_logs is None:
all_chat_logs = {}
hass.data[DATA_CHAT_LOGS] = all_chat_logs
- chat_log = all_chat_logs.get(session.conversation_id)
-
- if chat_log:
+ if chat_log := all_chat_logs.get(session.conversation_id):
chat_log = replace(chat_log, content=chat_log.content.copy())
else:
chat_log = ChatLog(hass, session.conversation_id)
+ if chat_log_delta_listener:
+ chat_log.delta_listener = chat_log_delta_listener
+
if user_input is not None:
chat_log.async_add_user_content(UserContent(content=user_input.text))
@@ -81,6 +95,9 @@ def async_get_chat_log(
session.async_on_cleanup(do_cleanup)
+ if chat_log_delta_listener:
+ chat_log.delta_listener = None
+
all_chat_logs[session.conversation_id] = chat_log
@@ -110,7 +127,7 @@ class ConverseError(HomeAssistantError):
class SystemContent:
"""Base class for chat messages."""
- role: str = field(init=False, default="system")
+ role: Literal["system"] = field(init=False, default="system")
content: str
@@ -118,7 +135,7 @@ class SystemContent:
class UserContent:
"""Assistant content."""
- role: str = field(init=False, default="user")
+ role: Literal["user"] = field(init=False, default="user")
content: str
@@ -126,7 +143,7 @@ class UserContent:
class AssistantContent:
"""Assistant content."""
- role: str = field(init=False, default="assistant")
+ role: Literal["assistant"] = field(init=False, default="assistant")
agent_id: str
content: str | None = None
tool_calls: list[llm.ToolInput] | None = None
@@ -136,7 +153,7 @@ class AssistantContent:
class ToolResultContent:
"""Tool result content."""
- role: str = field(init=False, default="tool_result")
+ role: Literal["tool_result"] = field(init=False, default="tool_result")
agent_id: str
tool_call_id: str
tool_name: str
@@ -163,6 +180,27 @@ class ChatLog:
content: list[Content] = field(default_factory=lambda: [SystemContent(content="")])
extra_system_prompt: str | None = None
llm_api: llm.APIInstance | None = None
+ delta_listener: Callable[[ChatLog, dict], None] | None = None
+
+ @property
+ def continue_conversation(self) -> bool:
+ """Return whether the conversation should continue."""
+ if not self.content:
+ return False
+
+ last_msg = self.content[-1]
+
+ return (
+ last_msg.role == "assistant"
+ and last_msg.content is not None
+ and last_msg.content.strip().endswith(
+ (
+ "?",
+ ";", # Greek question mark
+ "?", # Chinese question mark
+ )
+ )
+ )
@property
def unresponded_tool_results(self) -> bool:
@@ -273,6 +311,8 @@ class ChatLog:
self.llm_api.async_call_tool(tool_call),
name=f"llm_tool_{tool_call.id}",
)
+ if self.delta_listener:
+ self.delta_listener(self, delta) # type: ignore[arg-type]
continue
# Starting a new message
@@ -292,10 +332,15 @@ class ChatLog:
content, tool_call_tasks=tool_call_tasks
):
yield tool_result
+ if self.delta_listener:
+ self.delta_listener(self, asdict(tool_result))
current_content = delta.get("content") or ""
current_tool_calls = delta.get("tool_calls") or []
+ if self.delta_listener:
+ self.delta_listener(self, delta) # type: ignore[arg-type]
+
if current_content or current_tool_calls:
content = AssistantContent(
agent_id=agent_id,
@@ -307,12 +352,43 @@ class ChatLog:
content, tool_call_tasks=tool_call_tasks
):
yield tool_result
+ if self.delta_listener:
+ self.delta_listener(self, asdict(tool_result))
+
+ async def _async_expand_prompt_template(
+ self,
+ llm_context: llm.LLMContext,
+ prompt: str,
+ language: str,
+ user_name: str | None = None,
+ ) -> str:
+ try:
+ return template.Template(prompt, self.hass).async_render(
+ {
+ "ha_name": self.hass.config.location_name,
+ "user_name": user_name,
+ "llm_context": llm_context,
+ },
+ parse_result=False,
+ )
+ except TemplateError as err:
+ LOGGER.error("Error rendering prompt: %s", err)
+ intent_response = intent.IntentResponse(language=language)
+ intent_response.async_set_error(
+ intent.IntentResponseErrorCode.UNKNOWN,
+ "Sorry, I had a problem with my template",
+ )
+ raise ConverseError(
+ "Error rendering prompt",
+ conversation_id=self.conversation_id,
+ response=intent_response,
+ ) from err
async def async_update_llm_data(
self,
conversing_domain: str,
user_input: ConversationInput,
- user_llm_hass_api: str | None = None,
+ user_llm_hass_api: str | list[str] | None = None,
user_llm_prompt: str | None = None,
) -> None:
"""Set the LLM system prompt."""
@@ -363,44 +439,32 @@ class ChatLog:
):
user_name = user.name
- try:
- prompt_parts = [
- template.Template(
- llm.BASE_PROMPT
- + (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
- self.hass,
- ).async_render(
- {
- "ha_name": self.hass.config.location_name,
- "user_name": user_name,
- "llm_context": llm_context,
- },
- parse_result=False,
- )
- ]
-
- except TemplateError as err:
- LOGGER.error("Error rendering prompt: %s", err)
- intent_response = intent.IntentResponse(language=user_input.language)
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- "Sorry, I had a problem with my template",
+ prompt_parts = []
+ prompt_parts.append(
+ await self._async_expand_prompt_template(
+ llm_context,
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
+ user_input.language,
+ user_name,
)
- raise ConverseError(
- "Error rendering prompt",
- conversation_id=self.conversation_id,
- response=intent_response,
- ) from err
+ )
if llm_api:
prompt_parts.append(llm_api.api_prompt)
- extra_system_prompt = (
- # Take new system prompt if one was given
- user_input.extra_system_prompt or self.extra_system_prompt
+ prompt_parts.append(
+ await self._async_expand_prompt_template(
+ llm_context,
+ llm.BASE_PROMPT,
+ user_input.language,
+ user_name,
+ )
)
- if extra_system_prompt:
+ if extra_system_prompt := (
+ # Take new system prompt if one was given
+ user_input.extra_system_prompt or self.extra_system_prompt
+ ):
prompt_parts.append(extra_system_prompt)
prompt = "\n".join(prompt_parts)
@@ -412,10 +476,16 @@ class ChatLog:
LOGGER.debug("Prompt: %s", self.content)
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
- trace.async_conversation_trace_append(
- trace.ConversationTraceEventType.AGENT_DETAIL,
+ self.async_trace(
{
"messages": self.content,
"tools": self.llm_api.tools if self.llm_api else None,
- },
+ }
+ )
+
+ def async_trace(self, agent_details: dict[str, Any]) -> None:
+ """Append agent specific details to the conversation trace."""
+ trace.async_conversation_trace_append(
+ trace.ConversationTraceEventType.AGENT_DETAIL,
+ agent_details,
)
diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py
index 23c201d7579..bed4b4c0dd6 100644
--- a/homeassistant/components/conversation/default_agent.py
+++ b/homeassistant/components/conversation/default_agent.py
@@ -42,7 +42,6 @@ from homeassistant.components.homeassistant.exposed_entities import (
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
from homeassistant.helpers import (
area_registry as ar,
- chat_session,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
@@ -53,9 +52,10 @@ from homeassistant.helpers import (
)
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_added_domain
+from homeassistant.util import language as language_util
from homeassistant.util.json import JsonObjectType, json_loads_object
-from .chat_log import AssistantContent, async_get_chat_log
+from .chat_log import AssistantContent, ChatLog
from .const import (
DATA_DEFAULT_ENTITY,
DEFAULT_EXPOSED_ATTRIBUTES,
@@ -184,21 +184,6 @@ class IntentCache:
self.cache.clear()
-def _get_language_variations(language: str) -> Iterable[str]:
- """Generate language codes with and without region."""
- yield language
-
- parts = re.split(r"([-_])", language)
- if len(parts) == 3:
- lang, sep, region = parts
- if sep == "_":
- # en_US -> en-US
- yield f"{lang}-{region}"
-
- # en-US -> en
- yield lang
-
-
async def async_setup_default_agent(
hass: core.HomeAssistant,
entity_component: EntityComponent[ConversationEntity],
@@ -346,49 +331,46 @@ class DefaultAgent(ConversationEntity):
return result
- async def async_process(self, user_input: ConversationInput) -> ConversationResult:
- """Process a sentence."""
+ async def _async_handle_message(
+ self,
+ user_input: ConversationInput,
+ chat_log: ChatLog,
+ ) -> ConversationResult:
+ """Handle a message."""
response: intent.IntentResponse | None = None
- with (
- chat_session.async_get_chat_session(
- self.hass, user_input.conversation_id
- ) as session,
- async_get_chat_log(self.hass, session, user_input) as chat_log,
- ):
- # Check if a trigger matched
- if trigger_result := await self.async_recognize_sentence_trigger(
- user_input
- ):
- # Process callbacks and get response
- response_text = await self._handle_trigger_result(
- trigger_result, user_input
- )
- # Convert to conversation result
- response = intent.IntentResponse(
- language=user_input.language or self.hass.config.language
- )
- response.response_type = intent.IntentResponseType.ACTION_DONE
- response.async_set_speech(response_text)
-
- if response is None:
- # Match intents
- intent_result = await self.async_recognize_intent(user_input)
- response = await self._async_process_intent_result(
- intent_result, user_input
- )
-
- speech: str = response.speech.get("plain", {}).get("speech", "")
- chat_log.async_add_assistant_content_without_tools(
- AssistantContent(
- agent_id=user_input.agent_id,
- content=speech,
- )
+ # Check if a trigger matched
+ if trigger_result := await self.async_recognize_sentence_trigger(user_input):
+ # Process callbacks and get response
+ response_text = await self._handle_trigger_result(
+ trigger_result, user_input
)
- return ConversationResult(
- response=response, conversation_id=session.conversation_id
+ # Convert to conversation result
+ response = intent.IntentResponse(
+ language=user_input.language or self.hass.config.language
)
+ response.response_type = intent.IntentResponseType.ACTION_DONE
+ response.async_set_speech(response_text)
+
+ if response is None:
+ # Match intents
+ intent_result = await self.async_recognize_intent(user_input)
+ response = await self._async_process_intent_result(
+ intent_result, user_input
+ )
+
+ speech: str = response.speech.get("plain", {}).get("speech", "")
+ chat_log.async_add_assistant_content_without_tools(
+ AssistantContent(
+ agent_id=user_input.agent_id,
+ content=speech,
+ )
+ )
+
+ return ConversationResult(
+ response=response, conversation_id=chat_log.conversation_id
+ )
async def _async_process_intent_result(
self,
@@ -668,7 +650,14 @@ class DefaultAgent(ConversationEntity):
if (
(maybe_result is None) # first result
- or (num_matched_entities > best_num_matched_entities)
+ or (
+ # More literal text matched
+ result.text_chunks_matched > maybe_result.text_chunks_matched
+ )
+ or (
+ # More entities matched
+ num_matched_entities > best_num_matched_entities
+ )
or (
# Fewer unmatched entities
(num_matched_entities == best_num_matched_entities)
@@ -680,16 +669,6 @@ class DefaultAgent(ConversationEntity):
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
- or (
- # More literal text matched
- (num_matched_entities == best_num_matched_entities)
- and (num_unmatched_entities == best_num_unmatched_entities)
- and (num_unmatched_ranges == best_num_unmatched_ranges)
- and (
- result.text_chunks_matched
- > maybe_result.text_chunks_matched
- )
- )
or (
# Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched)
@@ -914,26 +893,20 @@ class DefaultAgent(ConversationEntity):
def _load_intents(self, language: str) -> LanguageIntents | None:
"""Load all intents for language (run inside executor)."""
intents_dict: dict[str, Any] = {}
- language_variant: str | None = None
supported_langs = set(get_languages())
# Choose a language variant upfront and commit to it for custom
# sentences, etc.
- all_language_variants = {lang.lower(): lang for lang in supported_langs}
+ lang_matches = language_util.matches(language, supported_langs)
- # en-US, en_US, en, ...
- for maybe_variant in _get_language_variations(language):
- matching_variant = all_language_variants.get(maybe_variant.lower())
- if matching_variant:
- language_variant = matching_variant
- break
-
- if not language_variant:
+ if not lang_matches:
_LOGGER.warning(
"Unable to find supported language variant for %s", language
)
return None
+ language_variant = lang_matches[0]
+
# Load intents for this language variant
lang_variant_intents = get_intents(language_variant, json_load=json_load)
@@ -1329,6 +1302,8 @@ class DefaultAgent(ConversationEntity):
async def async_handle_intents(
self,
user_input: ConversationInput,
+ *,
+ intent_filter: Callable[[RecognizeResult], bool] | None = None,
) -> intent.IntentResponse | None:
"""Try to match sentence against registered intents and return response.
@@ -1336,7 +1311,9 @@ class DefaultAgent(ConversationEntity):
Returns None if no match or a matching error occurred.
"""
result = await self.async_recognize_intent(user_input, strict_intents_only=True)
- if not isinstance(result, RecognizeResult):
+ if not isinstance(result, RecognizeResult) or (
+ intent_filter is not None and intent_filter(result)
+ ):
# No error message on failed match
return None
diff --git a/homeassistant/components/conversation/entity.py b/homeassistant/components/conversation/entity.py
index d9598dee7eb..ca4d18ab9f5 100644
--- a/homeassistant/components/conversation/entity.py
+++ b/homeassistant/components/conversation/entity.py
@@ -4,9 +4,11 @@ from abc import abstractmethod
from typing import Literal, final
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
+from homeassistant.helpers.chat_session import async_get_chat_session
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util import dt as dt_util
+from .chat_log import ChatLog, async_get_chat_log
from .const import ConversationEntityFeature
from .models import ConversationInput, ConversationResult
@@ -51,9 +53,21 @@ class ConversationEntity(RestoreEntity):
def supported_languages(self) -> list[str] | Literal["*"]:
"""Return a list of supported languages."""
- @abstractmethod
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
"""Process a sentence."""
+ with (
+ async_get_chat_session(self.hass, user_input.conversation_id) as session,
+ async_get_chat_log(self.hass, session, user_input) as chat_log,
+ ):
+ return await self._async_handle_message(user_input, chat_log)
+
+ async def _async_handle_message(
+ self,
+ user_input: ConversationInput,
+ chat_log: ChatLog,
+ ) -> ConversationResult:
+ """Call the API."""
+ raise NotImplementedError
async def async_prepare(self, language: str | None = None) -> None:
"""Load intents for a language."""
diff --git a/homeassistant/components/conversation/http.py b/homeassistant/components/conversation/http.py
index 4d8526a4fd4..efcdcb8d69b 100644
--- a/homeassistant/components/conversation/http.py
+++ b/homeassistant/components/conversation/http.py
@@ -3,11 +3,13 @@
from __future__ import annotations
from collections.abc import Iterable
+from dataclasses import asdict
from typing import Any
from aiohttp import web
from hassil.recognize import MISSING_ENTITY, RecognizeResult
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
+from home_assistant_intents import get_language_scores
import voluptuous as vol
from homeassistant.components import http, websocket_api
@@ -38,6 +40,7 @@ def async_setup(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_list_agents)
websocket_api.async_register_command(hass, websocket_list_sentences)
websocket_api.async_register_command(hass, websocket_hass_agent_debug)
+ websocket_api.async_register_command(hass, websocket_hass_agent_language_scores)
@websocket_api.websocket_command(
@@ -336,6 +339,36 @@ def _get_unmatched_slots(
return unmatched_slots
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "conversation/agent/homeassistant/language_scores",
+ vol.Optional("language"): str,
+ vol.Optional("country"): str,
+ }
+)
+@websocket_api.async_response
+async def websocket_hass_agent_language_scores(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Get support scores per language."""
+ language = msg.get("language", hass.config.language)
+ country = msg.get("country", hass.config.country)
+
+ scores = await hass.async_add_executor_job(get_language_scores)
+ matching_langs = language_util.matches(language, scores.keys(), country=country)
+ preferred_lang = matching_langs[0] if matching_langs else language
+ result = {
+ "languages": {
+ lang_key: asdict(lang_scores) for lang_key, lang_scores in scores.items()
+ },
+ "preferred_language": preferred_lang,
+ }
+
+ connection.send_result(msg["id"], result)
+
+
class ConversationProcessView(http.HomeAssistantView):
"""View to process text."""
diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json
index 2d4a8053d75..a1281764bd5 100644
--- a/homeassistant/components/conversation/manifest.json
+++ b/homeassistant/components/conversation/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.2.5"]
+ "requirements": ["hassil==2.2.3", "home-assistant-intents==2025.3.28"]
}
diff --git a/homeassistant/components/conversation/models.py b/homeassistant/components/conversation/models.py
index 08a68fa0164..7bdd13afc01 100644
--- a/homeassistant/components/conversation/models.py
+++ b/homeassistant/components/conversation/models.py
@@ -62,12 +62,14 @@ class ConversationResult:
response: intent.IntentResponse
conversation_id: str | None = None
+ continue_conversation: bool = False
def as_dict(self) -> dict[str, Any]:
"""Return result as a dict."""
return {
"response": self.response.as_dict(),
"conversation_id": self.conversation_id,
+ "continue_conversation": self.continue_conversation,
}
diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py
deleted file mode 100644
index 4326c95cb66..00000000000
--- a/homeassistant/components/conversation/util.py
+++ /dev/null
@@ -1,37 +0,0 @@
-"""Util for Conversation."""
-
-from __future__ import annotations
-
-import re
-
-
-def create_matcher(utterance: str) -> re.Pattern[str]:
- """Create a regex that matches the utterance."""
- # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL
- # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name}
- parts = re.split(r"({\w+}|\[[\w\s]+\] *)", utterance)
- # Pattern to extract name from GROUP part. Matches {name}
- group_matcher = re.compile(r"{(\w+)}")
- # Pattern to extract text from OPTIONAL part. Matches [the color]
- optional_matcher = re.compile(r"\[([\w ]+)\] *")
-
- pattern = ["^"]
- for part in parts:
- group_match = group_matcher.match(part)
- optional_match = optional_matcher.match(part)
-
- # Normal part
- if group_match is None and optional_match is None:
- pattern.append(part)
- continue
-
- # Group part
- if group_match is not None:
- pattern.append(rf"(?P<{group_match.groups()[0]}>[\w ]+?)\s*")
-
- # Optional part
- elif optional_match is not None:
- pattern.append(rf"(?:{optional_match.groups()[0]} *)?")
-
- pattern.append("$")
- return re.compile("".join(pattern), re.IGNORECASE)
diff --git a/homeassistant/components/cookidoo/button.py b/homeassistant/components/cookidoo/button.py
index b292a7309ba..97136deb031 100644
--- a/homeassistant/components/cookidoo/button.py
+++ b/homeassistant/components/cookidoo/button.py
@@ -8,7 +8,7 @@ from cookidoo_api import Cookidoo, CookidooException
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
@@ -35,7 +35,7 @@ TODO_CLEAR = CookidooButtonEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: CookidooConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Cookidoo button entities based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/cookidoo/sensor.py b/homeassistant/components/cookidoo/sensor.py
index 7fbacea18bc..6df41383a75 100644
--- a/homeassistant/components/cookidoo/sensor.py
+++ b/homeassistant/components/cookidoo/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
@@ -73,7 +73,7 @@ SENSOR_DESCRIPTIONS: tuple[CookidooSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: CookidooConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/cookidoo/strings.json b/homeassistant/components/cookidoo/strings.json
index ae384fb6635..52f99133546 100644
--- a/homeassistant/components/cookidoo/strings.json
+++ b/homeassistant/components/cookidoo/strings.json
@@ -6,7 +6,7 @@
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]",
- "country": "Country"
+ "country": "[%key:common::config_flow::data::country%]"
},
"data_description": {
"email": "Email used to access your {cookidoo} account.",
diff --git a/homeassistant/components/cookidoo/todo.py b/homeassistant/components/cookidoo/todo.py
index 3d5264f4e01..c577b845657 100644
--- a/homeassistant/components/cookidoo/todo.py
+++ b/homeassistant/components/cookidoo/todo.py
@@ -18,7 +18,7 @@ from homeassistant.components.todo import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import CookidooConfigEntry, CookidooDataUpdateCoordinator
@@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: CookidooConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the todo list from a config entry created in the integrations UI."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/coolmaster/binary_sensor.py b/homeassistant/components/coolmaster/binary_sensor.py
index ab2718b9352..5c1f19fd14c 100644
--- a/homeassistant/components/coolmaster/binary_sensor.py
+++ b/homeassistant/components/coolmaster/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CoolmasterConfigEntry
from .entity import CoolmasterEntity
@@ -18,7 +18,7 @@ from .entity import CoolmasterEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: CoolmasterConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the CoolMasterNet binary_sensor platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/coolmaster/button.py b/homeassistant/components/coolmaster/button.py
index 5463566d1ef..7cc8fc56c80 100644
--- a/homeassistant/components/coolmaster/button.py
+++ b/homeassistant/components/coolmaster/button.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CoolmasterConfigEntry
from .entity import CoolmasterEntity
@@ -14,7 +14,7 @@ from .entity import CoolmasterEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: CoolmasterConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the CoolMasterNet button platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py
index cd1659e1666..52fdfaaca3f 100644
--- a/homeassistant/components/coolmaster/climate.py
+++ b/homeassistant/components/coolmaster/climate.py
@@ -15,7 +15,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_SUPPORTED_MODES
from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
@@ -39,7 +39,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: CoolmasterConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the CoolMasterNet climate platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/coolmaster/sensor.py b/homeassistant/components/coolmaster/sensor.py
index 2b835565bae..32dceb83c5f 100644
--- a/homeassistant/components/coolmaster/sensor.py
+++ b/homeassistant/components/coolmaster/sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CoolmasterConfigEntry
from .entity import CoolmasterEntity
@@ -14,7 +14,7 @@ from .entity import CoolmasterEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: CoolmasterConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the CoolMasterNet sensor platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/cover/reproduce_state.py b/homeassistant/components/cover/reproduce_state.py
index 307fe5f11bd..927e725460c 100644
--- a/homeassistant/components/cover/reproduce_state.py
+++ b/homeassistant/components/cover/reproduce_state.py
@@ -3,12 +3,14 @@
from __future__ import annotations
import asyncio
-from collections.abc import Iterable
+from collections.abc import Coroutine, Iterable
+from functools import partial
import logging
-from typing import Any
+from typing import Any, Final
from homeassistant.const import (
ATTR_ENTITY_ID,
+ ATTR_SUPPORTED_FEATURES,
SERVICE_CLOSE_COVER,
SERVICE_CLOSE_COVER_TILT,
SERVICE_OPEN_COVER,
@@ -16,7 +18,8 @@ from homeassistant.const import (
SERVICE_SET_COVER_POSITION,
SERVICE_SET_COVER_TILT_POSITION,
)
-from homeassistant.core import Context, HomeAssistant, State
+from homeassistant.core import Context, HomeAssistant, ServiceResponse, State
+from homeassistant.util.enum import try_parse_enum
from . import (
ATTR_CURRENT_POSITION,
@@ -24,17 +27,142 @@ from . import (
ATTR_POSITION,
ATTR_TILT_POSITION,
DOMAIN,
+ CoverEntityFeature,
CoverState,
)
_LOGGER = logging.getLogger(__name__)
-VALID_STATES = {
- CoverState.CLOSED,
- CoverState.CLOSING,
- CoverState.OPEN,
- CoverState.OPENING,
-}
+
+OPENING_STATES = {CoverState.OPENING, CoverState.OPEN}
+CLOSING_STATES = {CoverState.CLOSING, CoverState.CLOSED}
+VALID_STATES: set[CoverState] = OPENING_STATES | CLOSING_STATES
+
+FULL_OPEN: Final = 100
+FULL_CLOSE: Final = 0
+
+
+def _determine_features(current_attrs: dict[str, Any]) -> CoverEntityFeature:
+ """Determine supported features based on current attributes."""
+ features = CoverEntityFeature(0)
+ if ATTR_CURRENT_POSITION in current_attrs:
+ features |= (
+ CoverEntityFeature.SET_POSITION
+ | CoverEntityFeature.OPEN
+ | CoverEntityFeature.CLOSE
+ )
+ if ATTR_CURRENT_TILT_POSITION in current_attrs:
+ features |= (
+ CoverEntityFeature.SET_TILT_POSITION
+ | CoverEntityFeature.OPEN_TILT
+ | CoverEntityFeature.CLOSE_TILT
+ )
+ if features == CoverEntityFeature(0):
+ features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
+ return features
+
+
+async def _async_set_position(
+ service_call: partial[Coroutine[Any, Any, ServiceResponse]],
+ service_data: dict[str, Any],
+ features: CoverEntityFeature,
+ target_position: int,
+) -> bool:
+ """Set the position of the cover.
+
+ Returns True if the position was set, False if there is no
+ supported method for setting the position.
+ """
+ if CoverEntityFeature.SET_POSITION in features:
+ await service_call(
+ SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: target_position}
+ )
+ elif target_position == FULL_CLOSE and CoverEntityFeature.CLOSE in features:
+ await service_call(SERVICE_CLOSE_COVER, service_data)
+ elif target_position == FULL_OPEN and CoverEntityFeature.OPEN in features:
+ await service_call(SERVICE_OPEN_COVER, service_data)
+ else:
+ # Requested a position but the cover doesn't support it
+ return False
+ return True
+
+
+async def _async_set_tilt_position(
+ service_call: partial[Coroutine[Any, Any, ServiceResponse]],
+ service_data: dict[str, Any],
+ features: CoverEntityFeature,
+ target_tilt_position: int,
+) -> bool:
+ """Set the tilt position of the cover.
+
+ Returns True if the tilt position was set, False if there is no
+ supported method for setting the tilt position.
+ """
+ if CoverEntityFeature.SET_TILT_POSITION in features:
+ await service_call(
+ SERVICE_SET_COVER_TILT_POSITION,
+ service_data | {ATTR_TILT_POSITION: target_tilt_position},
+ )
+ elif (
+ target_tilt_position == FULL_CLOSE and CoverEntityFeature.CLOSE_TILT in features
+ ):
+ await service_call(SERVICE_CLOSE_COVER_TILT, service_data)
+ elif target_tilt_position == FULL_OPEN and CoverEntityFeature.OPEN_TILT in features:
+ await service_call(SERVICE_OPEN_COVER_TILT, service_data)
+ else:
+ # Requested a tilt position but the cover doesn't support it
+ return False
+ return True
+
+
+async def _async_close_cover(
+ service_call: partial[Coroutine[Any, Any, ServiceResponse]],
+ service_data: dict[str, Any],
+ features: CoverEntityFeature,
+ set_position: bool,
+ set_tilt: bool,
+) -> None:
+ """Close the cover if it was not closed by setting the position."""
+ if not set_position:
+ if CoverEntityFeature.CLOSE in features:
+ await service_call(SERVICE_CLOSE_COVER, service_data)
+ elif CoverEntityFeature.SET_POSITION in features:
+ await service_call(
+ SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: FULL_CLOSE}
+ )
+ if not set_tilt:
+ if CoverEntityFeature.CLOSE_TILT in features:
+ await service_call(SERVICE_CLOSE_COVER_TILT, service_data)
+ elif CoverEntityFeature.SET_TILT_POSITION in features:
+ await service_call(
+ SERVICE_SET_COVER_TILT_POSITION,
+ service_data | {ATTR_TILT_POSITION: FULL_CLOSE},
+ )
+
+
+async def _async_open_cover(
+ service_call: partial[Coroutine[Any, Any, ServiceResponse]],
+ service_data: dict[str, Any],
+ features: CoverEntityFeature,
+ set_position: bool,
+ set_tilt: bool,
+) -> None:
+ """Open the cover if it was not opened by setting the position."""
+ if not set_position:
+ if CoverEntityFeature.OPEN in features:
+ await service_call(SERVICE_OPEN_COVER, service_data)
+ elif CoverEntityFeature.SET_POSITION in features:
+ await service_call(
+ SERVICE_SET_COVER_POSITION, service_data | {ATTR_POSITION: FULL_OPEN}
+ )
+ if not set_tilt:
+ if CoverEntityFeature.OPEN_TILT in features:
+ await service_call(SERVICE_OPEN_COVER_TILT, service_data)
+ elif CoverEntityFeature.SET_TILT_POSITION in features:
+ await service_call(
+ SERVICE_SET_COVER_TILT_POSITION,
+ service_data | {ATTR_TILT_POSITION: FULL_OPEN},
+ )
async def _async_reproduce_state(
@@ -45,74 +173,64 @@ async def _async_reproduce_state(
reproduce_options: dict[str, Any] | None = None,
) -> None:
"""Reproduce a single state."""
- if (cur_state := hass.states.get(state.entity_id)) is None:
- _LOGGER.warning("Unable to find entity %s", state.entity_id)
+ entity_id = state.entity_id
+ if (cur_state := hass.states.get(entity_id)) is None:
+ _LOGGER.warning("Unable to find entity %s", entity_id)
return
- if state.state not in VALID_STATES:
- _LOGGER.warning(
- "Invalid state specified for %s: %s", state.entity_id, state.state
- )
+ if (target_state := state.state) not in VALID_STATES:
+ _LOGGER.warning("Invalid state specified for %s: %s", entity_id, target_state)
return
+ current_attrs = cur_state.attributes
+ target_attrs = state.attributes
+
+ current_position: int | None = current_attrs.get(ATTR_CURRENT_POSITION)
+ target_position: int | None = target_attrs.get(ATTR_CURRENT_POSITION)
+ position_matches = current_position == target_position
+
+ current_tilt_position: int | None = current_attrs.get(ATTR_CURRENT_TILT_POSITION)
+ target_tilt_position: int | None = target_attrs.get(ATTR_CURRENT_TILT_POSITION)
+ tilt_position_matches = current_tilt_position == target_tilt_position
+
+ state_matches = cur_state.state == target_state
# Return if we are already at the right state.
- if (
- cur_state.state == state.state
- and cur_state.attributes.get(ATTR_CURRENT_POSITION)
- == state.attributes.get(ATTR_CURRENT_POSITION)
- and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION)
- == state.attributes.get(ATTR_CURRENT_TILT_POSITION)
- ):
+ if state_matches and position_matches and tilt_position_matches:
return
- service_data = {ATTR_ENTITY_ID: state.entity_id}
- service_data_tilting = {ATTR_ENTITY_ID: state.entity_id}
+ features = try_parse_enum(
+ CoverEntityFeature, current_attrs.get(ATTR_SUPPORTED_FEATURES)
+ )
+ if features is None:
+ # Backwards compatibility for integrations that
+ # don't set supported features since it previously
+ # worked without it.
+ _LOGGER.warning("Supported features is not set for %s", entity_id)
+ features = _determine_features(current_attrs)
- if not (
- cur_state.state == state.state
- and cur_state.attributes.get(ATTR_CURRENT_POSITION)
- == state.attributes.get(ATTR_CURRENT_POSITION)
- ):
- # Open/Close
- if state.state in [CoverState.CLOSED, CoverState.CLOSING]:
- service = SERVICE_CLOSE_COVER
- elif state.state in [CoverState.OPEN, CoverState.OPENING]:
- if (
- ATTR_CURRENT_POSITION in cur_state.attributes
- and ATTR_CURRENT_POSITION in state.attributes
- ):
- service = SERVICE_SET_COVER_POSITION
- service_data[ATTR_POSITION] = state.attributes[ATTR_CURRENT_POSITION]
- else:
- service = SERVICE_OPEN_COVER
+ service_call = partial(
+ hass.services.async_call,
+ DOMAIN,
+ context=context,
+ blocking=True,
+ )
+ service_data = {ATTR_ENTITY_ID: entity_id}
- await hass.services.async_call(
- DOMAIN, service, service_data, context=context, blocking=True
+ set_position = target_position is not None and await _async_set_position(
+ service_call, service_data, features, target_position
+ )
+ set_tilt = target_tilt_position is not None and await _async_set_tilt_position(
+ service_call, service_data, features, target_tilt_position
+ )
+
+ if target_state in CLOSING_STATES:
+ await _async_close_cover(
+ service_call, service_data, features, set_position, set_tilt
)
- if (
- ATTR_CURRENT_TILT_POSITION in state.attributes
- and ATTR_CURRENT_TILT_POSITION in cur_state.attributes
- and cur_state.attributes.get(ATTR_CURRENT_TILT_POSITION)
- != state.attributes.get(ATTR_CURRENT_TILT_POSITION)
- ):
- # Tilt position
- if state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100:
- service_tilting = SERVICE_OPEN_COVER_TILT
- elif state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 0:
- service_tilting = SERVICE_CLOSE_COVER_TILT
- else:
- service_tilting = SERVICE_SET_COVER_TILT_POSITION
- service_data_tilting[ATTR_TILT_POSITION] = state.attributes[
- ATTR_CURRENT_TILT_POSITION
- ]
-
- await hass.services.async_call(
- DOMAIN,
- service_tilting,
- service_data_tilting,
- context=context,
- blocking=True,
+ elif target_state in OPENING_STATES:
+ await _async_open_cover(
+ service_call, service_data, features, set_position, set_tilt
)
diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json
index 0afef8a200f..6ca8b50620f 100644
--- a/homeassistant/components/cover/strings.json
+++ b/homeassistant/components/cover/strings.json
@@ -38,10 +38,10 @@
"name": "[%key:component::cover::title%]",
"state": {
"open": "[%key:common::state::open%]",
- "opening": "Opening",
+ "opening": "[%key:common::state::opening%]",
"closed": "[%key:common::state::closed%]",
- "closing": "Closing",
- "stopped": "Stopped"
+ "closing": "[%key:common::state::closing%]",
+ "stopped": "[%key:common::state::stopped%]"
},
"state_attributes": {
"current_position": {
diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py
index 6a14f7ad13f..11f683b1434 100644
--- a/homeassistant/components/cpuspeed/sensor.py
+++ b/homeassistant/components/cpuspeed/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfFrequency
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -24,7 +24,7 @@ HZ_ADVERTISED = "hz_advertised"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform from config_entry."""
async_add_entities([CPUSpeedSensor(entry)], True)
diff --git a/homeassistant/components/crownstone/light.py b/homeassistant/components/crownstone/light.py
index 70b7631fe6b..dc29ad93072 100644
--- a/homeassistant/components/crownstone/light.py
+++ b/homeassistant/components/crownstone/light.py
@@ -14,7 +14,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CROWNSTONE_INCLUDE_TYPES,
@@ -30,7 +30,7 @@ from .helpers import map_from_to
async def async_setup_entry(
hass: HomeAssistant,
config_entry: CrownstoneConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up crownstones from a config entry."""
manager = config_entry.runtime_data
diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py
index 0eaffa39ee9..88a7b71e3ed 100644
--- a/homeassistant/components/daikin/__init__.py
+++ b/homeassistant/components/daikin/__init__.py
@@ -21,6 +21,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+from homeassistant.util.ssl import client_context_no_verify
from .const import KEY_MAC, TIMEOUT
from .coordinator import DaikinConfigEntry, DaikinCoordinator
@@ -48,6 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaikinConfigEntry) -> bo
key=entry.data.get(CONF_API_KEY),
uuid=entry.data.get(CONF_UUID),
password=entry.data.get(CONF_PASSWORD),
+ ssl_context=client_context_no_verify(),
)
_LOGGER.debug("Connection to %s successful", host)
except TimeoutError as err:
diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py
index 06ee0a03860..648a65c0d30 100644
--- a/homeassistant/components/daikin/climate.py
+++ b/homeassistant/components/daikin/climate.py
@@ -21,7 +21,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_INSIDE_TEMPERATURE,
@@ -83,7 +83,7 @@ DAIKIN_ATTR_ADVANCED = "adv"
async def async_setup_entry(
hass: HomeAssistant,
entry: DaikinConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Daikin climate based on config_entry."""
daikin_api = entry.runtime_data
diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py
index cc25a88ae39..f5febafc4dc 100644
--- a/homeassistant/components/daikin/config_flow.py
+++ b/homeassistant/components/daikin/config_flow.py
@@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_UUID
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
+from homeassistant.util.ssl import client_context_no_verify
from .const import DOMAIN, KEY_MAC, TIMEOUT
@@ -90,6 +91,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
key=key,
uuid=uuid,
password=password,
+ ssl_context=client_context_no_verify(),
)
except (TimeoutError, ClientError):
self.host = None
diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json
index f794d97a9ba..947fe514747 100644
--- a/homeassistant/components/daikin/manifest.json
+++ b/homeassistant/components/daikin/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/daikin",
"iot_class": "local_polling",
"loggers": ["pydaikin"],
- "requirements": ["pydaikin==2.13.8"],
+ "requirements": ["pydaikin==2.15.0"],
"zeroconf": ["_dkapi._tcp.local."]
}
diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py
index 982aac1f3f2..c1aa28fbe67 100644
--- a/homeassistant/components/daikin/sensor.py
+++ b/homeassistant/components/daikin/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_COMPRESSOR_FREQUENCY,
@@ -134,7 +134,7 @@ SENSOR_TYPES: tuple[DaikinSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: DaikinConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Daikin climate based on config_entry."""
daikin_api = entry.runtime_data
diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py
index 8a3a15d367f..20a56ac321c 100644
--- a/homeassistant/components/daikin/switch.py
+++ b/homeassistant/components/daikin/switch.py
@@ -6,7 +6,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DaikinConfigEntry, DaikinCoordinator
from .entity import DaikinEntity
@@ -19,7 +19,7 @@ DAIKIN_ATTR_MODE = "mode"
async def async_setup_entry(
hass: HomeAssistant,
entry: DaikinConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Daikin climate based on config_entry."""
daikin_api = entry.runtime_data
diff --git a/homeassistant/components/deako/light.py b/homeassistant/components/deako/light.py
index 75b01935c9a..12f42c36f29 100644
--- a/homeassistant/components/deako/light.py
+++ b/homeassistant/components/deako/light.py
@@ -7,7 +7,7 @@ from pydeako import Deako
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeakoConfigEntry
from .const import DOMAIN
@@ -20,7 +20,7 @@ MODEL_DIMMER = "dimmer"
async def async_setup_entry(
hass: HomeAssistant,
config: DeakoConfigEntry,
- add_entities: AddEntitiesCallback,
+ add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Configure the platform."""
client = config.runtime_data
diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json
index 078af8c67a5..21211d334df 100644
--- a/homeassistant/components/debugpy/manifest.json
+++ b/homeassistant/components/debugpy/manifest.json
@@ -6,5 +6,5 @@
"integration_type": "service",
"iot_class": "local_push",
"quality_scale": "internal",
- "requirements": ["debugpy==1.8.11"]
+ "requirements": ["debugpy==1.8.13"]
}
diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py
index 94f4cd1ddd6..85ca32d76e6 100644
--- a/homeassistant/components/deconz/alarm_control_panel.py
+++ b/homeassistant/components/deconz/alarm_control_panel.py
@@ -17,7 +17,7 @@ from homeassistant.components.alarm_control_panel import (
CodeFormat,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .entity import DeconzDevice
@@ -48,7 +48,7 @@ def get_alarm_system_id_for_unique_id(hub: DeconzHub, unique_id: str) -> str | N
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the deCONZ alarm control panel devices."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py
index e3b0fc2f2c0..fcbb61a4e4f 100644
--- a/homeassistant/components/deconz/binary_sensor.py
+++ b/homeassistant/components/deconz/binary_sensor.py
@@ -25,7 +25,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import ATTR_TEMPERATURE, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .const import ATTR_DARK, ATTR_ON
@@ -161,7 +161,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzBinarySensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the deCONZ binary sensor."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/button.py b/homeassistant/components/deconz/button.py
index 9fea1d02ab8..1d96f9867a7 100644
--- a/homeassistant/components/deconz/button.py
+++ b/homeassistant/components/deconz/button.py
@@ -16,7 +16,7 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .entity import DeconzDevice, DeconzSceneMixin
@@ -47,7 +47,7 @@ ENTITY_DESCRIPTIONS = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the deCONZ button entity."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py
index aa274e6c0c1..26597c195e7 100644
--- a/homeassistant/components/deconz/climate.py
+++ b/homeassistant/components/deconz/climate.py
@@ -30,7 +30,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .const import ATTR_LOCKED, ATTR_OFFSET, ATTR_VALVE
@@ -77,7 +77,7 @@ DECONZ_TO_PRESET_MODE = {value: key for key, value in PRESET_MODE_TO_DECONZ.item
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the deCONZ climate devices."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py
index 6dee00248ff..d68e0fec09c 100644
--- a/homeassistant/components/deconz/cover.py
+++ b/homeassistant/components/deconz/cover.py
@@ -18,7 +18,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .entity import DeconzDevice
@@ -34,7 +34,7 @@ DECONZ_TYPE_TO_DEVICE_CLASS = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up covers for deCONZ component."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/fan.py b/homeassistant/components/deconz/fan.py
index aec078f771f..324ada807e0 100644
--- a/homeassistant/components/deconz/fan.py
+++ b/homeassistant/components/deconz/fan.py
@@ -13,7 +13,7 @@ from homeassistant.components.fan import (
FanEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
@@ -34,7 +34,7 @@ ORDERED_NAMED_FAN_SPEEDS: list[LightFanSpeed] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up fans for deCONZ component."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py
index 72ba7035c8e..b61a1d39333 100644
--- a/homeassistant/components/deconz/light.py
+++ b/homeassistant/components/deconz/light.py
@@ -30,7 +30,7 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import (
color_hs_to_xy,
color_temperature_kelvin_to_mired,
@@ -142,7 +142,7 @@ def update_color_state(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the deCONZ lights and groups from a config entry."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py
index e5e2faf1d57..77b9ea435c7 100644
--- a/homeassistant/components/deconz/lock.py
+++ b/homeassistant/components/deconz/lock.py
@@ -10,7 +10,7 @@ from pydeconz.models.sensor.door_lock import DoorLock
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .entity import DeconzDevice
@@ -19,7 +19,7 @@ from .entity import DeconzDevice
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up locks for deCONZ component."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json
index 93ae8e392c8..5664e6abc8a 100644
--- a/homeassistant/components/deconz/manifest.json
+++ b/homeassistant/components/deconz/manifest.json
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pydeconz"],
- "requirements": ["pydeconz==118"],
+ "requirements": ["pydeconz==120"],
"ssdp": [
{
"manufacturer": "Royal Philips Electronics",
diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py
index 9de86c1c79b..d5ba8cc28d5 100644
--- a/homeassistant/components/deconz/number.py
+++ b/homeassistant/components/deconz/number.py
@@ -19,7 +19,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .entity import DeconzDevice
@@ -70,7 +70,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzNumberDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the deCONZ number entity."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py
index 3f29b12b05f..0aff2b3ca8c 100644
--- a/homeassistant/components/deconz/scene.py
+++ b/homeassistant/components/deconz/scene.py
@@ -8,7 +8,7 @@ from pydeconz.models.event import EventType
from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN, Scene
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .entity import DeconzSceneMixin
@@ -17,7 +17,7 @@ from .entity import DeconzSceneMixin
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up scenes for deCONZ integration."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/select.py b/homeassistant/components/deconz/select.py
index a3109a278fc..4d92b465cdc 100644
--- a/homeassistant/components/deconz/select.py
+++ b/homeassistant/components/deconz/select.py
@@ -14,7 +14,7 @@ from pydeconz.models.sensor.presence import (
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .entity import DeconzDevice
@@ -30,7 +30,7 @@ DECONZ_TO_SENSITIVITY = {value: key for key, value in SENSITIVITY_TO_DECONZ.item
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the deCONZ button entity."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py
index 3003fb1008d..d318db6e2bf 100644
--- a/homeassistant/components/deconz/sensor.py
+++ b/homeassistant/components/deconz/sensor.py
@@ -50,7 +50,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
@@ -332,7 +332,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the deCONZ sensors."""
hub = config_entry.runtime_data
@@ -468,7 +468,7 @@ class DeconzBatteryTracker:
sensor_id: str,
hub: DeconzHub,
description: DeconzSensorDescription,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tracker."""
self.sensor = hub.api.sensors[sensor_id]
diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py
index 28b606e30ba..4c15cf8ccfe 100644
--- a/homeassistant/components/deconz/siren.py
+++ b/homeassistant/components/deconz/siren.py
@@ -14,7 +14,7 @@ from homeassistant.components.siren import (
SirenEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .entity import DeconzDevice
@@ -23,7 +23,7 @@ from .entity import DeconzDevice
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sirens for deCONZ component."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py
index cd28871e35b..49904642804 100644
--- a/homeassistant/components/deconz/switch.py
+++ b/homeassistant/components/deconz/switch.py
@@ -9,7 +9,7 @@ from pydeconz.models.light.light import Light
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeconzConfigEntry
from .const import POWER_PLUGS
@@ -19,7 +19,7 @@ from .entity import DeconzDevice
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DeconzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches for deCONZ component.
diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py
index 19afe26e8f9..78eced64c7c 100644
--- a/homeassistant/components/deluge/config_flow.py
+++ b/homeassistant/components/deluge/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
+import logging
from ssl import SSLError
from typing import Any
@@ -21,6 +22,8 @@ from .const import (
DOMAIN,
)
+_LOGGER = logging.getLogger(__name__)
+
class DelugeFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Deluge."""
@@ -86,7 +89,8 @@ class DelugeFlowHandler(ConfigFlow, domain=DOMAIN):
await self.hass.async_add_executor_job(api.connect)
except (ConnectionRefusedError, TimeoutError, SSLError):
return "cannot_connect"
- except Exception as ex: # noqa: BLE001
+ except Exception as ex:
+ _LOGGER.exception("Unexpected error")
if type(ex).__name__ == "BadLoginError":
return "invalid_auth"
return "unknown"
diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py
index 24d5ce9ec61..d6809967703 100644
--- a/homeassistant/components/deluge/sensor.py
+++ b/homeassistant/components/deluge/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import STATE_IDLE, Platform, UnitOfDataRate
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DelugeGetSessionStatusKeys, DelugeSensorType
@@ -116,7 +116,7 @@ SENSOR_TYPES: tuple[DelugeSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: DelugeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Deluge sensor."""
async_add_entities(
diff --git a/homeassistant/components/deluge/strings.json b/homeassistant/components/deluge/strings.json
index 6adde8ef7df..ddea78b315f 100644
--- a/homeassistant/components/deluge/strings.json
+++ b/homeassistant/components/deluge/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "description": "To be able to use this integration, you have to enable the following option in deluge settings: Daemon > Allow remote controls",
+ "description": "To be able to use this integration, you have to enable the following option in Deluge settings: Daemon > Allow remote controls",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py
index 1ec0cd7a7df..342442ee727 100644
--- a/homeassistant/components/deluge/switch.py
+++ b/homeassistant/components/deluge/switch.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DelugeConfigEntry, DelugeDataUpdateCoordinator
from .entity import DelugeEntity
@@ -16,7 +16,7 @@ from .entity import DelugeEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: DelugeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Deluge switch."""
async_add_entities([DelugeSwitch(entry.runtime_data)])
diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py
index 9314fc211de..dbc65119bfa 100644
--- a/homeassistant/components/demo/__init__.py
+++ b/homeassistant/components/demo/__init__.py
@@ -48,6 +48,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [
Platform.TIME,
Platform.UPDATE,
Platform.VACUUM,
+ Platform.VALVE,
Platform.WATER_HEATER,
Platform.WEATHER,
]
diff --git a/homeassistant/components/demo/air_quality.py b/homeassistant/components/demo/air_quality.py
index 551f2c8e88a..4e247812efe 100644
--- a/homeassistant/components/demo/air_quality.py
+++ b/homeassistant/components/demo/air_quality.py
@@ -5,13 +5,13 @@ from __future__ import annotations
from homeassistant.components.air_quality import AirQualityEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
async_add_entities(
diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py
index d34830042d7..64474b4beb6 100644
--- a/homeassistant/components/demo/alarm_control_panel.py
+++ b/homeassistant/components/demo/alarm_control_panel.py
@@ -9,13 +9,13 @@ from homeassistant.components.manual.alarm_control_panel import ManualAlarm
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ARMING_TIME, CONF_DELAY_TIME, CONF_TRIGGER_TIME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
async_add_entities(
diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py
index bc1d7b9daf2..b210e726205 100644
--- a/homeassistant/components/demo/binary_sensor.py
+++ b/homeassistant/components/demo/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -17,7 +17,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo binary sensor platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/button.py b/homeassistant/components/demo/button.py
index a3b8dd9ff0c..25212f38989 100644
--- a/homeassistant/components/demo/button.py
+++ b/homeassistant/components/demo/button.py
@@ -7,7 +7,7 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -15,7 +15,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo button platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py
index 4e2fa7b3460..b0e82acfa61 100644
--- a/homeassistant/components/demo/calendar.py
+++ b/homeassistant/components/demo/calendar.py
@@ -7,14 +7,14 @@ import datetime
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo Calendar config entry."""
async_add_entities(
diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py
index 9fae6468207..69ba7efda01 100644
--- a/homeassistant/components/demo/camera.py
+++ b/homeassistant/components/demo/camera.py
@@ -7,13 +7,13 @@ from pathlib import Path
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
async_add_entities(
diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py
index d5b763caa5a..f68714695f3 100644
--- a/homeassistant/components/demo/climate.py
+++ b/homeassistant/components/demo/climate.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -27,7 +27,7 @@ SUPPORT_FLAGS = ClimateEntityFeature(0)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo climate platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py
index adddb6a3a7d..ed13f24cfd7 100644
--- a/homeassistant/components/demo/cover.py
+++ b/homeassistant/components/demo/cover.py
@@ -15,7 +15,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_utc_time_change
from . import DOMAIN
@@ -24,7 +24,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo cover platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/date.py b/homeassistant/components/demo/date.py
index b67c4248123..875075a381d 100644
--- a/homeassistant/components/demo/date.py
+++ b/homeassistant/components/demo/date.py
@@ -8,7 +8,7 @@ from homeassistant.components.date import DateEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -16,7 +16,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo date platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/datetime.py b/homeassistant/components/demo/datetime.py
index 920bc14cdc5..353ed8311bb 100644
--- a/homeassistant/components/demo/datetime.py
+++ b/homeassistant/components/demo/datetime.py
@@ -8,7 +8,7 @@ from homeassistant.components.datetime import DateTimeEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -16,7 +16,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo datetime platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/event.py b/homeassistant/components/demo/event.py
index c58b5f5fc2e..f593a833123 100644
--- a/homeassistant/components/demo/event.py
+++ b/homeassistant/components/demo/event.py
@@ -6,7 +6,7 @@ from homeassistant.components.event import EventDeviceClass, EventEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -14,7 +14,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo event platform."""
async_add_entities([DemoEvent()])
diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py
index 42e7f9e2434..9f48628688e 100644
--- a/homeassistant/components/demo/fan.py
+++ b/homeassistant/components/demo/fan.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
PRESET_MODE_AUTO = "auto"
PRESET_MODE_SMART = "smart"
@@ -29,7 +29,7 @@ LIMITED_SUPPORT = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
async_add_entities(
diff --git a/homeassistant/components/demo/humidifier.py b/homeassistant/components/demo/humidifier.py
index 7245d96eaf0..2bdbd22eef8 100644
--- a/homeassistant/components/demo/humidifier.py
+++ b/homeassistant/components/demo/humidifier.py
@@ -12,7 +12,7 @@ from homeassistant.components.humidifier import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
SUPPORT_FLAGS = HumidifierEntityFeature(0)
@@ -20,7 +20,7 @@ SUPPORT_FLAGS = HumidifierEntityFeature(0)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo humidifier devices config entry."""
async_add_entities(
diff --git a/homeassistant/components/demo/icons.json b/homeassistant/components/demo/icons.json
index eafcbb9161a..9a076f47a2d 100644
--- a/homeassistant/components/demo/icons.json
+++ b/homeassistant/components/demo/icons.json
@@ -45,6 +45,17 @@
}
}
},
+ "light": {
+ "bed_light": {
+ "state_attributes": {
+ "effect": {
+ "state": {
+ "rainbow": "mdi:looks"
+ }
+ }
+ }
+ }
+ },
"number": {
"volume": {
"default": "mdi:volume-high"
diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py
index ec98a056b3e..25a7b46bfb6 100644
--- a/homeassistant/components/demo/light.py
+++ b/homeassistant/components/demo/light.py
@@ -15,6 +15,7 @@ from homeassistant.components.light import (
ATTR_WHITE,
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
+ EFFECT_OFF,
ColorMode,
LightEntity,
LightEntityFeature,
@@ -22,13 +23,13 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
LIGHT_COLORS = [(56, 86), (345, 75)]
-LIGHT_EFFECT_LIST = ["rainbow", "none"]
+LIGHT_EFFECT_LIST = ["rainbow", EFFECT_OFF]
LIGHT_TEMPS = [4166, 2631]
@@ -39,7 +40,7 @@ SUPPORT_DEMO_HS_WHITE = {ColorMode.HS, ColorMode.WHITE}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo light platform."""
async_add_entities(
@@ -48,6 +49,7 @@ async def async_setup_entry(
available=True,
effect_list=LIGHT_EFFECT_LIST,
effect=LIGHT_EFFECT_LIST[0],
+ translation_key="bed_light",
device_name="Bed Light",
state=False,
unique_id="light_1",
@@ -119,8 +121,10 @@ class DemoLight(LightEntity):
rgbw_color: tuple[int, int, int, int] | None = None,
rgbww_color: tuple[int, int, int, int, int] | None = None,
supported_color_modes: set[ColorMode] | None = None,
+ translation_key: str | None = None,
) -> None:
"""Initialize the light."""
+ self._attr_translation_key = translation_key
self._available = True
self._brightness = brightness
self._ct = ct or random.choice(LIGHT_TEMPS)
diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py
index 1f25445af7f..081e1cf1d53 100644
--- a/homeassistant/components/demo/lock.py
+++ b/homeassistant/components/demo/lock.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.lock import LockEntity, LockEntityFeature, LockState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
LOCK_UNLOCK_DELAY = 2 # Used to give a realistic lock/unlock experience in frontend
@@ -16,7 +16,7 @@ LOCK_UNLOCK_DELAY = 2 # Used to give a realistic lock/unlock experience in fron
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
async_add_entities(
diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py
index fa3c3e3b2fc..5cd83722742 100644
--- a/homeassistant/components/demo/media_player.py
+++ b/homeassistant/components/demo/media_player.py
@@ -15,14 +15,14 @@ from homeassistant.components.media_player import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
async_add_entities(
@@ -41,6 +41,7 @@ async def async_setup_entry(
DemoTVShowPlayer(),
DemoBrowsePlayer("Browse"),
DemoGroupPlayer("Group"),
+ DemoSearchPlayer("Search"),
]
)
@@ -95,6 +96,8 @@ NETFLIX_PLAYER_SUPPORT = (
BROWSE_PLAYER_SUPPORT = MediaPlayerEntityFeature.BROWSE_MEDIA
+SEARCH_PLAYER_SUPPORT = MediaPlayerEntityFeature.SEARCH_MEDIA
+
class AbstractDemoPlayer(MediaPlayerEntity):
"""A demo media players."""
@@ -398,3 +401,9 @@ class DemoGroupPlayer(AbstractDemoPlayer):
| MediaPlayerEntityFeature.GROUPING
| MediaPlayerEntityFeature.TURN_OFF
)
+
+
+class DemoSearchPlayer(AbstractDemoPlayer):
+ """A Demo media player that supports searching."""
+
+ _attr_supported_features = SEARCH_PLAYER_SUPPORT
diff --git a/homeassistant/components/demo/notify.py b/homeassistant/components/demo/notify.py
index 7524517e6e8..d26e13cc541 100644
--- a/homeassistant/components/demo/notify.py
+++ b/homeassistant/components/demo/notify.py
@@ -10,7 +10,7 @@ from homeassistant.components.notify import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
EVENT_NOTIFY = "notify"
@@ -18,7 +18,7 @@ EVENT_NOTIFY = "notify"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo entity platform."""
async_add_entities([DemoNotifyEntity(unique_id="notify", device_name="Notifier")])
diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py
index 8c3f5ec3477..c7b62bdc3e0 100644
--- a/homeassistant/components/demo/number.py
+++ b/homeassistant/components/demo/number.py
@@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -15,7 +15,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo number platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py
index 774f375dd27..ffd6fd6e609 100644
--- a/homeassistant/components/demo/remote.py
+++ b/homeassistant/components/demo/remote.py
@@ -9,13 +9,13 @@ from homeassistant.components.remote import RemoteEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import DEVICE_DEFAULT_NAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
async_add_entities(
diff --git a/homeassistant/components/demo/select.py b/homeassistant/components/demo/select.py
index ff664a31d2f..fce90bc9b4f 100644
--- a/homeassistant/components/demo/select.py
+++ b/homeassistant/components/demo/select.py
@@ -6,7 +6,7 @@ from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -14,7 +14,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo select platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py
index 0c61faae00e..ae9ff26eca9 100644
--- a/homeassistant/components/demo/sensor.py
+++ b/homeassistant/components/demo/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from . import DOMAIN
@@ -33,7 +33,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo sensor platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/siren.py b/homeassistant/components/demo/siren.py
index 235d98f5875..ddaa5101e0f 100644
--- a/homeassistant/components/demo/siren.py
+++ b/homeassistant/components/demo/siren.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.siren import SirenEntity, SirenEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
SUPPORT_FLAGS = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON
@@ -15,7 +15,7 @@ SUPPORT_FLAGS = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo siren devices config entry."""
async_add_entities(
diff --git a/homeassistant/components/demo/strings.json b/homeassistant/components/demo/strings.json
index da72b33d3ca..e22b4c413d5 100644
--- a/homeassistant/components/demo/strings.json
+++ b/homeassistant/components/demo/strings.json
@@ -28,10 +28,10 @@
"state_attributes": {
"fan_mode": {
"state": {
- "auto_high": "Auto High",
- "auto_low": "Auto Low",
- "on_high": "On High",
- "on_low": "On Low"
+ "auto_high": "Auto high",
+ "auto_low": "Auto low",
+ "on_high": "On high",
+ "on_low": "On low"
}
},
"swing_mode": {
@@ -39,14 +39,14 @@
"1": "1",
"2": "2",
"3": "3",
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
"off": "[%key:common::state::off%]"
}
},
"swing_horizontal_mode": {
"state": {
"rangefull": "Full range",
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
"off": "[%key:common::state::off%]"
}
}
@@ -58,7 +58,7 @@
"state_attributes": {
"preset_mode": {
"state": {
- "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]",
+ "auto": "[%key:common::state::auto%]",
"sleep": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]",
"smart": "Smart",
"on": "[%key:common::state::on%]"
@@ -78,12 +78,23 @@
}
}
},
+ "light": {
+ "bed_light": {
+ "state_attributes": {
+ "effect": {
+ "state": {
+ "rainbow": "Rainbow"
+ }
+ }
+ }
+ }
+ },
"select": {
"speed": {
"state": {
- "light_speed": "Light Speed",
- "ludicrous_speed": "Ludicrous Speed",
- "ridiculous_speed": "Ridiculous Speed"
+ "light_speed": "Light speed",
+ "ludicrous_speed": "Ludicrous speed",
+ "ridiculous_speed": "Ridiculous speed"
}
}
},
@@ -102,7 +113,7 @@
"model_s": {
"state_attributes": {
"cleaned_area": {
- "name": "Cleaned Area"
+ "name": "Cleaned area"
}
}
}
diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py
index 95eebe44588..1757e4a8b88 100644
--- a/homeassistant/components/demo/stt.py
+++ b/homeassistant/components/demo/stt.py
@@ -17,7 +17,7 @@ from homeassistant.components.stt import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
SUPPORT_LANGUAGES = ["en", "de"]
@@ -25,7 +25,7 @@ SUPPORT_LANGUAGES = ["en", "de"]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Demo speech platform via config entry."""
async_add_entities([DemoProviderEntity()])
diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py
index 5dc05398bf1..dd288f285af 100644
--- a/homeassistant/components/demo/switch.py
+++ b/homeassistant/components/demo/switch.py
@@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -16,7 +16,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo switch platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/text.py b/homeassistant/components/demo/text.py
index 1730f414fdf..3219821ef98 100644
--- a/homeassistant/components/demo/text.py
+++ b/homeassistant/components/demo/text.py
@@ -6,7 +6,7 @@ from homeassistant.components.text import TextEntity, TextMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -14,7 +14,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo text platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py
index f5f0322f9be..296155e9bec 100644
--- a/homeassistant/components/demo/time.py
+++ b/homeassistant/components/demo/time.py
@@ -8,7 +8,7 @@ from homeassistant.components.time import TimeEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -16,7 +16,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo time platform."""
async_add_entities([DemoTime("time", "Time", time(12, 0, 0), False)])
diff --git a/homeassistant/components/demo/update.py b/homeassistant/components/demo/update.py
index 3fa037f6b02..916646416e9 100644
--- a/homeassistant/components/demo/update.py
+++ b/homeassistant/components/demo/update.py
@@ -13,7 +13,7 @@ from homeassistant.components.update import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -23,7 +23,7 @@ FAKE_INSTALL_SLEEP_TIME = 0.5
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up demo update platform."""
async_add_entities(
diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py
index 3dd945ab82e..38019cff3c1 100644
--- a/homeassistant/components/demo/vacuum.py
+++ b/homeassistant/components/demo/vacuum.py
@@ -14,7 +14,7 @@ from homeassistant.components.vacuum import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import event
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF
@@ -63,7 +63,7 @@ DEMO_VACUUM_NONE = "4_Fourth_floor"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
async_add_entities(
diff --git a/homeassistant/components/demo/valve.py b/homeassistant/components/demo/valve.py
new file mode 100644
index 00000000000..03f0123dd96
--- /dev/null
+++ b/homeassistant/components/demo/valve.py
@@ -0,0 +1,89 @@
+"""Demo valve platform that implements valves."""
+
+from __future__ import annotations
+
+import asyncio
+from typing import Any
+
+from homeassistant.components.valve import ValveEntity, ValveEntityFeature, ValveState
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+OPEN_CLOSE_DELAY = 2 # Used to give a realistic open/close experience in frontend
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Demo config entry."""
+ async_add_entities(
+ [
+ DemoValve("Front Garden", ValveState.OPEN),
+ DemoValve("Orchard", ValveState.CLOSED),
+ ]
+ )
+
+
+class DemoValve(ValveEntity):
+ """Representation of a Demo valve."""
+
+ _attr_should_poll = False
+
+ def __init__(
+ self,
+ name: str,
+ state: str,
+ moveable: bool = True,
+ ) -> None:
+ """Initialize the valve."""
+ self._attr_name = name
+ if moveable:
+ self._attr_supported_features = (
+ ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
+ )
+ self._state = state
+ self._moveable = moveable
+
+ @property
+ def is_open(self) -> bool:
+ """Return true if valve is open."""
+ return self._state == ValveState.OPEN
+
+ @property
+ def is_opening(self) -> bool:
+ """Return true if valve is opening."""
+ return self._state == ValveState.OPENING
+
+ @property
+ def is_closing(self) -> bool:
+ """Return true if valve is closing."""
+ return self._state == ValveState.CLOSING
+
+ @property
+ def is_closed(self) -> bool:
+ """Return true if valve is closed."""
+ return self._state == ValveState.CLOSED
+
+ @property
+ def reports_position(self) -> bool:
+ """Return True if entity reports position, False otherwise."""
+ return False
+
+ async def async_open_valve(self, **kwargs: Any) -> None:
+ """Open the valve."""
+ self._state = ValveState.OPENING
+ self.async_write_ha_state()
+ await asyncio.sleep(OPEN_CLOSE_DELAY)
+ self._state = ValveState.OPEN
+ self.async_write_ha_state()
+
+ async def async_close_valve(self, **kwargs: Any) -> None:
+ """Close the valve."""
+ self._state = ValveState.CLOSING
+ self.async_write_ha_state()
+ await asyncio.sleep(OPEN_CLOSE_DELAY)
+ self._state = ValveState.CLOSED
+ self.async_write_ha_state()
diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py
index f295780b190..9e12bb9e1d5 100644
--- a/homeassistant/components/demo/water_heater.py
+++ b/homeassistant/components/demo/water_heater.py
@@ -11,7 +11,7 @@ from homeassistant.components.water_heater import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
SUPPORT_FLAGS_HEATER = (
WaterHeaterEntityFeature.TARGET_TEMPERATURE
@@ -24,16 +24,21 @@ SUPPORT_FLAGS_HEATER = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
async_add_entities(
[
DemoWaterHeater(
- "Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco"
+ "Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco", 1
),
DemoWaterHeater(
- "Demo Water Heater Celsius", 45, UnitOfTemperature.CELSIUS, True, "eco"
+ "Demo Water Heater Celsius",
+ 45,
+ UnitOfTemperature.CELSIUS,
+ True,
+ "eco",
+ 1,
),
]
)
@@ -52,6 +57,7 @@ class DemoWaterHeater(WaterHeaterEntity):
unit_of_measurement: str,
away: bool,
current_operation: str,
+ target_temperature_step: float,
) -> None:
"""Initialize the water_heater device."""
self._attr_name = name
@@ -74,6 +80,7 @@ class DemoWaterHeater(WaterHeaterEntity):
"gas",
"off",
]
+ self._attr_target_temperature_step = target_temperature_step
def set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperatures."""
diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py
index 2468c54dde3..d1f829fee1b 100644
--- a/homeassistant/components/demo/weather.py
+++ b/homeassistant/components/demo/weather.py
@@ -26,7 +26,7 @@ from homeassistant.components.weather import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util import dt as dt_util
@@ -58,7 +58,7 @@ WEATHER_UPDATE_INTERVAL = timedelta(minutes=30)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
async_add_entities(
diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py
index 818d530ddab..a67c76f6525 100644
--- a/homeassistant/components/denonavr/media_player.py
+++ b/homeassistant/components/denonavr/media_player.py
@@ -39,7 +39,7 @@ from homeassistant.const import ATTR_COMMAND, CONF_HOST, CONF_MODEL, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DenonavrConfigEntry
from .const import (
@@ -109,7 +109,7 @@ DENON_STATE_MAPPING = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DenonavrConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the DenonAVR receiver from a config entry."""
entities = []
diff --git a/homeassistant/components/denonavr/strings.json b/homeassistant/components/denonavr/strings.json
index 6c055c5932a..192ab3bd71f 100644
--- a/homeassistant/components/denonavr/strings.json
+++ b/homeassistant/components/denonavr/strings.json
@@ -23,14 +23,14 @@
}
},
"error": {
- "discovery_error": "Failed to discover a Denon AVR Network Receiver"
+ "discovery_error": "Failed to discover a Denon AVR network receiver"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
- "cannot_connect": "Failed to connect, please try again, disconnecting mains power and ethernet cables and reconnecting them may help",
- "not_denonavr_manufacturer": "Not a Denon AVR Network Receiver, discovered manufacturer did not match",
- "not_denonavr_missing": "Not a Denon AVR Network Receiver, discovery information not complete"
+ "cannot_connect": "Failed to connect, please try again, disconnecting mains power and Ethernet cables and reconnecting them may help",
+ "not_denonavr_manufacturer": "Not a Denon AVR network receiver, discovered manufacturer did not match",
+ "not_denonavr_missing": "Not a Denon AVR network receiver, discovery information not complete"
}
},
"options": {
@@ -64,7 +64,7 @@
"fields": {
"dynamic_eq": {
"name": "Dynamic equalizer",
- "description": "True/false for enable/disable."
+ "description": "Whether DynamicEQ should be enabled or disabled."
}
}
},
diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py
index 988da5e938b..f6c2b45ef9c 100644
--- a/homeassistant/components/derivative/sensor.py
+++ b/homeassistant/components/derivative/sensor.py
@@ -24,12 +24,25 @@ from homeassistant.const import (
STATE_UNKNOWN,
UnitOfTime,
)
-from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
+from homeassistant.core import (
+ Event,
+ EventStateChangedData,
+ EventStateReportedData,
+ HomeAssistant,
+ State,
+ callback,
+)
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
+from homeassistant.helpers.event import (
+ async_track_state_change_event,
+ async_track_state_report_event,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
@@ -83,7 +96,7 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Derivative config entry."""
registry = er.async_get(hass)
@@ -197,13 +210,33 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
_LOGGER.warning("Could not restore last state: %s", err)
@callback
- def calc_derivative(event: Event[EventStateChangedData]) -> None:
+ def on_state_reported(event: Event[EventStateReportedData]) -> None:
+ """Handle constant sensor state."""
+ if self._attr_native_value == Decimal(0):
+ # If the derivative is zero, and the source sensor hasn't
+ # changed state, then we know it will still be zero.
+ return
+ new_state = event.data["new_state"]
+ if new_state is not None:
+ calc_derivative(
+ new_state, new_state.state, event.data["old_last_reported"]
+ )
+
+ @callback
+ def on_state_changed(event: Event[EventStateChangedData]) -> None:
+ """Handle changed sensor state."""
+ new_state = event.data["new_state"]
+ old_state = event.data["old_state"]
+ if new_state is not None and old_state is not None:
+ calc_derivative(new_state, old_state.state, old_state.last_reported)
+
+ def calc_derivative(
+ new_state: State, old_value: str, old_last_reported: datetime
+ ) -> None:
"""Handle the sensor state changes."""
- if (
- (old_state := event.data["old_state"]) is None
- or old_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
- or (new_state := event.data["new_state"]) is None
- or new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE)
+ if old_value in (STATE_UNKNOWN, STATE_UNAVAILABLE) or new_state.state in (
+ STATE_UNKNOWN,
+ STATE_UNAVAILABLE,
):
return
@@ -217,15 +250,15 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
self._state_list = [
(time_start, time_end, state)
for time_start, time_end, state in self._state_list
- if (new_state.last_updated - time_end).total_seconds()
+ if (new_state.last_reported - time_end).total_seconds()
< self._time_window
]
try:
elapsed_time = (
- new_state.last_updated - old_state.last_updated
+ new_state.last_reported - old_last_reported
).total_seconds()
- delta_value = Decimal(new_state.state) - Decimal(old_state.state)
+ delta_value = Decimal(new_state.state) - Decimal(old_value)
new_derivative = (
delta_value
/ Decimal(elapsed_time)
@@ -237,7 +270,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
_LOGGER.warning("While calculating derivative: %s", err)
except DecimalException as err:
_LOGGER.warning(
- "Invalid state (%s > %s): %s", old_state.state, new_state.state, err
+ "Invalid state (%s > %s): %s", old_value, new_state.state, err
)
except AssertionError as err:
_LOGGER.error("Could not calculate derivative: %s", err)
@@ -254,7 +287,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
# add latest derivative to the window list
self._state_list.append(
- (old_state.last_updated, new_state.last_updated, new_derivative)
+ (old_last_reported, new_state.last_reported, new_derivative)
)
def calculate_weight(
@@ -274,13 +307,19 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
else:
derivative = Decimal("0.00")
for start, end, value in self._state_list:
- weight = calculate_weight(start, end, new_state.last_updated)
+ weight = calculate_weight(start, end, new_state.last_reported)
derivative = derivative + (value * Decimal(weight))
self._attr_native_value = round(derivative, self._round_digits)
self.async_write_ha_state()
self.async_on_remove(
async_track_state_change_event(
- self.hass, self._sensor_source_id, calc_derivative
+ self.hass, self._sensor_source_id, on_state_changed
+ )
+ )
+
+ self.async_on_remove(
+ async_track_state_report_event(
+ self.hass, self._sensor_source_id, on_state_reported
)
)
diff --git a/homeassistant/components/devialet/media_player.py b/homeassistant/components/devialet/media_player.py
index 04ec58723cf..6fa6d40e17d 100644
--- a/homeassistant/components/devialet/media_player.py
+++ b/homeassistant/components/devialet/media_player.py
@@ -12,7 +12,7 @@ from homeassistant.components.media_player import (
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, SOUND_MODES
@@ -38,7 +38,7 @@ DEVIALET_TO_HA_FEATURE_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
entry: DevialetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Devialet entry."""
async_add_entities([DevialetMediaPlayerEntity(entry.runtime_data)])
diff --git a/homeassistant/components/devolo_home_control/binary_sensor.py b/homeassistant/components/devolo_home_control/binary_sensor.py
index d24033a80b9..7a88b12c48a 100644
--- a/homeassistant/components/devolo_home_control/binary_sensor.py
+++ b/homeassistant/components/devolo_home_control/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .entity import DevoloDeviceEntity
@@ -29,7 +29,7 @@ DEVICE_CLASS_MAPPING = {
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeControlConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all binary sensor and multi level sensor devices and setup them via config entry."""
entities: list[BinarySensorEntity] = []
diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py
index 1f407eb6804..3fdfa60870a 100644
--- a/homeassistant/components/devolo_home_control/climate.py
+++ b/homeassistant/components/devolo_home_control/climate.py
@@ -15,7 +15,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import PRECISION_HALVES, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
@@ -24,7 +24,7 @@ from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeControlConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all cover devices and setup them via config entry."""
diff --git a/homeassistant/components/devolo_home_control/cover.py b/homeassistant/components/devolo_home_control/cover.py
index f49a9d0f0be..f23244f1b50 100644
--- a/homeassistant/components/devolo_home_control/cover.py
+++ b/homeassistant/components/devolo_home_control/cover.py
@@ -10,7 +10,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
@@ -19,7 +19,7 @@ from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeControlConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all cover devices and setup them via config entry."""
diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py
index c855574b83a..8a88081ed05 100644
--- a/homeassistant/components/devolo_home_control/light.py
+++ b/homeassistant/components/devolo_home_control/light.py
@@ -9,7 +9,7 @@ from devolo_home_control_api.homecontrol import HomeControl
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
@@ -18,7 +18,7 @@ from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeControlConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all light devices and setup them via config entry."""
diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py
index 8d0a7f0313c..22581267eea 100644
--- a/homeassistant/components/devolo_home_control/sensor.py
+++ b/homeassistant/components/devolo_home_control/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .entity import DevoloDeviceEntity
@@ -40,7 +40,7 @@ STATE_CLASS_MAPPING = {
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeControlConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all sensor devices and setup them via config entry."""
entities: list[SensorEntity] = []
diff --git a/homeassistant/components/devolo_home_control/siren.py b/homeassistant/components/devolo_home_control/siren.py
index e896f4d3ed8..5e4df944b3c 100644
--- a/homeassistant/components/devolo_home_control/siren.py
+++ b/homeassistant/components/devolo_home_control/siren.py
@@ -7,7 +7,7 @@ from devolo_home_control_api.homecontrol import HomeControl
from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
@@ -16,7 +16,7 @@ from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeControlConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all binary sensor and multi level sensor devices and setup them via config entry."""
diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py
index a6f16229046..378e23a5f5f 100644
--- a/homeassistant/components/devolo_home_control/switch.py
+++ b/homeassistant/components/devolo_home_control/switch.py
@@ -9,7 +9,7 @@ from devolo_home_control_api.homecontrol import HomeControl
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeControlConfigEntry
from .entity import DevoloDeviceEntity
@@ -18,7 +18,7 @@ from .entity import DevoloDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeControlConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all devices and setup the switch devices via config entry."""
diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py
index 5752956ffb5..2c258d758da 100644
--- a/homeassistant/components/devolo_home_network/binary_sensor.py
+++ b/homeassistant/components/devolo_home_network/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeNetworkConfigEntry
from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER
@@ -54,7 +54,7 @@ SENSOR_TYPES: dict[str, DevoloBinarySensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeNetworkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all devices and sensors and setup them via config entry."""
coordinators = entry.runtime_data.coordinators
diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py
index 06822ff199e..fe6b1786363 100644
--- a/homeassistant/components/devolo_home_network/button.py
+++ b/homeassistant/components/devolo_home_network/button.py
@@ -16,7 +16,7 @@ from homeassistant.components.button import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeNetworkConfigEntry
from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS
@@ -59,7 +59,7 @@ BUTTON_TYPES: dict[str, DevoloButtonEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeNetworkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all devices and buttons and setup them via config entry."""
device = entry.runtime_data.device
diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py
index 583f022df84..cb726e5954c 100644
--- a/homeassistant/components/devolo_home_network/device_tracker.py
+++ b/homeassistant/components/devolo_home_network/device_tracker.py
@@ -12,7 +12,7 @@ from homeassistant.components.device_tracker import (
from homeassistant.const import STATE_UNKNOWN, UnitOfFrequency
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import DevoloHomeNetworkConfigEntry
@@ -25,7 +25,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeNetworkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all devices and sensors and setup them via config entry."""
device = entry.runtime_data.device
@@ -88,6 +88,8 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
):
"""Representation of a devolo device tracker."""
+ _attr_translation_key = "device_tracker"
+
def __init__(
self,
coordinator: DevoloDataUpdateCoordinator[list[ConnectedStationInfo]],
@@ -123,13 +125,6 @@ class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module
)
return attrs
- @property
- def icon(self) -> str:
- """Return device icon."""
- if self.is_connected:
- return "mdi:lan-connect"
- return "mdi:lan-disconnect"
-
@property
def is_connected(self) -> bool:
"""Return true if the device is connected to the network."""
diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py
index 93ec1b9a3a2..64d8ff131e8 100644
--- a/homeassistant/components/devolo_home_network/entity.py
+++ b/homeassistant/components/devolo_home_network/entity.py
@@ -8,6 +8,7 @@ from devolo_plc_api.device_api import (
WifiGuestAccessGet,
)
from devolo_plc_api.plcnet_api import DataRate, LogicalNetwork
+from yarl import URL
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
@@ -43,7 +44,7 @@ class DevoloEntity(Entity):
self.entry = entry
self._attr_device_info = DeviceInfo(
- configuration_url=f"http://{self.device.ip}",
+ configuration_url=URL.build(scheme="http", host=self.device.ip),
identifiers={(DOMAIN, str(self.device.serial_number))},
manufacturer="devolo",
model=self.device.product,
diff --git a/homeassistant/components/devolo_home_network/icons.json b/homeassistant/components/devolo_home_network/icons.json
index 816d0e36d03..752e5aa3f36 100644
--- a/homeassistant/components/devolo_home_network/icons.json
+++ b/homeassistant/components/devolo_home_network/icons.json
@@ -13,6 +13,14 @@
"default": "mdi:wifi-plus"
}
},
+ "device_tracker": {
+ "device_tracker": {
+ "default": "mdi:lan-disconnect",
+ "state": {
+ "home": "mdi:lan-connect"
+ }
+ }
+ },
"sensor": {
"connected_plc_devices": {
"default": "mdi:lan"
diff --git a/homeassistant/components/devolo_home_network/image.py b/homeassistant/components/devolo_home_network/image.py
index 91e8dd83b7d..46a3eb3426a 100644
--- a/homeassistant/components/devolo_home_network/image.py
+++ b/homeassistant/components/devolo_home_network/image.py
@@ -12,7 +12,7 @@ from devolo_plc_api.device_api import WifiGuestAccessGet
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import DevoloHomeNetworkConfigEntry
@@ -42,7 +42,7 @@ IMAGE_TYPES: dict[str, DevoloImageEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeNetworkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all devices and sensors and setup them via config entry."""
coordinators = entry.runtime_data.coordinators
diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json
index 9b1e181d7c0..31f3a51ebeb 100644
--- a/homeassistant/components/devolo_home_network/manifest.json
+++ b/homeassistant/components/devolo_home_network/manifest.json
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["devolo_plc_api"],
- "requirements": ["devolo-plc-api==1.4.1"],
+ "requirements": ["devolo-plc-api==1.5.1"],
"zeroconf": [
{
"type": "_dvl-deviceapi._tcp.local.",
diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py
index 220ab66312a..d9a6f3f1110 100644
--- a/homeassistant/components/devolo_home_network/sensor.py
+++ b/homeassistant/components/devolo_home_network/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory, UnitOfDataRate
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from . import DevoloHomeNetworkConfigEntry
@@ -123,7 +123,7 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any, Any]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeNetworkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all devices and sensors and setup them via config entry."""
device = entry.runtime_data.device
diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py
index 8ff35dcc4b6..b57305a7a77 100644
--- a/homeassistant/components/devolo_home_network/switch.py
+++ b/homeassistant/components/devolo_home_network/switch.py
@@ -14,7 +14,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeNetworkConfigEntry
from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS
@@ -55,7 +55,7 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeNetworkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all devices and sensors and setup them via config entry."""
device = entry.runtime_data.device
@@ -114,9 +114,14 @@ class DevoloSwitchEntity[_DataT: _DataType](
translation_key="password_protected",
translation_placeholders={"title": self.entry.title},
) from ex
- except DeviceUnavailable:
- pass # The coordinator will handle this
- await self.coordinator.async_request_refresh()
+ except DeviceUnavailable as ex:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="no_response",
+ translation_placeholders={"title": self.entry.title},
+ ) from ex
+ finally:
+ await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
@@ -129,6 +134,11 @@ class DevoloSwitchEntity[_DataT: _DataType](
translation_key="password_protected",
translation_placeholders={"title": self.entry.title},
) from ex
- except DeviceUnavailable:
- pass # The coordinator will handle this
- await self.coordinator.async_request_refresh()
+ except DeviceUnavailable as ex:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="no_response",
+ translation_placeholders={"title": self.entry.title},
+ ) from ex
+ finally:
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py
index 5091ce8e1e7..aaaf72af359 100644
--- a/homeassistant/components/devolo_home_network/update.py
+++ b/homeassistant/components/devolo_home_network/update.py
@@ -19,7 +19,7 @@ from homeassistant.components.update import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DevoloHomeNetworkConfigEntry
from .const import DOMAIN, REGULAR_FIRMWARE
@@ -51,7 +51,7 @@ UPDATE_TYPES: dict[str, DevoloUpdateEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: DevoloHomeNetworkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Get all devices and sensors and setup them via config entry."""
coordinators = entry.runtime_data.coordinators
diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py
index 90917e0ce2c..ed6dc94e764 100644
--- a/homeassistant/components/dexcom/config_flow.py
+++ b/homeassistant/components/dexcom/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import logging
from typing import Any
from pydexcom import AccountError, Dexcom, SessionError
@@ -12,6 +13,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import CONF_SERVER, DOMAIN, SERVER_OUS, SERVER_US
+_LOGGER = logging.getLogger(__name__)
+
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
@@ -43,7 +46,8 @@ class DexcomConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except AccountError:
errors["base"] = "invalid_auth"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
if "base" not in errors:
diff --git a/homeassistant/components/dexcom/sensor.py b/homeassistant/components/dexcom/sensor.py
index cdb1894b675..eac0134f010 100644
--- a/homeassistant/components/dexcom/sensor.py
+++ b/homeassistant/components/dexcom/sensor.py
@@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import CONF_USERNAME, UnitOfBloodGlucoseConcentration
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -26,7 +26,7 @@ TRENDS = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DexcomConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Dexcom sensors."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json
index 45af4f1b5dd..64fd2ff38c6 100644
--- a/homeassistant/components/dhcp/manifest.json
+++ b/homeassistant/components/dhcp/manifest.json
@@ -14,8 +14,8 @@
],
"quality_scale": "internal",
"requirements": [
- "aiodhcpwatcher==1.1.0",
- "aiodiscover==2.2.2",
- "cached-ipaddress==0.8.0"
+ "aiodhcpwatcher==1.1.1",
+ "aiodiscover==2.6.1",
+ "cached-ipaddress==0.10.0"
]
}
diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py
index 8998e050a75..91934a2da3a 100644
--- a/homeassistant/components/directv/media_player.py
+++ b/homeassistant/components/directv/media_player.py
@@ -15,7 +15,7 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import DirecTVConfigEntry
@@ -55,7 +55,7 @@ SUPPORT_DTV_CLIENT = (
async def async_setup_entry(
hass: HomeAssistant,
entry: DirecTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the DirecTV config entry."""
dtv = entry.runtime_data
diff --git a/homeassistant/components/directv/remote.py b/homeassistant/components/directv/remote.py
index dbaab5fa4e6..c9aacaae4d3 100644
--- a/homeassistant/components/directv/remote.py
+++ b/homeassistant/components/directv/remote.py
@@ -11,7 +11,7 @@ from directv import DIRECTV, DIRECTVError
from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DirecTVConfigEntry
from .entity import DIRECTVEntity
@@ -24,7 +24,7 @@ SCAN_INTERVAL = timedelta(minutes=2)
async def async_setup_entry(
hass: HomeAssistant,
entry: DirecTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load DirecTV remote based on a config entry."""
dtv = entry.runtime_data
diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py
index 9cf63176de6..0a8b7422f84 100644
--- a/homeassistant/components/discovergy/__init__.py
+++ b/homeassistant/components/discovergy/__init__.py
@@ -9,7 +9,7 @@ import pydiscovergy.error as discovergyError
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.httpx_client import create_async_httpx_client
from .coordinator import DiscovergyConfigEntry, DiscovergyUpdateCoordinator
@@ -21,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DiscovergyConfigEntry) -
client = Discovergy(
email=entry.data[CONF_EMAIL],
password=entry.data[CONF_PASSWORD],
- httpx_client=get_async_client(hass),
+ httpx_client=create_async_httpx_client(hass),
authentication=BasicAuth(),
)
diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py
index d4ef87049b8..e3f26ad49f8 100644
--- a/homeassistant/components/discovergy/coordinator.py
+++ b/homeassistant/components/discovergy/coordinator.py
@@ -51,9 +51,7 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]):
)
except InvalidLogin as err:
raise ConfigEntryAuthFailed(
- f"Auth expired while fetching last reading for meter {self.meter.meter_id}"
+ "Auth expired while fetching last reading"
) from err
except (HTTPError, DiscovergyClientError) as err:
- raise UpdateFailed(
- f"Error while fetching last reading for meter {self.meter.meter_id}"
- ) from err
+ raise UpdateFailed(f"Error while fetching last reading: {err}") from err
diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py
index 65b1722e0d8..7d4bb6cb052 100644
--- a/homeassistant/components/discovergy/sensor.py
+++ b/homeassistant/components/discovergy/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
@@ -166,7 +166,7 @@ ADDITIONAL_SENSORS: tuple[DiscovergySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: DiscovergyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Discovergy sensors."""
entities: list[DiscovergySensor] = []
diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py
index 54322cc6875..ef1348f613d 100644
--- a/homeassistant/components/dlink/switch.py
+++ b/homeassistant/components/dlink/switch.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DLinkConfigEntry
from .const import ATTR_TOTAL_CONSUMPTION
@@ -24,7 +24,7 @@ SWITCH_TYPE = SwitchEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: DLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the D-Link Power Plug switch."""
async_add_entities([SmartPlugSwitch(entry, SWITCH_TYPE)], True)
diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json
index 82541476a02..119d1d31d52 100644
--- a/homeassistant/components/dlna_dmr/manifest.json
+++ b/homeassistant/components/dlna_dmr/manifest.json
@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
- "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"],
+ "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py
index 563ed209b7d..d93d55e62be 100644
--- a/homeassistant/components/dlna_dmr/media_player.py
+++ b/homeassistant/components/dlna_dmr/media_player.py
@@ -32,7 +32,7 @@ from homeassistant.components.media_player import (
from homeassistant.const import CONF_DEVICE_ID, CONF_MAC, CONF_TYPE, CONF_URL
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import (
@@ -92,7 +92,7 @@ def catch_request_errors[_DlnaDmrEntityT: DlnaDmrEntity, **_P, _R](
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the DlnaDmrEntity from a config entry."""
_LOGGER.debug("media_player.async_setup_entry %s (%s)", entry.entry_id, entry.title)
diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json
index 17fc3dc27e8..0289d5100d6 100644
--- a/homeassistant/components/dlna_dms/manifest.json
+++ b/homeassistant/components/dlna_dms/manifest.json
@@ -7,7 +7,7 @@
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"iot_class": "local_polling",
- "requirements": ["async-upnp-client==0.43.0"],
+ "requirements": ["async-upnp-client==0.44.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py
index 34730e934a0..6708baefe8c 100644
--- a/homeassistant/components/dnsip/sensor.py
+++ b/homeassistant/components/dnsip/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_HOSTNAME,
@@ -45,7 +45,9 @@ def sort_ips(ips: list, querytype: str) -> list:
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the dnsip sensor entry."""
diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json
index 2c672dd4abb..cb31c7d6314 100644
--- a/homeassistant/components/doods/manifest.json
+++ b/homeassistant/components/doods/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pydoods"],
"quality_scale": "legacy",
- "requirements": ["pydoods==1.0.2", "Pillow==11.1.0"]
+ "requirements": ["pydoods==1.0.2", "Pillow==11.2.1"]
}
diff --git a/homeassistant/components/doorbird/button.py b/homeassistant/components/doorbird/button.py
index 62631e51abc..173c2e923e4 100644
--- a/homeassistant/components/doorbird/button.py
+++ b/homeassistant/components/doorbird/button.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .device import ConfiguredDoorBird, async_reset_device_favorites
from .entity import DoorBirdEntity
@@ -45,7 +45,7 @@ BUTTON_DESCRIPTIONS: tuple[DoorbirdButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DoorBirdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the DoorBird button platform."""
door_bird_data = config_entry.runtime_data
diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py
index 45f37527ac1..a41e7c41b28 100644
--- a/homeassistant/components/doorbird/camera.py
+++ b/homeassistant/components/doorbird/camera.py
@@ -9,7 +9,7 @@ import aiohttp
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .entity import DoorBirdEntity
@@ -25,7 +25,7 @@ _TIMEOUT = 15 # seconds
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DoorBirdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the DoorBird camera platform."""
door_bird_data = config_entry.runtime_data
diff --git a/homeassistant/components/doorbird/event.py b/homeassistant/components/doorbird/event.py
index 4c20098fc80..688f8b2fbeb 100644
--- a/homeassistant/components/doorbird/event.py
+++ b/homeassistant/components/doorbird/event.py
@@ -9,7 +9,7 @@ from homeassistant.components.event import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .device import DoorbirdEvent
@@ -35,7 +35,7 @@ EVENT_DESCRIPTIONS = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DoorBirdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the DoorBird event platform."""
door_bird_data = config_entry.runtime_data
diff --git a/homeassistant/components/doorbird/strings.json b/homeassistant/components/doorbird/strings.json
index 090ba4f161f..ad43e8c1c1c 100644
--- a/homeassistant/components/doorbird/strings.json
+++ b/homeassistant/components/doorbird/strings.json
@@ -6,7 +6,7 @@
"events": "Comma separated list of events."
},
"data_description": {
- "events": "Add an comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion"
+ "events": "Add a comma separated event name for each event you wish to track. After entering them here, use the DoorBird app to assign them to a specific event.\n\nExample: somebody_pressed_the_button, motion"
}
}
}
diff --git a/homeassistant/components/dormakaba_dkey/binary_sensor.py b/homeassistant/components/dormakaba_dkey/binary_sensor.py
index 56b991bf908..a8870ed224b 100644
--- a/homeassistant/components/dormakaba_dkey/binary_sensor.py
+++ b/homeassistant/components/dormakaba_dkey/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator
from .entity import DormakabaDkeyEntity
@@ -45,7 +45,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: DormakabaDkeyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary sensor platform for Dormakaba dKey."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/dormakaba_dkey/config_flow.py b/homeassistant/components/dormakaba_dkey/config_flow.py
index 0d23b822231..369accb83d8 100644
--- a/homeassistant/components/dormakaba_dkey/config_flow.py
+++ b/homeassistant/components/dormakaba_dkey/config_flow.py
@@ -57,7 +57,7 @@ class DormkabaConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovery_info = self._discovered_devices[address]
return await self.async_step_associate()
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses
diff --git a/homeassistant/components/dormakaba_dkey/lock.py b/homeassistant/components/dormakaba_dkey/lock.py
index 352e7cbe0ac..12a553adba3 100644
--- a/homeassistant/components/dormakaba_dkey/lock.py
+++ b/homeassistant/components/dormakaba_dkey/lock.py
@@ -8,7 +8,7 @@ from py_dormakaba_dkey.commands import UnlockStatus
from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator
from .entity import DormakabaDkeyEntity
@@ -17,7 +17,7 @@ from .entity import DormakabaDkeyEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: DormakabaDkeyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the lock platform for Dormakaba dKey."""
async_add_entities([DormakabaDkeyLock(entry.runtime_data)])
diff --git a/homeassistant/components/dormakaba_dkey/sensor.py b/homeassistant/components/dormakaba_dkey/sensor.py
index b1e941bc7e1..413ea1c56b1 100644
--- a/homeassistant/components/dormakaba_dkey/sensor.py
+++ b/homeassistant/components/dormakaba_dkey/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DormakabaDkeyConfigEntry, DormakabaDkeyCoordinator
from .entity import DormakabaDkeyEntity
@@ -28,7 +28,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: DormakabaDkeyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the lock platform for Dormakaba dKey."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/dremel_3d_printer/binary_sensor.py b/homeassistant/components/dremel_3d_printer/binary_sensor.py
index 972945a84bb..923bcdad09c 100644
--- a/homeassistant/components/dremel_3d_printer/binary_sensor.py
+++ b/homeassistant/components/dremel_3d_printer/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DremelConfigEntry
from .entity import Dremel3DPrinterEntity
@@ -43,7 +43,7 @@ BINARY_SENSOR_TYPES: tuple[Dremel3DPrinterBinarySensorEntityDescription, ...] =
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DremelConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the available Dremel binary sensors."""
async_add_entities(
diff --git a/homeassistant/components/dremel_3d_printer/button.py b/homeassistant/components/dremel_3d_printer/button.py
index f91c1b0ea51..880b179650f 100644
--- a/homeassistant/components/dremel_3d_printer/button.py
+++ b/homeassistant/components/dremel_3d_printer/button.py
@@ -10,7 +10,7 @@ from dremel3dpy import Dremel3DPrinter
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DremelConfigEntry
from .entity import Dremel3DPrinterEntity
@@ -45,7 +45,7 @@ BUTTON_TYPES: tuple[Dremel3DPrinterButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DremelConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Dremel 3D Printer control buttons."""
async_add_entities(
diff --git a/homeassistant/components/dremel_3d_printer/camera.py b/homeassistant/components/dremel_3d_printer/camera.py
index f4293915a25..ccb7eeaa658 100644
--- a/homeassistant/components/dremel_3d_printer/camera.py
+++ b/homeassistant/components/dremel_3d_printer/camera.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.camera import CameraEntityDescription
from homeassistant.components.mjpeg import MjpegCamera
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import Dremel3DPrinterDataUpdateCoordinator, DremelConfigEntry
from .entity import Dremel3DPrinterEntity
@@ -19,7 +19,7 @@ CAMERA_TYPE = CameraEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DremelConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a MJPEG IP Camera for the 3D45 Model. The 3D20 and 3D40 models don't have built in cameras."""
async_add_entities([Dremel3D45Camera(config_entry.runtime_data, CAMERA_TYPE)])
diff --git a/homeassistant/components/dremel_3d_printer/sensor.py b/homeassistant/components/dremel_3d_printer/sensor.py
index 002a5fc4adb..1f02b1fe239 100644
--- a/homeassistant/components/dremel_3d_printer/sensor.py
+++ b/homeassistant/components/dremel_3d_printer/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
@@ -235,7 +235,7 @@ SENSOR_TYPES: tuple[Dremel3DPrinterSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DremelConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the available Dremel 3D Printer sensors."""
async_add_entities(
diff --git a/homeassistant/components/drop_connect/binary_sensor.py b/homeassistant/components/drop_connect/binary_sensor.py
index bc8cf900610..f133be431f0 100644
--- a/homeassistant/components/drop_connect/binary_sensor.py
+++ b/homeassistant/components/drop_connect/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_DEVICE_TYPE,
@@ -105,7 +105,7 @@ DEVICE_BINARY_SENSORS: dict[str, list[str]] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DROPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the DROP binary sensors from config entry."""
_LOGGER.debug(
diff --git a/homeassistant/components/drop_connect/select.py b/homeassistant/components/drop_connect/select.py
index 9e4c74b67e6..e198033d0f7 100644
--- a/homeassistant/components/drop_connect/select.py
+++ b/homeassistant/components/drop_connect/select.py
@@ -9,7 +9,7 @@ from typing import Any
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_DEVICE_TYPE, DEV_HUB
from .coordinator import DROPConfigEntry, DROPDeviceDataUpdateCoordinator
@@ -50,7 +50,7 @@ DEVICE_SELECTS: dict[str, list[str]] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DROPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the DROP selects from config entry."""
_LOGGER.debug(
diff --git a/homeassistant/components/drop_connect/sensor.py b/homeassistant/components/drop_connect/sensor.py
index 5ec47ed9eb1..c69e2e12ea0 100644
--- a/homeassistant/components/drop_connect/sensor.py
+++ b/homeassistant/components/drop_connect/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_DEVICE_TYPE,
@@ -242,7 +242,7 @@ DEVICE_SENSORS: dict[str, list[str]] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DROPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the DROP sensors from config entry."""
_LOGGER.debug(
diff --git a/homeassistant/components/drop_connect/strings.json b/homeassistant/components/drop_connect/strings.json
index 93df4dc3310..6093f2e8100 100644
--- a/homeassistant/components/drop_connect/strings.json
+++ b/homeassistant/components/drop_connect/strings.json
@@ -38,8 +38,8 @@
"protect_mode": {
"name": "Protect mode",
"state": {
- "away": "Away",
- "home": "Home",
+ "away": "[%key:common::state::not_home%]",
+ "home": "[%key:common::state::home%]",
"schedule": "Schedule"
}
}
diff --git a/homeassistant/components/drop_connect/switch.py b/homeassistant/components/drop_connect/switch.py
index 404059d3196..d52d17c5ea0 100644
--- a/homeassistant/components/drop_connect/switch.py
+++ b/homeassistant/components/drop_connect/switch.py
@@ -9,7 +9,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_DEVICE_TYPE,
@@ -65,7 +65,7 @@ DEVICE_SWITCHES: dict[str, list[str]] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DROPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the DROP switches from config entry."""
_LOGGER.debug(
diff --git a/homeassistant/components/dsmr/config_flow.py b/homeassistant/components/dsmr/config_flow.py
index 7d6a641b006..6f15f99517b 100644
--- a/homeassistant/components/dsmr/config_flow.py
+++ b/homeassistant/components/dsmr/config_flow.py
@@ -59,7 +59,7 @@ class DSMRConnection:
self._equipment_identifier = obis_ref.EQUIPMENT_IDENTIFIER
if dsmr_version == "5B":
self._equipment_identifier = obis_ref.BELGIUM_EQUIPMENT_IDENTIFIER
- if dsmr_version == "5L":
+ if dsmr_version in ("5L", "5EONHU"):
self._equipment_identifier = obis_ref.LUXEMBOURG_EQUIPMENT_IDENTIFIER
if dsmr_version == "Q3D":
self._equipment_identifier = obis_ref.Q3D_EQUIPMENT_IDENTIFIER
diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py
index 4c6cb31ca4d..2682b4df1cc 100644
--- a/homeassistant/components/dsmr/const.py
+++ b/homeassistant/components/dsmr/const.py
@@ -28,7 +28,7 @@ DEVICE_NAME_GAS = "Gas Meter"
DEVICE_NAME_WATER = "Water Meter"
DEVICE_NAME_HEAT = "Heat Meter"
-DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"}
+DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D", "5EONHU"}
DSMR_PROTOCOL = "dsmr_protocol"
RFXTRX_DSMR_PROTOCOL = "rfxtrx_dsmr_protocol"
diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json
index 561f06d1bbe..f9e78ac616f 100644
--- a/homeassistant/components/dsmr/manifest.json
+++ b/homeassistant/components/dsmr/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["dsmr_parser"],
- "requirements": ["dsmr-parser==1.4.2"]
+ "requirements": ["dsmr-parser==1.4.3"]
}
diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py
index e05785b8b26..ba528271824 100644
--- a/homeassistant/components/dsmr/sensor.py
+++ b/homeassistant/components/dsmr/sensor.py
@@ -43,7 +43,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import Throttle
@@ -115,7 +115,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_active_tariff",
translation_key="electricity_active_tariff",
obis_reference="ELECTRICITY_ACTIVE_TARIFF",
- dsmr_versions={"2.2", "4", "5", "5B", "5L"},
+ dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
device_class=SensorDeviceClass.ENUM,
options=["low", "normal"],
),
@@ -123,7 +123,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_used_tariff_1",
translation_key="electricity_used_tariff_1",
obis_reference="ELECTRICITY_USED_TARIFF_1",
- dsmr_versions={"2.2", "4", "5", "5B", "5L"},
+ dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -131,7 +131,25 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_used_tariff_2",
translation_key="electricity_used_tariff_2",
obis_reference="ELECTRICITY_USED_TARIFF_2",
- dsmr_versions={"2.2", "4", "5", "5B", "5L"},
+ dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ DSMRSensorEntityDescription(
+ key="electricity_used_tariff_3",
+ translation_key="electricity_used_tariff_3",
+ obis_reference="ELECTRICITY_USED_TARIFF_3",
+ dsmr_versions={"5EONHU"},
+ force_update=True,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ DSMRSensorEntityDescription(
+ key="electricity_used_tariff_4",
+ translation_key="electricity_used_tariff_4",
+ obis_reference="ELECTRICITY_USED_TARIFF_4",
+ dsmr_versions={"5EONHU"},
+ force_update=True,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -139,7 +157,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_delivered_tariff_1",
translation_key="electricity_delivered_tariff_1",
obis_reference="ELECTRICITY_DELIVERED_TARIFF_1",
- dsmr_versions={"2.2", "4", "5", "5B", "5L"},
+ dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -147,7 +165,25 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_delivered_tariff_2",
translation_key="electricity_delivered_tariff_2",
obis_reference="ELECTRICITY_DELIVERED_TARIFF_2",
- dsmr_versions={"2.2", "4", "5", "5B", "5L"},
+ dsmr_versions={"2.2", "4", "5", "5B", "5L", "5EONHU"},
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ DSMRSensorEntityDescription(
+ key="electricity_delivered_tariff_3",
+ translation_key="electricity_delivered_tariff_3",
+ obis_reference="ELECTRICITY_DELIVERED_TARIFF_3",
+ dsmr_versions={"5EONHU"},
+ force_update=True,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ ),
+ DSMRSensorEntityDescription(
+ key="electricity_delivered_tariff_4",
+ translation_key="electricity_delivered_tariff_4",
+ obis_reference="ELECTRICITY_DELIVERED_TARIFF_4",
+ dsmr_versions={"5EONHU"},
+ force_update=True,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -341,7 +377,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_imported_total",
translation_key="electricity_imported_total",
obis_reference="ELECTRICITY_IMPORTED_TOTAL",
- dsmr_versions={"5L", "5S", "Q3D"},
+ dsmr_versions={"5L", "5S", "Q3D", "5EONHU"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -349,7 +385,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
key="electricity_exported_total",
translation_key="electricity_exported_total",
obis_reference="ELECTRICITY_EXPORTED_TOTAL",
- dsmr_versions={"5L", "5S", "Q3D"},
+ dsmr_versions={"5L", "5S", "Q3D", "5EONHU"},
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
@@ -387,6 +423,113 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
+ DSMRSensorEntityDescription(
+ key="actual_threshold_electricity",
+ translation_key="actual_threshold_electricity",
+ obis_reference="ACTUAL_TRESHOLD_ELECTRICITY", # Misspelled in external tool
+ dsmr_versions={"5EONHU"},
+ device_class=SensorDeviceClass.POWER,
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ DSMRSensorEntityDescription(
+ key="eon_hu_electricity_combined",
+ translation_key="electricity_combined",
+ obis_reference="EON_HU_ELECTRICITY_COMBINED",
+ dsmr_versions={"5EONHU"},
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ ),
+ DSMRSensorEntityDescription(
+ key="eon_hu_instantaneous_power_factor_total",
+ translation_key="instantaneous_power_factor_total",
+ obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_TOTAL",
+ dsmr_versions={"5EONHU"},
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.POWER_FACTOR,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ DSMRSensorEntityDescription(
+ key="eon_hu_instantaneous_power_factor_l1",
+ translation_key="instantaneous_power_factor_l1",
+ obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_L1",
+ dsmr_versions={"5EONHU"},
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.POWER_FACTOR,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ DSMRSensorEntityDescription(
+ key="eon_hu_instantaneous_power_factor_l2",
+ translation_key="instantaneous_power_factor_l2",
+ obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_L2",
+ dsmr_versions={"5EONHU"},
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.POWER_FACTOR,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ DSMRSensorEntityDescription(
+ key="eon_hu_instantaneous_power_factor_l3",
+ translation_key="instantaneous_power_factor_l3",
+ obis_reference="EON_HU_INSTANTANEOUS_POWER_FACTOR_L3",
+ dsmr_versions={"5EONHU"},
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.POWER_FACTOR,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ DSMRSensorEntityDescription(
+ key="eon_hu_frequency",
+ translation_key="frequency",
+ obis_reference="EON_HU_FREQUENCY",
+ dsmr_versions={"5EONHU"},
+ entity_registry_enabled_default=False,
+ device_class=SensorDeviceClass.FREQUENCY,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ DSMRSensorEntityDescription(
+ key="fuse_threshold_l1",
+ translation_key="fuse_threshold_l1",
+ obis_reference="FUSE_THRESHOLD_L1",
+ dsmr_versions={"5EONHU"},
+ device_class=SensorDeviceClass.CURRENT,
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ DSMRSensorEntityDescription(
+ key="fuse_threshold_l2",
+ translation_key="fuse_threshold_l2",
+ obis_reference="FUSE_THRESHOLD_L2",
+ dsmr_versions={"5EONHU"},
+ device_class=SensorDeviceClass.CURRENT,
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ DSMRSensorEntityDescription(
+ key="fuse_threshold_l3",
+ translation_key="fuse_threshold_l3",
+ obis_reference="FUSE_THRESHOLD_L3",
+ dsmr_versions={"5EONHU"},
+ device_class=SensorDeviceClass.CURRENT,
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ DSMRSensorEntityDescription(
+ key="text_message",
+ translation_key="text_message",
+ obis_reference="TEXT_MESSAGE",
+ dsmr_versions={"5EONHU"},
+ entity_registry_enabled_default=False,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
)
SENSORS_MBUS_DEVICE_TYPE: dict[int, tuple[DSMRSensorEntityDescription, ...]] = {
@@ -549,7 +692,9 @@ def get_dsmr_object(
async def async_setup_entry(
- hass: HomeAssistant, entry: DsmrConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: DsmrConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the DSMR sensor."""
dsmr_version = entry.data[CONF_DSMR_VERSION]
@@ -842,8 +987,9 @@ class DSMREntity(SensorEntity):
def translate_tariff(value: str, dsmr_version: str) -> str | None:
"""Convert 2/1 to normal/low depending on DSMR version."""
# DSMR V5B: Note: In Belgium values are swapped:
+ # DSMR V5EONHU: Note: In EON HUngary values are swapped:
# Rate code 2 is used for low rate and rate code 1 is used for normal rate.
- if dsmr_version == "5B":
+ if dsmr_version in ("5B", "5EONHU"):
if value == "0001":
value = "0002"
elif value == "0002":
diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json
index 055c0c41264..e95e9ae870a 100644
--- a/homeassistant/components/dsmr/strings.json
+++ b/homeassistant/components/dsmr/strings.json
@@ -51,8 +51,8 @@
"electricity_active_tariff": {
"name": "Active tariff",
"state": {
- "low": "Low",
- "normal": "Normal"
+ "low": "[%key:common::state::low%]",
+ "normal": "[%key:common::state::normal%]"
}
},
"electricity_delivered_tariff_1": {
@@ -61,6 +61,12 @@
"electricity_delivered_tariff_2": {
"name": "Energy production (tarif 2)"
},
+ "electricity_delivered_tariff_3": {
+ "name": "Energy production (tarif 3)"
+ },
+ "electricity_delivered_tariff_4": {
+ "name": "Energy production (tarif 4)"
+ },
"electricity_exported_total": {
"name": "Energy production (total)"
},
@@ -73,6 +79,12 @@
"electricity_used_tariff_2": {
"name": "Energy consumption (tarif 2)"
},
+ "electricity_used_tariff_3": {
+ "name": "Energy consumption (tarif 3)"
+ },
+ "electricity_used_tariff_4": {
+ "name": "Energy consumption (tarif 4)"
+ },
"gas_meter_reading": {
"name": "Gas consumption"
},
@@ -150,6 +162,57 @@
},
"water_meter_reading": {
"name": "Water consumption"
+ },
+ "actual_threshold_electricity": {
+ "name": "Actual threshold electricity"
+ },
+ "electricity_reactive_imported_total": {
+ "name": "Imported reactive electricity (total)"
+ },
+ "electricity_reactive_exported_total": {
+ "name": "Exported reactive electricity (total)"
+ },
+ "electricity_reactive_total_q1": {
+ "name": "Reactive electricity (Q1)"
+ },
+ "electricity_reactive_total_q2": {
+ "name": "Reactive electricity (Q2)"
+ },
+ "electricity_reactive_total_q3": {
+ "name": "Reactive electricity (Q3)"
+ },
+ "electricity_reactive_total_q4": {
+ "name": "Reactive electricity (Q4)"
+ },
+ "electricity_combined": {
+ "name": "Energy combined"
+ },
+ "instantaneous_power_factor_total": {
+ "name": "Instantaneous power factor (total)"
+ },
+ "instantaneous_power_factor_l1": {
+ "name": "Instantaneous power factor L1"
+ },
+ "instantaneous_power_factor_l2": {
+ "name": "Instantaneous power factor L2"
+ },
+ "instantaneous_power_factor_l3": {
+ "name": "Instantaneous power factor L3"
+ },
+ "frequency": {
+ "name": "Frequency"
+ },
+ "fuse_threshold_l1": {
+ "name": "Fuse threshold on L1"
+ },
+ "fuse_threshold_l2": {
+ "name": "Fuse threshold on L2"
+ },
+ "fuse_threshold_l3": {
+ "name": "Fuse threshold on L3"
+ },
+ "text_message": {
+ "name": "Text message"
}
}
},
diff --git a/homeassistant/components/dsmr_reader/sensor.py b/homeassistant/components/dsmr_reader/sensor.py
index 784a4cdec51..c9bd9c9fff2 100644
--- a/homeassistant/components/dsmr_reader/sensor.py
+++ b/homeassistant/components/dsmr_reader/sensor.py
@@ -7,7 +7,7 @@ from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.util import slugify
@@ -18,7 +18,7 @@ from .definitions import SENSORS, DSMRReaderSensorEntityDescription
async def async_setup_entry(
_: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up DSMR Reader sensors from config entry."""
async_add_entities(DSMRSensor(description, config_entry) for description in SENSORS)
diff --git a/homeassistant/components/dsmr_reader/strings.json b/homeassistant/components/dsmr_reader/strings.json
index 90cf0533a72..d405898a393 100644
--- a/homeassistant/components/dsmr_reader/strings.json
+++ b/homeassistant/components/dsmr_reader/strings.json
@@ -140,8 +140,8 @@
"electricity_tariff": {
"name": "Electricity tariff",
"state": {
- "low": "Low",
- "high": "High"
+ "low": "[%key:common::state::low%]",
+ "high": "[%key:common::state::high%]"
}
},
"power_failure_count": {
diff --git a/homeassistant/components/duke_energy/config_flow.py b/homeassistant/components/duke_energy/config_flow.py
index e06940b0fba..2ec92ff4c12 100644
--- a/homeassistant/components/duke_energy/config_flow.py
+++ b/homeassistant/components/duke_energy/config_flow.py
@@ -50,10 +50,10 @@ class DukeEnergyConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
- username = auth["cdp_internal_user_id"].lower()
+ username = auth["internalUserID"].lower()
await self.async_set_unique_id(username)
self._abort_if_unique_id_configured()
- email = auth["email"].lower()
+ email = auth["loginEmailAddress"].lower()
data = {
CONF_EMAIL: email,
CONF_USERNAME: username,
diff --git a/homeassistant/components/duke_energy/coordinator.py b/homeassistant/components/duke_energy/coordinator.py
index 12a2f5fd6ae..a70c94e6fee 100644
--- a/homeassistant/components/duke_energy/coordinator.py
+++ b/homeassistant/components/duke_energy/coordinator.py
@@ -8,7 +8,11 @@ from aiodukeenergy import DukeEnergy
from aiohttp import ClientError
from homeassistant.components.recorder import get_instance
-from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
+from homeassistant.components.recorder.models import (
+ StatisticData,
+ StatisticMeanType,
+ StatisticMetaData,
+)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
@@ -137,7 +141,7 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
f"Duke Energy {meter['serviceType'].capitalize()} {serial_number}"
)
consumption_metadata = StatisticMetaData(
- has_mean=False,
+ mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} Consumption",
source=DOMAIN,
@@ -175,22 +179,18 @@ class DukeEnergyCoordinator(DataUpdateCoordinator[None]):
one = timedelta(days=1)
if start_time is None:
# Max 3 years of data
- agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"])
- if agreement_date is None:
- start = dt_util.now(tz) - timedelta(days=3 * 365)
- else:
- start = max(
- agreement_date.replace(tzinfo=tz),
- dt_util.now(tz) - timedelta(days=3 * 365),
- )
+ start = dt_util.now(tz) - timedelta(days=3 * 365)
else:
start = datetime.fromtimestamp(start_time, tz=tz) - lookback
+ agreement_date = dt_util.parse_datetime(meter["agreementActiveDate"])
+ if agreement_date is not None:
+ start = max(agreement_date.replace(tzinfo=tz), start)
start = start.replace(hour=0, minute=0, second=0, microsecond=0)
end = dt_util.now(tz).replace(hour=0, minute=0, second=0, microsecond=0) - one
_LOGGER.debug("Data lookup range: %s - %s", start, end)
- start_step = end - lookback
+ start_step = max(end - lookback, start)
end_step = end
usage: dict[datetime, dict[str, float | int]] = {}
while True:
diff --git a/homeassistant/components/duke_energy/manifest.json b/homeassistant/components/duke_energy/manifest.json
index ece18d7ad2a..ad64fdd5cc4 100644
--- a/homeassistant/components/duke_energy/manifest.json
+++ b/homeassistant/components/duke_energy/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["recorder"],
"documentation": "https://www.home-assistant.io/integrations/duke_energy",
"iot_class": "cloud_polling",
- "requirements": ["aiodukeenergy==0.2.2"]
+ "requirements": ["aiodukeenergy==0.3.0"]
}
diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py
index db903cac2bf..b3093221385 100644
--- a/homeassistant/components/dunehd/media_player.py
+++ b/homeassistant/components/dunehd/media_player.py
@@ -17,7 +17,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DuneHDConfigEntry
from .const import ATTR_MANUFACTURER, DEFAULT_NAME, DOMAIN
@@ -39,7 +39,7 @@ DUNEHD_PLAYER_SUPPORT: Final[MediaPlayerEntityFeature] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: DuneHDConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Dune HD entities from a config_entry."""
async_add_entities(
diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py
index aadef47b998..e2431b5eade 100644
--- a/homeassistant/components/duotecno/binary_sensor.py
+++ b/homeassistant/components/duotecno/binary_sensor.py
@@ -6,7 +6,7 @@ from duotecno.unit import ControlUnit, VirtualUnit
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DuotecnoConfigEntry
from .entity import DuotecnoEntity
@@ -15,7 +15,7 @@ from .entity import DuotecnoEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: DuotecnoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Duotecno binary sensor on config_entry."""
async_add_entities(
diff --git a/homeassistant/components/duotecno/climate.py b/homeassistant/components/duotecno/climate.py
index 83a211d97f5..0ae6735feb5 100644
--- a/homeassistant/components/duotecno/climate.py
+++ b/homeassistant/components/duotecno/climate.py
@@ -13,7 +13,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DuotecnoConfigEntry
from .entity import DuotecnoEntity, api_call
@@ -32,7 +32,7 @@ PRESETMODES_REVERSE: Final = {value: key for key, value in PRESETMODES.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: DuotecnoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Duotecno climate based on config_entry."""
async_add_entities(
diff --git a/homeassistant/components/duotecno/cover.py b/homeassistant/components/duotecno/cover.py
index 7d879741555..e184cf7ffb3 100644
--- a/homeassistant/components/duotecno/cover.py
+++ b/homeassistant/components/duotecno/cover.py
@@ -8,7 +8,7 @@ from duotecno.unit import DuoswitchUnit
from homeassistant.components.cover import CoverEntity, CoverEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DuotecnoConfigEntry
from .entity import DuotecnoEntity, api_call
@@ -17,7 +17,7 @@ from .entity import DuotecnoEntity, api_call
async def async_setup_entry(
hass: HomeAssistant,
entry: DuotecnoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the duoswitch endities."""
async_add_entities(
diff --git a/homeassistant/components/duotecno/light.py b/homeassistant/components/duotecno/light.py
index 7b41cbaef22..38617c92748 100644
--- a/homeassistant/components/duotecno/light.py
+++ b/homeassistant/components/duotecno/light.py
@@ -6,7 +6,7 @@ from duotecno.unit import DimUnit
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DuotecnoConfigEntry
from .entity import DuotecnoEntity, api_call
@@ -15,7 +15,7 @@ from .entity import DuotecnoEntity, api_call
async def async_setup_entry(
hass: HomeAssistant,
entry: DuotecnoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Duotecno light based on config_entry."""
async_add_entities(
diff --git a/homeassistant/components/duotecno/switch.py b/homeassistant/components/duotecno/switch.py
index 0c01a6ca4de..cef7715e946 100644
--- a/homeassistant/components/duotecno/switch.py
+++ b/homeassistant/components/duotecno/switch.py
@@ -6,7 +6,7 @@ from duotecno.unit import SwitchUnit
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DuotecnoConfigEntry
from .entity import DuotecnoEntity, api_call
@@ -15,7 +15,7 @@ from .entity import DuotecnoEntity, api_call
async def async_setup_entry(
hass: HomeAssistant,
entry: DuotecnoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
async_add_entities(
diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py
index 0aaf1f2a801..1c2817350a5 100644
--- a/homeassistant/components/dwd_weather_warnings/sensor.py
+++ b/homeassistant/components/dwd_weather_warnings/sensor.py
@@ -16,7 +16,7 @@ from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -55,7 +55,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: DwdWeatherWarningsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities from config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py
index 17adf1947ec..2cd473a2977 100644
--- a/homeassistant/components/dynalite/cover.py
+++ b/homeassistant/components/dynalite/cover.py
@@ -8,7 +8,7 @@ from homeassistant.components.cover import (
CoverEntity,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from .bridge import DynaliteBridge, DynaliteConfigEntry
@@ -18,7 +18,7 @@ from .entity import DynaliteBase, async_setup_entry_base
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DynaliteConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Record the async_add_entities function to add them later when received from Dynalite."""
diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py
index ea2bc2bc96f..e9816c828db 100644
--- a/homeassistant/components/dynalite/light.py
+++ b/homeassistant/components/dynalite/light.py
@@ -4,7 +4,7 @@ from typing import Any
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .bridge import DynaliteConfigEntry
from .entity import DynaliteBase, async_setup_entry_base
@@ -13,7 +13,7 @@ from .entity import DynaliteBase, async_setup_entry_base
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DynaliteConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Record the async_add_entities function to add them later when received from Dynalite."""
async_setup_entry_base(
diff --git a/homeassistant/components/dynalite/strings.json b/homeassistant/components/dynalite/strings.json
index 468cdebf0b1..4f73f91113b 100644
--- a/homeassistant/components/dynalite/strings.json
+++ b/homeassistant/components/dynalite/strings.json
@@ -36,7 +36,7 @@
},
"request_channel_level": {
"name": "Request channel level",
- "description": "Requests Dynalite to report the level of a specific channel.",
+ "description": "Requests Dynalite to report the brightness level of a specific channel.",
"fields": {
"host": {
"name": "[%key:common::config_flow::data::host%]",
@@ -48,7 +48,7 @@
},
"channel": {
"name": "Channel",
- "description": "Channel to request the level for."
+ "description": "Channel to request the brightness level for."
}
}
}
diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py
index dd6aad8670c..29f78ecbc20 100644
--- a/homeassistant/components/dynalite/switch.py
+++ b/homeassistant/components/dynalite/switch.py
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .bridge import DynaliteConfigEntry
from .entity import DynaliteBase, async_setup_entry_base
@@ -14,7 +14,7 @@ from .entity import DynaliteBase, async_setup_entry_base
async def async_setup_entry(
hass: HomeAssistant,
config_entry: DynaliteConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Record the async_add_entities function to add them later when received from Dynalite."""
async_setup_entry_base(
diff --git a/homeassistant/components/eafm/sensor.py b/homeassistant/components/eafm/sensor.py
index d9b18cbc663..5d0af596521 100644
--- a/homeassistant/components/eafm/sensor.py
+++ b/homeassistant/components/eafm/sensor.py
@@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -20,7 +20,7 @@ UNIT_MAPPING = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EafmConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up UK Flood Monitoring Sensors."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/easyenergy/sensor.py b/homeassistant/components/easyenergy/sensor.py
index 6976a38da49..35fab870af3 100644
--- a/homeassistant/components/easyenergy/sensor.py
+++ b/homeassistant/components/easyenergy/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES
@@ -213,7 +213,7 @@ def get_gas_price(data: EasyEnergyData, hours: int) -> float | None:
async def async_setup_entry(
hass: HomeAssistant,
entry: EasyEnergyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up easyEnergy sensors based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/easyenergy/strings.json b/homeassistant/components/easyenergy/strings.json
index 96afffdf78f..502db7920a3 100644
--- a/homeassistant/components/easyenergy/strings.json
+++ b/homeassistant/components/easyenergy/strings.json
@@ -60,12 +60,12 @@
"description": "Requests gas prices from easyEnergy.",
"fields": {
"config_entry": {
- "name": "Config Entry",
+ "name": "Config entry",
"description": "The configuration entry to use for this action."
},
"incl_vat": {
- "name": "VAT Included",
- "description": "Include or exclude VAT in the prices, default is true."
+ "name": "VAT included",
+ "description": "Whether the prices should include VAT."
},
"start": {
"name": "Start",
diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py
index 9c9f2192f43..76b3399ec6e 100644
--- a/homeassistant/components/ecobee/binary_sensor.py
+++ b/homeassistant/components/ecobee/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcobeeConfigEntry
from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER
@@ -17,7 +17,7 @@ from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcobeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ecobee binary (occupancy) sensors."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py
index 743e2e1ba4b..26fb362ba03 100644
--- a/homeassistant/components/ecobee/climate.py
+++ b/homeassistant/components/ecobee/climate.py
@@ -38,7 +38,7 @@ from homeassistant.helpers import (
entity_platform,
)
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
from . import EcobeeConfigEntry, EcobeeData
@@ -204,7 +204,7 @@ SUPPORT_FLAGS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcobeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat."""
diff --git a/homeassistant/components/ecobee/humidifier.py b/homeassistant/components/ecobee/humidifier.py
index 982cbdd07f2..a6f3c16f84a 100644
--- a/homeassistant/components/ecobee/humidifier.py
+++ b/homeassistant/components/ecobee/humidifier.py
@@ -3,18 +3,20 @@
from __future__ import annotations
from datetime import timedelta
+from typing import Any
from homeassistant.components.humidifier import (
DEFAULT_MAX_HUMIDITY,
DEFAULT_MIN_HUMIDITY,
MODE_AUTO,
+ HumidifierAction,
HumidifierDeviceClass,
HumidifierEntity,
HumidifierEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcobeeConfigEntry
from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER
@@ -28,7 +30,7 @@ MODE_OFF = "off"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcobeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat humidifier entity."""
data = config_entry.runtime_data
@@ -41,6 +43,12 @@ async def async_setup_entry(
async_add_entities(entities, True)
+ECOBEE_HUMIDIFIER_ACTION_TO_HASS = {
+ "humidifier": HumidifierAction.HUMIDIFYING,
+ "dehumidifier": HumidifierAction.DRYING,
+}
+
+
class EcobeeHumidifier(HumidifierEntity):
"""A humidifier class for an ecobee thermostat with humidifier attached."""
@@ -52,7 +60,7 @@ class EcobeeHumidifier(HumidifierEntity):
_attr_has_entity_name = True
_attr_name = None
- def __init__(self, data, thermostat_index):
+ def __init__(self, data, thermostat_index) -> None:
"""Initialize ecobee humidifier platform."""
self.data = data
self.thermostat_index = thermostat_index
@@ -80,11 +88,11 @@ class EcobeeHumidifier(HumidifierEntity):
)
@property
- def available(self):
+ def available(self) -> bool:
"""Return if device is available."""
return self.thermostat["runtime"]["connected"]
- async def async_update(self):
+ async def async_update(self) -> None:
"""Get the latest state from the thermostat."""
if self.update_without_throttle:
await self.data.update(no_throttle=True)
@@ -96,12 +104,20 @@ class EcobeeHumidifier(HumidifierEntity):
self._last_humidifier_on_mode = self.mode
@property
- def is_on(self):
+ def action(self) -> HumidifierAction:
+ """Return the current action."""
+ for status in self.thermostat["equipmentStatus"].split(","):
+ if status in ECOBEE_HUMIDIFIER_ACTION_TO_HASS:
+ return ECOBEE_HUMIDIFIER_ACTION_TO_HASS[status]
+ return HumidifierAction.IDLE if self.is_on else HumidifierAction.OFF
+
+ @property
+ def is_on(self) -> bool:
"""Return True if the humidifier is on."""
return self.mode != MODE_OFF
@property
- def mode(self):
+ def mode(self) -> str:
"""Return the current mode, e.g., off, auto, manual."""
return self.thermostat["settings"]["humidifierMode"]
@@ -118,9 +134,11 @@ class EcobeeHumidifier(HumidifierEntity):
except KeyError:
return None
- def set_mode(self, mode):
+ def set_mode(self, mode: str) -> None:
"""Set humidifier mode (auto, off, manual)."""
- if mode.lower() not in (self.available_modes):
+ if self.available_modes is None:
+ raise NotImplementedError("Humidifier does not support modes.")
+ if mode.lower() not in self.available_modes:
raise ValueError(
f"Invalid mode value: {mode} Valid values are"
f" {', '.join(self.available_modes)}."
@@ -134,10 +152,10 @@ class EcobeeHumidifier(HumidifierEntity):
self.data.ecobee.set_humidity(self.thermostat_index, humidity)
self.update_without_throttle = True
- def turn_off(self, **kwargs):
+ def turn_off(self, **kwargs: Any) -> None:
"""Set humidifier to off mode."""
self.set_mode(MODE_OFF)
- def turn_on(self, **kwargs):
+ def turn_on(self, **kwargs: Any) -> None:
"""Set humidifier to on mode."""
self.set_mode(self._last_humidifier_on_mode)
diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py
index 7c70d7ae4ac..2cf6a30acd7 100644
--- a/homeassistant/components/ecobee/notify.py
+++ b/homeassistant/components/ecobee/notify.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from homeassistant.components.notify import NotifyEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcobeeConfigEntry, EcobeeData
from .entity import EcobeeBaseEntity
@@ -13,7 +13,7 @@ from .entity import EcobeeBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcobeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/ecobee/number.py b/homeassistant/components/ecobee/number.py
index f047ea8f896..50e9170394d 100644
--- a/homeassistant/components/ecobee/number.py
+++ b/homeassistant/components/ecobee/number.py
@@ -14,7 +14,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcobeeConfigEntry, EcobeeData
from .entity import EcobeeBaseEntity
@@ -53,7 +53,7 @@ VENTILATOR_NUMBERS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcobeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat number entity."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py
index 1b50fc21edf..759f167ec1c 100644
--- a/homeassistant/components/ecobee/sensor.py
+++ b/homeassistant/components/ecobee/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcobeeConfigEntry
from .const import DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER
@@ -74,7 +74,7 @@ SENSOR_TYPES: tuple[EcobeeSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcobeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ecobee sensors."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json
index 2b44c45edef..bc61cb444c1 100644
--- a/homeassistant/components/ecobee/strings.json
+++ b/homeassistant/components/ecobee/strings.json
@@ -25,7 +25,7 @@
"state_attributes": {
"preset_mode": {
"state": {
- "away_indefinitely": "Away Indefinitely"
+ "away_indefinitely": "Away indefinitely"
}
}
}
@@ -55,7 +55,7 @@
"fields": {
"entity_id": {
"name": "Entity",
- "description": "Ecobee thermostat on which to create the vacation."
+ "description": "ecobee thermostat on which to create the vacation."
},
"vacation_name": {
"name": "Vacation name",
@@ -91,7 +91,7 @@
},
"fan_min_on_time": {
"name": "Fan minimum on time",
- "description": "Minimum number of minutes to run the fan each hour (0 to 60) during the vacation."
+ "description": "Minimum number of minutes to run the fan each hour during the vacation."
}
}
},
@@ -101,7 +101,7 @@
"fields": {
"entity_id": {
"name": "Entity",
- "description": "Ecobee thermostat on which to delete the vacation."
+ "description": "ecobee thermostat on which to delete the vacation."
},
"vacation_name": {
"name": "[%key:component::ecobee::services::create_vacation::fields::vacation_name::name%]",
@@ -125,7 +125,7 @@
},
"set_fan_min_on_time": {
"name": "Set fan minimum on time",
- "description": "Sets the minimum fan on time.",
+ "description": "Sets the minimum amount of time that the fan will run per hour.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -133,7 +133,7 @@
},
"fan_min_on_time": {
"name": "[%key:component::ecobee::services::create_vacation::fields::fan_min_on_time::name%]",
- "description": "New value of fan min on time."
+ "description": "Minimum number of minutes to run the fan each hour."
}
}
},
@@ -149,7 +149,7 @@
},
"set_mic_mode": {
"name": "Set mic mode",
- "description": "Enables/disables Alexa microphone (only for Ecobee 4).",
+ "description": "Enables/disables Alexa microphone (only for ecobee 4).",
"fields": {
"mic_enabled": {
"name": "Mic enabled",
@@ -177,7 +177,7 @@
"fields": {
"entity_id": {
"name": "Entity",
- "description": "Ecobee thermostat on which to set active sensors."
+ "description": "ecobee thermostat on which to set active sensors."
},
"preset_mode": {
"name": "Climate Name",
@@ -203,12 +203,12 @@
},
"issues": {
"migrate_aux_heat": {
- "title": "Migration of Ecobee set_aux_heat action",
+ "title": "Migration of ecobee set_aux_heat action",
"fix_flow": {
"step": {
"confirm": {
- "description": "The Ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.",
- "title": "Disable legacy Ecobee set_aux_heat action"
+ "description": "The ecobee `set_aux_heat` action has been migrated. A new `aux_heat_only` switch entity is available for each thermostat that supports a Heat Pump.\n\nUpdate any automations to use the new `aux_heat_only` switch entity. When this is done, fix this issue and restart Home Assistant.",
+ "title": "Disable legacy ecobee set_aux_heat action"
}
}
}
diff --git a/homeassistant/components/ecobee/switch.py b/homeassistant/components/ecobee/switch.py
index c92082b7b58..e0848913b39 100644
--- a/homeassistant/components/ecobee/switch.py
+++ b/homeassistant/components/ecobee/switch.py
@@ -9,7 +9,7 @@ from typing import Any
from homeassistant.components.climate import HVACMode
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import EcobeeConfigEntry, EcobeeData
@@ -25,7 +25,7 @@ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcobeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat switch entity."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py
index 39b2d30ddd8..126f8dd5061 100644
--- a/homeassistant/components/ecobee/weather.py
+++ b/homeassistant/components/ecobee/weather.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import EcobeeConfigEntry
@@ -40,7 +40,7 @@ from .const import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcobeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ecobee weather platform."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/ecoforest/number.py b/homeassistant/components/ecoforest/number.py
index 878c150343e..c1d5f5f3055 100644
--- a/homeassistant/components/ecoforest/number.py
+++ b/homeassistant/components/ecoforest/number.py
@@ -9,7 +9,7 @@ from pyecoforest.models.device import Device
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EcoforestConfigEntry
from .entity import EcoforestEntity
@@ -37,7 +37,7 @@ NUMBER_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcoforestConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ecoforest number platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py
index 0babb476ab6..d0e4c17abe1 100644
--- a/homeassistant/components/ecoforest/sensor.py
+++ b/homeassistant/components/ecoforest/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import EcoforestConfigEntry
@@ -132,7 +132,7 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = (
),
EcoforestSensorEntityDescription(
key="convecto_air_flow",
- translation_key="convecto_air_flow",
+ translation_key="convector_air_flow",
native_unit_of_measurement=PERCENTAGE,
entity_registry_enabled_default=False,
value_fn=lambda data: data.convecto_air_flow,
@@ -143,7 +143,7 @@ SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: EcoforestConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ecoforest sensor platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json
index 1094e10ada3..d0e807b5f2a 100644
--- a/homeassistant/components/ecoforest/strings.json
+++ b/homeassistant/components/ecoforest/strings.json
@@ -78,8 +78,8 @@
"extractor": {
"name": "Extractor"
},
- "convecto_air_flow": {
- "name": "Convecto air flow"
+ "convector_air_flow": {
+ "name": "Convector air flow"
}
},
"number": {
diff --git a/homeassistant/components/ecoforest/switch.py b/homeassistant/components/ecoforest/switch.py
index de52248e751..bd83bfc9ee5 100644
--- a/homeassistant/components/ecoforest/switch.py
+++ b/homeassistant/components/ecoforest/switch.py
@@ -11,7 +11,7 @@ from pyecoforest.models.device import Device
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EcoforestConfigEntry
from .entity import EcoforestEntity
@@ -38,7 +38,7 @@ SWITCH_TYPES: tuple[EcoforestSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcoforestConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ecoforest switch platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py
index 13ef8c4713b..0d041dfca5a 100644
--- a/homeassistant/components/econet/binary_sensor.py
+++ b/homeassistant/components/econet/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EconetConfigEntry
from .entity import EcoNetEntity
@@ -42,7 +42,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: EconetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EcoNet binary sensor based on a config entry."""
equipment = entry.runtime_data
diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py
index d46dbd8750a..56a98c8d630 100644
--- a/homeassistant/components/econet/climate.py
+++ b/homeassistant/components/econet/climate.py
@@ -22,11 +22,9 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EconetConfigEntry
-from .const import DOMAIN
from .entity import EcoNetEntity
ECONET_STATE_TO_HA = {
@@ -35,8 +33,13 @@ ECONET_STATE_TO_HA = {
ThermostatOperationMode.OFF: HVACMode.OFF,
ThermostatOperationMode.AUTO: HVACMode.HEAT_COOL,
ThermostatOperationMode.FAN_ONLY: HVACMode.FAN_ONLY,
+ ThermostatOperationMode.EMERGENCY_HEAT: HVACMode.HEAT,
+}
+HA_STATE_TO_ECONET = {
+ value: key
+ for key, value in ECONET_STATE_TO_HA.items()
+ if key != ThermostatOperationMode.EMERGENCY_HEAT
}
-HA_STATE_TO_ECONET = {value: key for key, value in ECONET_STATE_TO_HA.items()}
ECONET_FAN_STATE_TO_HA = {
ThermostatFanMode.AUTO: FAN_AUTO,
@@ -57,7 +60,7 @@ SUPPORT_FLAGS_THERMOSTAT = (
async def async_setup_entry(
hass: HomeAssistant,
entry: EconetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EcoNet thermostat based on a config entry."""
equipment = entry.runtime_data
@@ -207,34 +210,6 @@ class EcoNetThermostat(EcoNetEntity[Thermostat], ClimateEntity):
"""Set the fan mode."""
self._econet.set_fan_mode(HA_FAN_STATE_TO_ECONET[fan_mode])
- def turn_aux_heat_on(self) -> None:
- """Turn auxiliary heater on."""
- async_create_issue(
- self.hass,
- DOMAIN,
- "migrate_aux_heat",
- breaks_in_ha_version="2025.4.0",
- is_fixable=True,
- is_persistent=True,
- translation_key="migrate_aux_heat",
- severity=IssueSeverity.WARNING,
- )
- self._econet.set_mode(ThermostatOperationMode.EMERGENCY_HEAT)
-
- def turn_aux_heat_off(self) -> None:
- """Turn auxiliary heater off."""
- async_create_issue(
- self.hass,
- DOMAIN,
- "migrate_aux_heat",
- breaks_in_ha_version="2025.4.0",
- is_fixable=True,
- is_persistent=True,
- translation_key="migrate_aux_heat",
- severity=IssueSeverity.WARNING,
- )
- self._econet.set_mode(ThermostatOperationMode.HEATING)
-
@property
def min_temp(self):
"""Return the minimum temperature."""
diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json
index bda52ee3d07..bc7505740d7 100644
--- a/homeassistant/components/econet/manifest.json
+++ b/homeassistant/components/econet/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/econet",
"iot_class": "cloud_push",
"loggers": ["paho_mqtt", "pyeconet"],
- "requirements": ["pyeconet==0.1.26"]
+ "requirements": ["pyeconet==0.1.28"]
}
diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py
index 510906d699c..1cc806ca8d5 100644
--- a/homeassistant/components/econet/sensor.py
+++ b/homeassistant/components/econet/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EconetConfigEntry
from .entity import EcoNetEntity
@@ -83,7 +83,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: EconetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EcoNet sensor based on a config entry."""
diff --git a/homeassistant/components/econet/switch.py b/homeassistant/components/econet/switch.py
index 9fcd38c860e..ff7f017b49f 100644
--- a/homeassistant/components/econet/switch.py
+++ b/homeassistant/components/econet/switch.py
@@ -10,7 +10,7 @@ from pyeconet.equipment.thermostat import Thermostat, ThermostatOperationMode
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EconetConfigEntry
from .entity import EcoNetEntity
@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: EconetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ecobee thermostat switch entity."""
equipment = entry.runtime_data
diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py
index cfbff70b580..f93ad7f8872 100644
--- a/homeassistant/components/econet/water_heater.py
+++ b/homeassistant/components/econet/water_heater.py
@@ -19,7 +19,7 @@ from homeassistant.components.water_heater import (
)
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EconetConfigEntry
from .entity import EcoNetEntity
@@ -48,7 +48,7 @@ SUPPORT_FLAGS_HEATER = (
async def async_setup_entry(
hass: HomeAssistant,
entry: EconetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EcoNet water heater based on a config entry."""
equipment = entry.runtime_data
@@ -91,15 +91,15 @@ class EcoNetWaterHeater(EcoNetEntity[WaterHeater], WaterHeaterEntity):
def operation_list(self) -> list[str]:
"""List of available operation modes."""
econet_modes = self.water_heater.modes
- op_list = []
+ operation_modes = set()
for mode in econet_modes:
if (
mode is not WaterHeaterOperationMode.UNKNOWN
and mode is not WaterHeaterOperationMode.VACATION
):
ha_mode = ECONET_STATE_TO_HA[mode]
- op_list.append(ha_mode)
- return op_list
+ operation_modes.add(ha_mode)
+ return list(operation_modes)
@property
def supported_features(self) -> WaterHeaterEntityFeature:
diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py
index d755d01a4ae..552a8152cc5 100644
--- a/homeassistant/components/ecovacs/binary_sensor.py
+++ b/homeassistant/components/ecovacs/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
@@ -45,7 +45,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcovacsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add entities for passed config_entry in HA."""
async_add_entities(
diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py
index 2759ca972df..04eb0af02e6 100644
--- a/homeassistant/components/ecovacs/button.py
+++ b/homeassistant/components/ecovacs/button.py
@@ -13,7 +13,7 @@ from deebot_client.events import LifeSpan
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .const import SUPPORTED_LIFESPANS, SUPPORTED_STATION_ACTIONS
@@ -81,7 +81,7 @@ LIFESPAN_ENTITY_DESCRIPTIONS = tuple(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcovacsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add entities for passed config_entry in HA."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/ecovacs/event.py b/homeassistant/components/ecovacs/event.py
index 3249b466c77..488767b472d 100644
--- a/homeassistant/components/ecovacs/event.py
+++ b/homeassistant/components/ecovacs/event.py
@@ -7,7 +7,7 @@ from deebot_client.events import CleanJobStatus, ReportStatsEvent
from homeassistant.components.event import EventEntity, EventEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .entity import EcovacsEntity
@@ -17,7 +17,7 @@ from .util import get_name_key
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcovacsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add entities for passed config_entry in HA."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py
index d8b69084cec..f8a89b0cfa0 100644
--- a/homeassistant/components/ecovacs/image.py
+++ b/homeassistant/components/ecovacs/image.py
@@ -7,7 +7,7 @@ from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent
from homeassistant.components.image import ImageEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .entity import EcovacsEntity
@@ -16,7 +16,7 @@ from .entity import EcovacsEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcovacsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add entities for passed config_entry in HA."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/ecovacs/lawn_mower.py b/homeassistant/components/ecovacs/lawn_mower.py
index bf773207dc5..b9af67fafcd 100644
--- a/homeassistant/components/ecovacs/lawn_mower.py
+++ b/homeassistant/components/ecovacs/lawn_mower.py
@@ -16,7 +16,7 @@ from homeassistant.components.lawn_mower import (
LawnMowerEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .entity import EcovacsEntity
@@ -37,7 +37,7 @@ _STATE_TO_MOWER_STATE = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcovacsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ecovacs mowers."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json
index 33a251c22dc..ad8b3ea70a5 100644
--- a/homeassistant/components/ecovacs/manifest.json
+++ b/homeassistant/components/ecovacs/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
- "requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"]
+ "requirements": ["py-sucks==0.9.10", "deebot-client==12.5.0"]
}
diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py
index adf282560a9..7a74b02ceca 100644
--- a/homeassistant/components/ecovacs/number.py
+++ b/homeassistant/components/ecovacs/number.py
@@ -16,7 +16,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import DEGREE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .entity import (
@@ -83,7 +83,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcovacsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add entities for passed config_entry in HA."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py
index 3c3852f05ec..a7b9baf1c4a 100644
--- a/homeassistant/components/ecovacs/select.py
+++ b/homeassistant/components/ecovacs/select.py
@@ -11,7 +11,7 @@ from deebot_client.events import WaterInfoEvent, WorkModeEvent
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .entity import EcovacsCapabilityEntityDescription, EcovacsDescriptionEntity, EventT
@@ -54,7 +54,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcovacsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add entities for passed config_entry in HA."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py
index 0e906c6cb16..6c8ae080fc3 100644
--- a/homeassistant/components/ecovacs/sensor.py
+++ b/homeassistant/components/ecovacs/sensor.py
@@ -35,7 +35,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import EcovacsConfigEntry
@@ -192,7 +192,7 @@ LEGACY_LIFESPAN_SENSORS = tuple(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcovacsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add entities for passed config_entry in HA."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/ecovacs/strings.json b/homeassistant/components/ecovacs/strings.json
index 723bdef17f8..1be81ab1292 100644
--- a/homeassistant/components/ecovacs/strings.json
+++ b/homeassistant/components/ecovacs/strings.json
@@ -14,7 +14,7 @@
"step": {
"auth": {
"data": {
- "country": "Country",
+ "country": "[%key:common::config_flow::data::country%]",
"override_rest_url": "REST URL",
"override_mqtt_url": "MQTT URL",
"password": "[%key:common::config_flow::data::password%]",
@@ -176,9 +176,9 @@
"water_amount": {
"name": "Water flow level",
"state": {
- "high": "High",
- "low": "Low",
- "medium": "Medium",
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
"ultrahigh": "Ultrahigh"
}
},
@@ -229,9 +229,9 @@
"state_attributes": {
"fan_speed": {
"state": {
+ "normal": "[%key:common::state::normal%]",
"max": "Max",
"max_plus": "Max+",
- "normal": "Normal",
"quiet": "Quiet"
}
},
@@ -250,7 +250,7 @@
"message": "Params are required for the command: {command}"
},
"vacuum_raw_get_positions_not_supported": {
- "message": "Getting the positions of the chargers and the device itself is not supported"
+ "message": "Retrieving the positions of the chargers and the device itself is not supported"
}
},
"selector": {
@@ -264,7 +264,7 @@
"services": {
"raw_get_positions": {
"name": "Get raw positions",
- "description": "Get the raw response for the positions of the chargers and the device itself."
+ "description": "Retrieves a raw response containing the positions of the chargers and the device itself."
}
}
}
diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py
index 288d092d391..dd379dbb199 100644
--- a/homeassistant/components/ecovacs/switch.py
+++ b/homeassistant/components/ecovacs/switch.py
@@ -9,7 +9,7 @@ from deebot_client.events import EnableEvent
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcovacsConfigEntry
from .entity import (
@@ -105,7 +105,7 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcovacsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add entities for passed config_entry in HA."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py
index bc78981d1db..6570b80e920 100644
--- a/homeassistant/components/ecovacs/vacuum.py
+++ b/homeassistant/components/ecovacs/vacuum.py
@@ -21,7 +21,7 @@ from homeassistant.components.vacuum import (
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.util import slugify
@@ -41,7 +41,7 @@ SERVICE_RAW_GET_POSITIONS = "raw_get_positions"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EcovacsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ecovacs vacuums."""
diff --git a/homeassistant/components/ecowitt/binary_sensor.py b/homeassistant/components/ecowitt/binary_sensor.py
index 5bc782e3589..1d36f5232db 100644
--- a/homeassistant/components/ecowitt/binary_sensor.py
+++ b/homeassistant/components/ecowitt/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EcowittConfigEntry
from .entity import EcowittEntity
@@ -26,13 +26,16 @@ ECOWITT_BINARYSENSORS_MAPPING: Final = {
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
),
+ EcoWittSensorTypes.RAIN_STATE: BinarySensorEntityDescription(
+ key="RAIN_STATE", device_class=BinarySensorDeviceClass.MOISTURE
+ ),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: EcowittConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add sensors if new."""
ecowitt = entry.runtime_data
diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json
index 175960ab57d..3ce66f48f95 100644
--- a/homeassistant/components/ecowitt/manifest.json
+++ b/homeassistant/components/ecowitt/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
"iot_class": "local_push",
- "requirements": ["aioecowitt==2024.2.1"]
+ "requirements": ["aioecowitt==2025.3.1"]
}
diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py
index 23af2f2a3af..7d37aa40b86 100644
--- a/homeassistant/components/ecowitt/sensor.py
+++ b/homeassistant/components/ecowitt/sensor.py
@@ -32,7 +32,7 @@ from homeassistant.const import (
UnitOfVolumetricFlux,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM
@@ -65,7 +65,10 @@ ECOWITT_SENSORS_MAPPING: Final = {
state_class=SensorStateClass.MEASUREMENT,
),
EcoWittSensorTypes.DEGREE: SensorEntityDescription(
- key="DEGREE", native_unit_of_measurement=DEGREE
+ key="DEGREE",
+ native_unit_of_measurement=DEGREE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
EcoWittSensorTypes.WATT_METERS_SQUARED: SensorEntityDescription(
key="WATT_METERS_SQUARED",
@@ -218,7 +221,7 @@ ECOWITT_SENSORS_MAPPING: Final = {
async def async_setup_entry(
hass: HomeAssistant,
entry: EcowittConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add sensors if new."""
ecowitt = entry.runtime_data
diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py
index 62d06a8a535..3194781d71c 100644
--- a/homeassistant/components/edl21/sensor.py
+++ b/homeassistant/components/edl21/sensor.py
@@ -30,7 +30,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from .const import (
@@ -289,7 +289,7 @@ SENSOR_UNIT_MAPPING = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the EDL21 sensor."""
api = EDL21(hass, config_entry.data, async_add_entities)
@@ -317,7 +317,7 @@ class EDL21:
self,
hass: HomeAssistant,
config: Mapping[str, Any],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize an EDL21 object."""
self._registered_obis: set[tuple[str, str]] = set()
diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py
index 419c4da591d..6b54e4779a0 100644
--- a/homeassistant/components/efergy/sensor.py
+++ b/homeassistant/components/efergy/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import EfergyConfigEntry
@@ -108,7 +108,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: EfergyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Efergy sensors."""
api = entry.runtime_data
diff --git a/homeassistant/components/eheimdigital/__init__.py b/homeassistant/components/eheimdigital/__init__.py
index 26e6bea4d4a..77e722f3e0c 100644
--- a/homeassistant/components/eheimdigital/__init__.py
+++ b/homeassistant/components/eheimdigital/__init__.py
@@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
-PLATFORMS = [Platform.CLIMATE, Platform.LIGHT]
+PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.NUMBER, Platform.SENSOR]
async def async_setup_entry(
diff --git a/homeassistant/components/eheimdigital/climate.py b/homeassistant/components/eheimdigital/climate.py
index f0038982965..3cde9e758cd 100644
--- a/homeassistant/components/eheimdigital/climate.py
+++ b/homeassistant/components/eheimdigital/climate.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import HEATER_BIO_MODE, HEATER_PRESET_TO_HEATER_MODE, HEATER_SMART_MODE
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
@@ -34,18 +34,16 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: EheimDigitalConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the callbacks for the coordinator so climate entities can be added as devices are found."""
coordinator = entry.runtime_data
def async_setup_device_entities(
- device_address: str | dict[str, EheimDigitalDevice],
+ device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the climate entities for one or multiple devices."""
entities: list[EheimDigitalHeaterClimate] = []
- if isinstance(device_address, str):
- device_address = {device_address: coordinator.hub.devices[device_address]}
for device in device_address.values():
if isinstance(device, EheimDigitalHeater):
entities.append(EheimDigitalHeaterClimate(coordinator, device))
diff --git a/homeassistant/components/eheimdigital/config_flow.py b/homeassistant/components/eheimdigital/config_flow.py
index c6535608b0c..b0432267c8e 100644
--- a/homeassistant/components/eheimdigital/config_flow.py
+++ b/homeassistant/components/eheimdigital/config_flow.py
@@ -62,6 +62,7 @@ class EheimDigitalConfigFlow(ConfigFlow, domain=DOMAIN):
except (ClientError, TimeoutError):
return self.async_abort(reason="cannot_connect")
except Exception: # noqa: BLE001
+ LOGGER.exception("Unknown exception occurred")
return self.async_abort(reason="unknown")
await self.async_set_unique_id(hub.main.mac_address)
self._abort_if_unique_id_configured(updates={CONF_HOST: host})
diff --git a/homeassistant/components/eheimdigital/coordinator.py b/homeassistant/components/eheimdigital/coordinator.py
index 4359a314494..df5475b6567 100644
--- a/homeassistant/components/eheimdigital/coordinator.py
+++ b/homeassistant/components/eheimdigital/coordinator.py
@@ -2,25 +2,25 @@
from __future__ import annotations
+import asyncio
from collections.abc import Callable
from aiohttp import ClientError
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.hub import EheimDigitalHub
-from eheimdigital.types import EheimDeviceType
+from eheimdigital.types import EheimDeviceType, EheimDigitalClientError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, LOGGER
-type AsyncSetupDeviceEntitiesCallback = Callable[
- [str | dict[str, EheimDigitalDevice]], None
-]
+type AsyncSetupDeviceEntitiesCallback = Callable[[dict[str, EheimDigitalDevice]], None]
type EheimDigitalConfigEntry = ConfigEntry[EheimDigitalUpdateCoordinator]
@@ -43,12 +43,14 @@ class EheimDigitalUpdateCoordinator(
name=DOMAIN,
update_interval=DEFAULT_SCAN_INTERVAL,
)
+ self.main_device_added_event = asyncio.Event()
self.hub = EheimDigitalHub(
host=self.config_entry.data[CONF_HOST],
session=async_get_clientsession(hass),
loop=hass.loop,
receive_callback=self._async_receive_callback,
device_found_callback=self._async_device_found,
+ main_device_added_event=self.main_device_added_event,
)
self.known_devices: set[str] = set()
self.platform_callbacks: set[AsyncSetupDeviceEntitiesCallback] = set()
@@ -70,14 +72,23 @@ class EheimDigitalUpdateCoordinator(
if device_address not in self.known_devices:
for platform_callback in self.platform_callbacks:
- platform_callback(device_address)
+ platform_callback({device_address: self.hub.devices[device_address]})
async def _async_receive_callback(self) -> None:
self.async_set_updated_data(self.hub.devices)
async def _async_setup(self) -> None:
- await self.hub.connect()
- await self.hub.update()
+ try:
+ await self.hub.connect()
+ async with asyncio.timeout(2):
+ # This event gets triggered when the first message is received from
+ # the device, it contains the data necessary to create the main device.
+ # This removes the race condition where the main device is accessed
+ # before the response from the device is parsed.
+ await self.main_device_added_event.wait()
+ await self.hub.update()
+ except (TimeoutError, EheimDigitalClientError) as err:
+ raise ConfigEntryNotReady from err
async def _async_update_data(self) -> dict[str, EheimDigitalDevice]:
try:
diff --git a/homeassistant/components/eheimdigital/icons.json b/homeassistant/components/eheimdigital/icons.json
new file mode 100644
index 00000000000..428e383dd83
--- /dev/null
+++ b/homeassistant/components/eheimdigital/icons.json
@@ -0,0 +1,35 @@
+{
+ "entity": {
+ "number": {
+ "manual_speed": {
+ "default": "mdi:pump"
+ },
+ "day_speed": {
+ "default": "mdi:weather-sunny"
+ },
+ "night_speed": {
+ "default": "mdi:moon-waning-crescent"
+ },
+ "temperature_offset": {
+ "default": "mdi:thermometer"
+ },
+ "night_temperature_offset": {
+ "default": "mdi:thermometer"
+ }
+ },
+ "sensor": {
+ "current_speed": {
+ "default": "mdi:pump"
+ },
+ "service_hours": {
+ "default": "mdi:wrench-clock"
+ },
+ "error_code": {
+ "default": "mdi:alert-octagon",
+ "state": {
+ "no_error": "mdi:check-circle"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/eheimdigital/light.py b/homeassistant/components/eheimdigital/light.py
index 25498cf3af1..2725315befd 100644
--- a/homeassistant/components/eheimdigital/light.py
+++ b/homeassistant/components/eheimdigital/light.py
@@ -16,7 +16,7 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import brightness_to_value, value_to_brightness
from .const import EFFECT_DAYCL_MODE, EFFECT_TO_LIGHT_MODE
@@ -32,18 +32,16 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: EheimDigitalConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the callbacks for the coordinator so lights can be added as devices are found."""
coordinator = entry.runtime_data
def async_setup_device_entities(
- device_address: str | dict[str, EheimDigitalDevice],
+ device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalClassicLEDControlLight] = []
- if isinstance(device_address, str):
- device_address = {device_address: coordinator.hub.devices[device_address]}
for device in device_address.values():
if isinstance(device, EheimDigitalClassicLEDControl):
for channel in range(2):
diff --git a/homeassistant/components/eheimdigital/manifest.json b/homeassistant/components/eheimdigital/manifest.json
index 1d1ca6f84c7..c3c8a251300 100644
--- a/homeassistant/components/eheimdigital/manifest.json
+++ b/homeassistant/components/eheimdigital/manifest.json
@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["eheimdigital"],
"quality_scale": "bronze",
- "requirements": ["eheimdigital==1.0.6"],
+ "requirements": ["eheimdigital==1.1.0"],
"zeroconf": [
{ "type": "_http._tcp.local.", "name": "eheimdigital._http._tcp.local." }
]
diff --git a/homeassistant/components/eheimdigital/number.py b/homeassistant/components/eheimdigital/number.py
new file mode 100644
index 00000000000..f4504be624c
--- /dev/null
+++ b/homeassistant/components/eheimdigital/number.py
@@ -0,0 +1,177 @@
+"""EHEIM Digital numbers."""
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Generic, TypeVar, override
+
+from eheimdigital.classic_vario import EheimDigitalClassicVario
+from eheimdigital.device import EheimDigitalDevice
+from eheimdigital.heater import EheimDigitalHeater
+from eheimdigital.types import HeaterUnit
+
+from homeassistant.components.number import (
+ NumberDeviceClass,
+ NumberEntity,
+ NumberEntityDescription,
+)
+from homeassistant.const import (
+ PERCENTAGE,
+ PRECISION_HALVES,
+ PRECISION_TENTHS,
+ PRECISION_WHOLE,
+ EntityCategory,
+ UnitOfTemperature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
+from .entity import EheimDigitalEntity
+
+PARALLEL_UPDATES = 0
+
+_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
+
+
+@dataclass(frozen=True, kw_only=True)
+class EheimDigitalNumberDescription(NumberEntityDescription, Generic[_DeviceT_co]):
+ """Class describing EHEIM Digital sensor entities."""
+
+ value_fn: Callable[[_DeviceT_co], float | None]
+ set_value_fn: Callable[[_DeviceT_co, float], Awaitable[None]]
+ uom_fn: Callable[[_DeviceT_co], str] | None = None
+
+
+CLASSICVARIO_DESCRIPTIONS: tuple[
+ EheimDigitalNumberDescription[EheimDigitalClassicVario], ...
+] = (
+ EheimDigitalNumberDescription[EheimDigitalClassicVario](
+ key="manual_speed",
+ translation_key="manual_speed",
+ entity_category=EntityCategory.CONFIG,
+ native_step=PRECISION_WHOLE,
+ native_unit_of_measurement=PERCENTAGE,
+ value_fn=lambda device: device.manual_speed,
+ set_value_fn=lambda device, value: device.set_manual_speed(int(value)),
+ ),
+ EheimDigitalNumberDescription[EheimDigitalClassicVario](
+ key="day_speed",
+ translation_key="day_speed",
+ entity_category=EntityCategory.CONFIG,
+ native_step=PRECISION_WHOLE,
+ native_unit_of_measurement=PERCENTAGE,
+ value_fn=lambda device: device.day_speed,
+ set_value_fn=lambda device, value: device.set_day_speed(int(value)),
+ ),
+ EheimDigitalNumberDescription[EheimDigitalClassicVario](
+ key="night_speed",
+ translation_key="night_speed",
+ entity_category=EntityCategory.CONFIG,
+ native_step=PRECISION_WHOLE,
+ native_unit_of_measurement=PERCENTAGE,
+ value_fn=lambda device: device.night_speed,
+ set_value_fn=lambda device, value: device.set_night_speed(int(value)),
+ ),
+)
+
+HEATER_DESCRIPTIONS: tuple[EheimDigitalNumberDescription[EheimDigitalHeater], ...] = (
+ EheimDigitalNumberDescription[EheimDigitalHeater](
+ key="temperature_offset",
+ translation_key="temperature_offset",
+ entity_category=EntityCategory.CONFIG,
+ native_min_value=-3,
+ native_max_value=3,
+ native_step=PRECISION_TENTHS,
+ device_class=NumberDeviceClass.TEMPERATURE,
+ uom_fn=lambda device: (
+ UnitOfTemperature.CELSIUS
+ if device.temperature_unit is HeaterUnit.CELSIUS
+ else UnitOfTemperature.FAHRENHEIT
+ ),
+ value_fn=lambda device: device.temperature_offset,
+ set_value_fn=lambda device, value: device.set_temperature_offset(value),
+ ),
+ EheimDigitalNumberDescription[EheimDigitalHeater](
+ key="night_temperature_offset",
+ translation_key="night_temperature_offset",
+ entity_category=EntityCategory.CONFIG,
+ native_min_value=-5,
+ native_max_value=5,
+ native_step=PRECISION_HALVES,
+ device_class=NumberDeviceClass.TEMPERATURE,
+ uom_fn=lambda device: (
+ UnitOfTemperature.CELSIUS
+ if device.temperature_unit is HeaterUnit.CELSIUS
+ else UnitOfTemperature.FAHRENHEIT
+ ),
+ value_fn=lambda device: device.night_temperature_offset,
+ set_value_fn=lambda device, value: device.set_night_temperature_offset(value),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: EheimDigitalConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the callbacks for the coordinator so numbers can be added as devices are found."""
+ coordinator = entry.runtime_data
+
+ def async_setup_device_entities(
+ device_address: dict[str, EheimDigitalDevice],
+ ) -> None:
+ """Set up the number entities for one or multiple devices."""
+ entities: list[EheimDigitalNumber[EheimDigitalDevice]] = []
+ for device in device_address.values():
+ if isinstance(device, EheimDigitalClassicVario):
+ entities.extend(
+ EheimDigitalNumber[EheimDigitalClassicVario](
+ coordinator, device, description
+ )
+ for description in CLASSICVARIO_DESCRIPTIONS
+ )
+ if isinstance(device, EheimDigitalHeater):
+ entities.extend(
+ EheimDigitalNumber[EheimDigitalHeater](
+ coordinator, device, description
+ )
+ for description in HEATER_DESCRIPTIONS
+ )
+
+ async_add_entities(entities)
+
+ coordinator.add_platform_callback(async_setup_device_entities)
+ async_setup_device_entities(coordinator.hub.devices)
+
+
+class EheimDigitalNumber(
+ EheimDigitalEntity[_DeviceT_co], NumberEntity, Generic[_DeviceT_co]
+):
+ """Represent a EHEIM Digital number entity."""
+
+ entity_description: EheimDigitalNumberDescription[_DeviceT_co]
+
+ def __init__(
+ self,
+ coordinator: EheimDigitalUpdateCoordinator,
+ device: _DeviceT_co,
+ description: EheimDigitalNumberDescription[_DeviceT_co],
+ ) -> None:
+ """Initialize an EHEIM Digital number entity."""
+ super().__init__(coordinator, device)
+ self.entity_description = description
+ self._attr_unique_id = f"{self._device_address}_{description.key}"
+
+ @override
+ async def async_set_native_value(self, value: float) -> None:
+ return await self.entity_description.set_value_fn(self._device, value)
+
+ @override
+ def _async_update_attrs(self) -> None:
+ self._attr_native_value = self.entity_description.value_fn(self._device)
+ self._attr_native_unit_of_measurement = (
+ self.entity_description.uom_fn(self._device)
+ if self.entity_description.uom_fn
+ else self.entity_description.native_unit_of_measurement
+ )
diff --git a/homeassistant/components/eheimdigital/sensor.py b/homeassistant/components/eheimdigital/sensor.py
new file mode 100644
index 00000000000..3d809cc14dc
--- /dev/null
+++ b/homeassistant/components/eheimdigital/sensor.py
@@ -0,0 +1,114 @@
+"""EHEIM Digital sensors."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Generic, TypeVar, override
+
+from eheimdigital.classic_vario import EheimDigitalClassicVario
+from eheimdigital.device import EheimDigitalDevice
+from eheimdigital.types import FilterErrorCode
+
+from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
+from homeassistant.components.sensor.const import SensorDeviceClass
+from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
+from .entity import EheimDigitalEntity
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
+
+
+@dataclass(frozen=True, kw_only=True)
+class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]):
+ """Class describing EHEIM Digital sensor entities."""
+
+ value_fn: Callable[[_DeviceT_co], float | str | None]
+
+
+CLASSICVARIO_DESCRIPTIONS: tuple[
+ EheimDigitalSensorDescription[EheimDigitalClassicVario], ...
+] = (
+ EheimDigitalSensorDescription[EheimDigitalClassicVario](
+ key="current_speed",
+ translation_key="current_speed",
+ value_fn=lambda device: device.current_speed,
+ native_unit_of_measurement=PERCENTAGE,
+ ),
+ EheimDigitalSensorDescription[EheimDigitalClassicVario](
+ key="service_hours",
+ translation_key="service_hours",
+ value_fn=lambda device: device.service_hours,
+ device_class=SensorDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.HOURS,
+ suggested_unit_of_measurement=UnitOfTime.DAYS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ EheimDigitalSensorDescription[EheimDigitalClassicVario](
+ key="error_code",
+ translation_key="error_code",
+ value_fn=(
+ lambda device: device.error_code.name.lower()
+ if device.error_code is not None
+ else None
+ ),
+ device_class=SensorDeviceClass.ENUM,
+ options=[name.lower() for name in FilterErrorCode._member_names_],
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: EheimDigitalConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the callbacks for the coordinator so lights can be added as devices are found."""
+ coordinator = entry.runtime_data
+
+ def async_setup_device_entities(
+ device_address: dict[str, EheimDigitalDevice],
+ ) -> None:
+ """Set up the light entities for one or multiple devices."""
+ entities: list[EheimDigitalSensor[EheimDigitalDevice]] = []
+ for device in device_address.values():
+ if isinstance(device, EheimDigitalClassicVario):
+ entities += [
+ EheimDigitalSensor[EheimDigitalClassicVario](
+ coordinator, device, description
+ )
+ for description in CLASSICVARIO_DESCRIPTIONS
+ ]
+
+ async_add_entities(entities)
+
+ coordinator.add_platform_callback(async_setup_device_entities)
+ async_setup_device_entities(coordinator.hub.devices)
+
+
+class EheimDigitalSensor(
+ EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co]
+):
+ """Represent a EHEIM Digital sensor entity."""
+
+ entity_description: EheimDigitalSensorDescription[_DeviceT_co]
+
+ def __init__(
+ self,
+ coordinator: EheimDigitalUpdateCoordinator,
+ device: _DeviceT_co,
+ description: EheimDigitalSensorDescription[_DeviceT_co],
+ ) -> None:
+ """Initialize an EHEIM Digital number entity."""
+ super().__init__(coordinator, device)
+ self.entity_description = description
+ self._attr_unique_id = f"{self._device_address}_{description.key}"
+
+ @override
+ def _async_update_attrs(self) -> None:
+ self._attr_native_value = self.entity_description.value_fn(self._device)
diff --git a/homeassistant/components/eheimdigital/strings.json b/homeassistant/components/eheimdigital/strings.json
index ef6f6b10d0a..d7a14b023f7 100644
--- a/homeassistant/components/eheimdigital/strings.json
+++ b/homeassistant/components/eheimdigital/strings.json
@@ -46,6 +46,39 @@
}
}
}
+ },
+ "number": {
+ "manual_speed": {
+ "name": "Manual speed"
+ },
+ "day_speed": {
+ "name": "Day speed"
+ },
+ "night_speed": {
+ "name": "Night speed"
+ },
+ "temperature_offset": {
+ "name": "Temperature offset"
+ },
+ "night_temperature_offset": {
+ "name": "Night temperature offset"
+ }
+ },
+ "sensor": {
+ "current_speed": {
+ "name": "Current speed"
+ },
+ "service_hours": {
+ "name": "Remaining hours until service"
+ },
+ "error_code": {
+ "name": "Error code",
+ "state": {
+ "no_error": "No error",
+ "rotor_stuck": "Rotor stuck",
+ "air_in_filter": "Air in filter"
+ }
+ }
}
}
}
diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py
index 9df39bbe314..cfb2cfba845 100644
--- a/homeassistant/components/eight_sleep/__init__.py
+++ b/homeassistant/components/eight_sleep/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -28,11 +28,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- if all(
- config_entry.state is ConfigEntryState.NOT_LOADED
- for config_entry in hass.config_entries.async_entries(DOMAIN)
- if config_entry.entry_id != entry.entry_id
- ):
- ir.async_delete_issue(hass, DOMAIN, DOMAIN)
-
return True
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Remove a config entry."""
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
+ ir.async_delete_issue(hass, DOMAIN, DOMAIN)
+ # Remove any remaining disabled or ignored entries
+ for _entry in hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py
index 84def436dfb..bdf94f606db 100644
--- a/homeassistant/components/electrasmart/climate.py
+++ b/homeassistant/components/electrasmart/climate.py
@@ -28,7 +28,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElectraSmartConfigEntry
from .const import (
@@ -91,7 +91,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: ElectraSmartConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Electra AC devices."""
api = entry.runtime_data
diff --git a/homeassistant/components/electric_kiwi/icons.json b/homeassistant/components/electric_kiwi/icons.json
index 1932ce19432..e5dbbb5ac48 100644
--- a/homeassistant/components/electric_kiwi/icons.json
+++ b/homeassistant/components/electric_kiwi/icons.json
@@ -13,6 +13,11 @@
"hop_power_savings": {
"default": "mdi:percent"
}
+ },
+ "select": {
+ "hop_selector": {
+ "default": "mdi:lightning-bolt"
+ }
}
}
}
diff --git a/homeassistant/components/electric_kiwi/manifest.json b/homeassistant/components/electric_kiwi/manifest.json
index 45bb09ca475..b2f19000825 100644
--- a/homeassistant/components/electric_kiwi/manifest.json
+++ b/homeassistant/components/electric_kiwi/manifest.json
@@ -7,5 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/electric_kiwi",
"integration_type": "hub",
"iot_class": "cloud_polling",
+ "quality_scale": "bronze",
"requirements": ["electrickiwi-api==0.9.14"]
}
diff --git a/homeassistant/components/electric_kiwi/quality_scale.yaml b/homeassistant/components/electric_kiwi/quality_scale.yaml
new file mode 100644
index 00000000000..a7db8d203b6
--- /dev/null
+++ b/homeassistant/components/electric_kiwi/quality_scale.yaml
@@ -0,0 +1,105 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ 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: |
+ No custom actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Does not subscribe to event explicitly.
+ 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: |
+ No actions
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ Has no options flow
+ docs-installation-parameters:
+ status: exempt
+ comment: |
+ Handled by OAuth flow (HA is only one with credentials, users cannot get them)
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: todo
+
+ # Gold
+ devices:
+ status: exempt
+ comment: |
+ Web services only
+ diagnostics: todo
+ discovery-update-info:
+ status: exempt
+ comment: |
+ Web services only
+ discovery:
+ status: exempt
+ comment: |
+ Web services only
+ docs-data-update: todo
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: |
+ No devices
+ docs-supported-functions: todo
+ docs-troubleshooting: todo
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ No devices
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ No unnecessary or noisy entities
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow:
+ status: exempt
+ comment: |
+ Handled by OAuth
+ repair-issues:
+ status: exempt
+ comment: |
+ Does not have any repairs
+ stale-devices:
+ status: exempt
+ comment: |
+ Does not have devices
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py
index 30e02b5c5b9..2ba2a089557 100644
--- a/homeassistant/components/electric_kiwi/select.py
+++ b/homeassistant/components/electric_kiwi/select.py
@@ -7,12 +7,14 @@ import logging
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION
from .coordinator import ElectricKiwiConfigEntry, ElectricKiwiHOPDataCoordinator
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
ATTR_EK_HOP_SELECT = "hop_select"
@@ -26,7 +28,7 @@ HOP_SELECT = SelectEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: ElectricKiwiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Electric Kiwi select setup."""
hop_coordinator = entry.runtime_data.hop
diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py
index 410d70808c3..27f13a82e09 100644
--- a/homeassistant/components/electric_kiwi/sensor.py
+++ b/homeassistant/components/electric_kiwi/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -27,6 +27,8 @@ from .coordinator import (
ElectricKiwiHOPDataCoordinator,
)
+PARALLEL_UPDATES = 0
+
ATTR_EK_HOP_START = "hop_power_start"
ATTR_EK_HOP_END = "hop_power_end"
ATTR_TOTAL_RUNNING_BALANCE = "total_running_balance"
@@ -130,7 +132,7 @@ HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ElectricKiwiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Electric Kiwi Sensors Setup."""
account_coordinator = entry.runtime_data.account
diff --git a/homeassistant/components/elevenlabs/strings.json b/homeassistant/components/elevenlabs/strings.json
index b346f94a963..8b0205a9e9a 100644
--- a/homeassistant/components/elevenlabs/strings.json
+++ b/homeassistant/components/elevenlabs/strings.json
@@ -6,7 +6,7 @@
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
- "api_key": "Your Elevenlabs API key."
+ "api_key": "Your ElevenLabs API key."
}
}
},
diff --git a/homeassistant/components/elevenlabs/tts.py b/homeassistant/components/elevenlabs/tts.py
index 008cd106615..efcadb3f440 100644
--- a/homeassistant/components/elevenlabs/tts.py
+++ b/homeassistant/components/elevenlabs/tts.py
@@ -20,7 +20,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElevenLabsConfigEntry
from .const import (
@@ -58,7 +58,7 @@ def to_voice_settings(options: MappingProxyType[str, Any]) -> VoiceSettings:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElevenLabsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ElevenLabs tts platform via config entry."""
client = config_entry.runtime_data.client
diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py
index 505eff36b44..23ed65ded33 100644
--- a/homeassistant/components/elgato/button.py
+++ b/homeassistant/components/elgato/button.py
@@ -16,7 +16,7 @@ from homeassistant.components.button import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ElgatoConfigEntry, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
@@ -50,7 +50,7 @@ BUTTONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ElgatoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Elgato button based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py
index 990a0606fce..7d2010f7ba4 100644
--- a/homeassistant/components/elgato/light.py
+++ b/homeassistant/components/elgato/light.py
@@ -16,7 +16,7 @@ from homeassistant.components.light import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.util import color as color_util
@@ -31,7 +31,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: ElgatoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Elgato Light based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/elgato/sensor.py b/homeassistant/components/elgato/sensor.py
index 529d2f7c76e..02dbc2aeef6 100644
--- a/homeassistant/components/elgato/sensor.py
+++ b/homeassistant/components/elgato/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
@@ -104,7 +104,7 @@ SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ElgatoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Elgato sensor based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/elgato/switch.py b/homeassistant/components/elgato/switch.py
index 3b2420b0ace..1b24f621807 100644
--- a/homeassistant/components/elgato/switch.py
+++ b/homeassistant/components/elgato/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ElgatoConfigEntry, ElgatoData, ElgatoDataUpdateCoordinator
from .entity import ElgatoEntity
@@ -54,7 +54,7 @@ SWITCHES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ElgatoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Elgato switches based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py
index 5286b7ad66f..4bf51b99de1 100644
--- a/homeassistant/components/elkm1/__init__.py
+++ b/homeassistant/components/elkm1/__init__.py
@@ -101,9 +101,11 @@ def hostname_from_url(url: str) -> str:
def _host_validator(config: dict[str, str]) -> dict[str, str]:
"""Validate that a host is properly configured."""
- if config[CONF_HOST].startswith("elks://"):
+ if config[CONF_HOST].startswith(("elks://", "elksv1_2://")):
if CONF_USERNAME not in config or CONF_PASSWORD not in config:
- raise vol.Invalid("Specify username and password for elks://")
+ raise vol.Invalid(
+ "Specify username and password for elks:// or elksv1_2://"
+ )
elif not config[CONF_HOST].startswith("elk://") and not config[
CONF_HOST
].startswith("serial://"):
diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py
index ab51b6fe281..393845f65ff 100644
--- a/homeassistant/components/elkm1/alarm_control_panel.py
+++ b/homeassistant/components/elkm1/alarm_control_panel.py
@@ -20,7 +20,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import VolDictType
@@ -55,7 +55,7 @@ SERVICE_ALARM_CLEAR_BYPASS = "alarm_clear_bypass"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ElkM1 alarm platform."""
elk_data = config_entry.runtime_data
@@ -105,6 +105,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity):
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.ARM_NIGHT
+ | AlarmControlPanelEntityFeature.ARM_VACATION
)
_element: Area
@@ -204,7 +205,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity):
ArmedStatus.ARMED_STAY_INSTANT: AlarmControlPanelState.ARMED_HOME,
ArmedStatus.ARMED_TO_NIGHT: AlarmControlPanelState.ARMED_NIGHT,
ArmedStatus.ARMED_TO_NIGHT_INSTANT: AlarmControlPanelState.ARMED_NIGHT,
- ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_AWAY,
+ ArmedStatus.ARMED_TO_VACATION: AlarmControlPanelState.ARMED_VACATION,
}
if self._element.alarm_state is None:
diff --git a/homeassistant/components/elkm1/binary_sensor.py b/homeassistant/components/elkm1/binary_sensor.py
index 73f6b925e8c..ba6a375c29b 100644
--- a/homeassistant/components/elkm1/binary_sensor.py
+++ b/homeassistant/components/elkm1/binary_sensor.py
@@ -10,7 +10,7 @@ from elkm1_lib.zones import Zone
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElkM1ConfigEntry
from .entity import ElkAttachedEntity, ElkEntity
@@ -19,7 +19,7 @@ from .entity import ElkAttachedEntity, ElkEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the Elk-M1 sensor platform."""
elk_data = config_entry.runtime_data
diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py
index 1448acc6079..55af0cfa29c 100644
--- a/homeassistant/components/elkm1/climate.py
+++ b/homeassistant/components/elkm1/climate.py
@@ -19,7 +19,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import PRECISION_WHOLE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from . import ElkM1ConfigEntry
@@ -60,7 +60,7 @@ ELK_TO_HASS_FAN_MODES = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the Elk-M1 thermostat platform."""
elk_data = config_entry.runtime_data
diff --git a/homeassistant/components/elkm1/entity.py b/homeassistant/components/elkm1/entity.py
index d9967d93967..ce717578eae 100644
--- a/homeassistant/components/elkm1/entity.py
+++ b/homeassistant/components/elkm1/entity.py
@@ -100,7 +100,11 @@ class ElkEntity(Entity):
return {"index": self._element.index + 1}
def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None:
- pass
+ """Handle changes to the element.
+
+ This method is called when the element changes. It should be
+ overridden by subclasses to handle the changes.
+ """
@callback
def _element_callback(self, element: Element, changeset: dict[str, Any]) -> None:
@@ -111,7 +115,7 @@ class ElkEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Register callback for ElkM1 changes and update entity state."""
self._element.add_callback(self._element_callback)
- self._element_callback(self._element, {})
+ self._element_changed(self._element, {})
@property
def device_info(self) -> DeviceInfo:
diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py
index c041c9c9d65..b5e2f0acacf 100644
--- a/homeassistant/components/elkm1/light.py
+++ b/homeassistant/components/elkm1/light.py
@@ -10,7 +10,7 @@ from elkm1_lib.lights import Light
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElkM1ConfigEntry
from .entity import ElkEntity, create_elk_entities
@@ -20,7 +20,7 @@ from .models import ELKM1Data
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Elk light platform."""
elk_data = config_entry.runtime_data
diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py
index d8a1d83f326..5da240aee2d 100644
--- a/homeassistant/components/elkm1/scene.py
+++ b/homeassistant/components/elkm1/scene.py
@@ -8,7 +8,7 @@ from elkm1_lib.tasks import Task
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElkM1ConfigEntry
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
@@ -17,7 +17,7 @@ from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the Elk-M1 scene platform."""
elk_data = config_entry.runtime_data
diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py
index 2ca932ec134..328672edbed 100644
--- a/homeassistant/components/elkm1/sensor.py
+++ b/homeassistant/components/elkm1/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import EntityCategory, UnitOfElectricPotential
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import ElkM1ConfigEntry
@@ -40,7 +40,7 @@ ELK_SET_COUNTER_SERVICE_SCHEMA: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the Elk-M1 sensor platform."""
elk_data = config_entry.runtime_data
diff --git a/homeassistant/components/elkm1/strings.json b/homeassistant/components/elkm1/strings.json
index f184483646d..b50c1817838 100644
--- a/homeassistant/components/elkm1/strings.json
+++ b/homeassistant/components/elkm1/strings.json
@@ -53,7 +53,7 @@
"fields": {
"code": {
"name": "Code",
- "description": "An code to authorize the bypass of the alarm control panel."
+ "description": "Alarm code to authorize the bypass of the alarm control panel."
}
}
},
@@ -63,7 +63,7 @@
"fields": {
"code": {
"name": "Code",
- "description": "An code to authorize the bypass clear of the alarm control panel."
+ "description": "Alarm code to authorize the bypass clear of the alarm control panel."
}
}
},
@@ -73,7 +73,7 @@
"fields": {
"code": {
"name": "Code",
- "description": "An code to arm the alarm control panel."
+ "description": "Alarm code to arm the alarm control panel."
}
}
},
@@ -181,7 +181,7 @@
"fields": {
"code": {
"name": "Code",
- "description": "An code to authorize the bypass of the zone."
+ "description": "Alarm code to authorize the bypass of the zone."
}
}
},
diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py
index 3e0f4849518..d91d65512a2 100644
--- a/homeassistant/components/elkm1/switch.py
+++ b/homeassistant/components/elkm1/switch.py
@@ -12,7 +12,7 @@ from elkm1_lib.thermostats import Thermostat
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ElkM1ConfigEntry
from .entity import ElkAttachedEntity, ElkEntity, create_elk_entities
@@ -22,7 +22,7 @@ from .models import ELKM1Data
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElkM1ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the Elk-M1 switch platform."""
elk_data = config_entry.runtime_data
diff --git a/homeassistant/components/elmax/alarm_control_panel.py b/homeassistant/components/elmax/alarm_control_panel.py
index 139c9080c15..a90c8f2652c 100644
--- a/homeassistant/components/elmax/alarm_control_panel.py
+++ b/homeassistant/components/elmax/alarm_control_panel.py
@@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, InvalidStateError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ElmaxConfigEntry
@@ -25,7 +25,7 @@ from .entity import ElmaxEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElmaxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Elmax area platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/elmax/binary_sensor.py b/homeassistant/components/elmax/binary_sensor.py
index 351c386a084..d9ec3e75901 100644
--- a/homeassistant/components/elmax/binary_sensor.py
+++ b/homeassistant/components/elmax/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ElmaxConfigEntry
from .entity import ElmaxEntity
@@ -18,7 +18,7 @@ from .entity import ElmaxEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElmaxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Elmax sensor platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py
index b8697552626..98e49cc8056 100644
--- a/homeassistant/components/elmax/config_flow.py
+++ b/homeassistant/components/elmax/config_flow.py
@@ -498,7 +498,11 @@ class ElmaxConfigFlow(ConfigFlow, domain=DOMAIN):
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle device found via zeroconf."""
- host = discovery_info.host
+ host = (
+ f"[{discovery_info.ip_address}]"
+ if discovery_info.ip_address.version == 6
+ else str(discovery_info.ip_address)
+ )
https_port = (
int(discovery_info.port)
if discovery_info.port is not None
diff --git a/homeassistant/components/elmax/cover.py b/homeassistant/components/elmax/cover.py
index e98477fe496..6993d5e44be 100644
--- a/homeassistant/components/elmax/cover.py
+++ b/homeassistant/components/elmax/cover.py
@@ -10,7 +10,7 @@ from elmax_api.model.cover_status import CoverStatus
from homeassistant.components.cover import CoverEntity, CoverEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ElmaxConfigEntry
from .entity import ElmaxEntity
@@ -27,7 +27,7 @@ _COMMAND_BY_MOTION_STATUS = { # Maps the stop command to use for every cover mo
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElmaxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Elmax cover platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/elmax/strings.json b/homeassistant/components/elmax/strings.json
index 2ba74f5fc8f..5bc7eb292a2 100644
--- a/homeassistant/components/elmax/strings.json
+++ b/homeassistant/components/elmax/strings.json
@@ -4,12 +4,12 @@
"choose_mode": {
"description": "Please choose the connection mode to Elmax panels.",
"menu_options": {
- "cloud": "Connect to Elmax Panel via Elmax Cloud APIs",
- "direct": "Connect to Elmax Panel via local/direct IP"
+ "cloud": "Connect to Elmax panel via Elmax Cloud APIs",
+ "direct": "Connect to Elmax panel via local/direct IP"
}
},
"cloud": {
- "description": "Please login to the Elmax cloud using your credentials",
+ "description": "Please log in to the Elmax cloud using your credentials",
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
@@ -28,7 +28,7 @@
"direct": {
"description": "Specify the Elmax panel connection parameters below.",
"data": {
- "panel_api_host": "Panel API Hostname or IP",
+ "panel_api_host": "Panel API hostname or IP",
"panel_api_port": "Panel API port",
"use_ssl": "Use SSL",
"panel_pin": "Panel PIN code"
@@ -40,7 +40,7 @@
"panels": {
"description": "Select which panel you would like to control with this integration. Please note that the panel must be ON in order to be configured.",
"data": {
- "panel_name": "Panel Name",
+ "panel_name": "Panel name",
"panel_id": "Panel ID",
"panel_pin": "[%key:common::config_flow::data::pin%]"
}
diff --git a/homeassistant/components/elmax/switch.py b/homeassistant/components/elmax/switch.py
index 70faa44cf01..28a97fefd91 100644
--- a/homeassistant/components/elmax/switch.py
+++ b/homeassistant/components/elmax/switch.py
@@ -9,7 +9,7 @@ from elmax_api.model.panel import PanelStatus
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ElmaxConfigEntry
from .entity import ElmaxEntity
@@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ElmaxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Elmax switch platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/elvia/importer.py b/homeassistant/components/elvia/importer.py
index 4e8b7f716ef..caca787237c 100644
--- a/homeassistant/components/elvia/importer.py
+++ b/homeassistant/components/elvia/importer.py
@@ -7,7 +7,11 @@ from typing import TYPE_CHECKING, cast
from elvia import Elvia, error as ElviaError
-from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
+from homeassistant.components.recorder.models import (
+ StatisticData,
+ StatisticMeanType,
+ StatisticMetaData,
+)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
@@ -144,7 +148,7 @@ class ElviaImporter:
async_add_external_statistics(
hass=self.hass,
metadata=StatisticMetaData(
- has_mean=False,
+ mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{self.metering_point_id} Consumption",
source=DOMAIN,
diff --git a/homeassistant/components/elvia/strings.json b/homeassistant/components/elvia/strings.json
index 888a5ab8e76..a2c3cb81f54 100644
--- a/homeassistant/components/elvia/strings.json
+++ b/homeassistant/components/elvia/strings.json
@@ -19,7 +19,7 @@
},
"abort": {
"metering_point_id_already_configured": "Metering point with ID `{metering_point_id}` is already configured.",
- "no_metering_points": "The provived API token has no metering points."
+ "no_metering_points": "The provided API token has no metering points."
}
}
}
diff --git a/homeassistant/components/emoncms/config_flow.py b/homeassistant/components/emoncms/config_flow.py
index e0d4d0d03e9..8b3067b2cf4 100644
--- a/homeassistant/components/emoncms/config_flow.py
+++ b/homeassistant/components/emoncms/config_flow.py
@@ -17,7 +17,6 @@ from homeassistant.const import CONF_API_KEY, CONF_URL
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import selector
-from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_MESSAGE,
@@ -27,7 +26,6 @@ from .const import (
FEED_ID,
FEED_NAME,
FEED_TAG,
- LOGGER,
)
@@ -153,24 +151,6 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
- async def async_step_import(self, import_info: ConfigType) -> ConfigFlowResult:
- """Import config from yaml."""
- url = import_info[CONF_URL]
- api_key = import_info[CONF_API_KEY]
- include_only_feeds = None
- if import_info.get(CONF_ONLY_INCLUDE_FEEDID) is not None:
- include_only_feeds = list(map(str, import_info[CONF_ONLY_INCLUDE_FEEDID]))
- config = {
- CONF_API_KEY: api_key,
- CONF_ONLY_INCLUDE_FEEDID: include_only_feeds,
- CONF_URL: url,
- }
- LOGGER.debug(config)
- result = await self.async_step_user(config)
- if errors := result.get("errors"):
- return self.async_abort(reason=errors["base"])
- return result
-
class EmoncmsOptionsFlow(OptionsFlow):
"""Emoncms Options flow handler."""
diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py
index e3483d3f5d7..c5a25104549 100644
--- a/homeassistant/components/emoncms/sensor.py
+++ b/homeassistant/components/emoncms/sensor.py
@@ -4,24 +4,16 @@ from __future__ import annotations
from typing import Any
-import voluptuous as vol
-
from homeassistant.components.sensor import (
- PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
- CONF_API_KEY,
- CONF_ID,
- CONF_UNIT_OF_MEASUREMENT,
CONF_URL,
- CONF_VALUE_TEMPLATE,
PERCENTAGE,
UnitOfApparentPower,
UnitOfElectricCurrent,
@@ -36,19 +28,15 @@ from homeassistant.const import (
UnitOfVolume,
UnitOfVolumeFlowRate,
)
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
-from homeassistant.data_entry_flow import FlowResultType
-from homeassistant.helpers import config_validation as cv, template
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import template
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .config_flow import sensor_name
from .const import (
CONF_EXCLUDE_FEEDID,
CONF_ONLY_INCLUDE_FEEDID,
- DOMAIN,
FEED_ID,
FEED_NAME,
FEED_TAG,
@@ -202,94 +190,13 @@ ATTR_LASTUPDATETIMESTR = "LastUpdatedStr"
ATTR_SIZE = "Size"
ATTR_TAG = "Tag"
ATTR_USERID = "UserId"
-CONF_SENSOR_NAMES = "sensor_names"
DECIMALS = 2
-DEFAULT_UNIT = UnitOfPower.WATT
-
-ONLY_INCL_EXCL_NONE = "only_include_exclude_or_none"
-
-PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
- {
- vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_URL): cv.string,
- vol.Required(CONF_ID): cv.positive_int,
- vol.Exclusive(CONF_ONLY_INCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All(
- cv.ensure_list, [cv.positive_int]
- ),
- vol.Exclusive(CONF_EXCLUDE_FEEDID, ONLY_INCL_EXCL_NONE): vol.All(
- cv.ensure_list, [cv.positive_int]
- ),
- vol.Optional(CONF_SENSOR_NAMES): vol.All(
- {cv.positive_int: vol.All(cv.string, vol.Length(min=1))}
- ),
- vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
- vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=DEFAULT_UNIT): cv.string,
- }
-)
-
-
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Import config from yaml."""
- if CONF_VALUE_TEMPLATE in config:
- async_create_issue(
- hass,
- DOMAIN,
- f"remove_{CONF_VALUE_TEMPLATE}_{DOMAIN}",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.ERROR,
- translation_key=f"remove_{CONF_VALUE_TEMPLATE}",
- translation_placeholders={
- "domain": DOMAIN,
- "parameter": CONF_VALUE_TEMPLATE,
- },
- )
- return
- if CONF_ONLY_INCLUDE_FEEDID not in config:
- async_create_issue(
- hass,
- DOMAIN,
- f"missing_{CONF_ONLY_INCLUDE_FEEDID}_{DOMAIN}",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key=f"missing_{CONF_ONLY_INCLUDE_FEEDID}",
- translation_placeholders={
- "domain": DOMAIN,
- },
- )
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=config
- )
- if (
- result.get("type") == FlowResultType.CREATE_ENTRY
- or result.get("reason") == "already_configured"
- ):
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- is_fixable=False,
- issue_domain=DOMAIN,
- breaks_in_ha_version="2025.3.0",
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "emoncms",
- },
- )
async def async_setup_entry(
hass: HomeAssistant,
entry: EmonCMSConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the emoncms sensors."""
name = sensor_name(entry.data[CONF_URL])
diff --git a/homeassistant/components/emoncms/strings.json b/homeassistant/components/emoncms/strings.json
index 77216a3fb2f..451a3fb88e5 100644
--- a/homeassistant/components/emoncms/strings.json
+++ b/homeassistant/components/emoncms/strings.json
@@ -1,7 +1,7 @@
{
"config": {
"error": {
- "api_error": "An error occured in the pyemoncms API : {details}"
+ "api_error": "An error occurred in the pyemoncms API : {details}"
},
"step": {
"user": {
diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py
index 39ed61741ae..be9e2ecb4cc 100644
--- a/homeassistant/components/emonitor/sensor.py
+++ b/homeassistant/components/emonitor/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -38,7 +38,7 @@ SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EmonitorConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json
index da3912a9d25..fc54fb50064 100644
--- a/homeassistant/components/emulated_kasa/manifest.json
+++ b/homeassistant/components/emulated_kasa/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["sense_energy"],
"quality_scale": "internal",
- "requirements": ["sense-energy==0.13.4"]
+ "requirements": ["sense-energy==0.13.7"]
}
diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py
index d4466f47ef2..e8c3a00f098 100644
--- a/homeassistant/components/emulated_roku/__init__.py
+++ b/homeassistant/components/emulated_roku/__init__.py
@@ -46,6 +46,8 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
+type EmulatedRokuConfigEntry = ConfigEntry[EmulatedRoku]
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the emulated roku component."""
@@ -65,22 +67,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
-async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, entry: EmulatedRokuConfigEntry
+) -> bool:
"""Set up an emulated roku server from a config entry."""
- config = config_entry.data
-
- if DOMAIN not in hass.data:
- hass.data[DOMAIN] = {}
-
- name = config[CONF_NAME]
- listen_port = config[CONF_LISTEN_PORT]
- host_ip = config.get(CONF_HOST_IP) or await async_get_source_ip(hass)
- advertise_ip = config.get(CONF_ADVERTISE_IP)
- advertise_port = config.get(CONF_ADVERTISE_PORT)
- upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST)
+ config = entry.data
+ name: str = config[CONF_NAME]
+ listen_port: int = config[CONF_LISTEN_PORT]
+ host_ip: str = config.get(CONF_HOST_IP) or await async_get_source_ip(hass)
+ advertise_ip: str | None = config.get(CONF_ADVERTISE_IP)
+ advertise_port: int | None = config.get(CONF_ADVERTISE_PORT)
+ upnp_bind_multicast: bool | None = config.get(CONF_UPNP_BIND_MULTICAST)
server = EmulatedRoku(
hass,
+ entry.entry_id,
name,
host_ip,
listen_port,
@@ -88,14 +89,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
advertise_port,
upnp_bind_multicast,
)
-
- hass.data[DOMAIN][name] = server
-
+ entry.runtime_data = server
return await server.setup()
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: EmulatedRokuConfigEntry
+) -> bool:
"""Unload a config entry."""
- name = entry.data[CONF_NAME]
- server = hass.data[DOMAIN].pop(name)
- return await server.unload()
+ return await entry.runtime_data.unload()
diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py
index a84db4bd77b..6d8d9c4014f 100644
--- a/homeassistant/components/emulated_roku/binding.py
+++ b/homeassistant/components/emulated_roku/binding.py
@@ -5,7 +5,13 @@ import logging
from emulated_roku import EmulatedRokuCommandHandler, EmulatedRokuServer
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
-from homeassistant.core import CoreState, EventOrigin
+from homeassistant.core import (
+ CALLBACK_TYPE,
+ CoreState,
+ Event,
+ EventOrigin,
+ HomeAssistant,
+)
LOGGER = logging.getLogger(__package__)
@@ -27,16 +33,18 @@ class EmulatedRoku:
def __init__(
self,
- hass,
- name,
- host_ip,
- listen_port,
- advertise_ip,
- advertise_port,
- upnp_bind_multicast,
- ):
+ hass: HomeAssistant,
+ entry_id: str,
+ name: str,
+ host_ip: str,
+ listen_port: int,
+ advertise_ip: str | None,
+ advertise_port: int | None,
+ upnp_bind_multicast: bool | None,
+ ) -> None:
"""Initialize the properties."""
self.hass = hass
+ self.entry_id = entry_id
self.roku_usn = name
self.host_ip = host_ip
@@ -47,21 +55,21 @@ class EmulatedRoku:
self.bind_multicast = upnp_bind_multicast
- self._api_server = None
+ self._api_server: EmulatedRokuServer | None = None
- self._unsub_start_listener = None
- self._unsub_stop_listener = None
+ self._unsub_start_listener: CALLBACK_TYPE | None = None
+ self._unsub_stop_listener: CALLBACK_TYPE | None = None
- async def setup(self):
+ async def setup(self) -> bool:
"""Start the emulated_roku server."""
class EventCommandHandler(EmulatedRokuCommandHandler):
"""emulated_roku command handler to turn commands into events."""
- def __init__(self, hass):
+ def __init__(self, hass: HomeAssistant) -> None:
self.hass = hass
- def on_keydown(self, roku_usn, key):
+ def on_keydown(self, roku_usn: str, key: str) -> None:
"""Handle keydown event."""
self.hass.bus.async_fire(
EVENT_ROKU_COMMAND,
@@ -73,7 +81,7 @@ class EmulatedRoku:
EventOrigin.local,
)
- def on_keyup(self, roku_usn, key):
+ def on_keyup(self, roku_usn: str, key: str) -> None:
"""Handle keyup event."""
self.hass.bus.async_fire(
EVENT_ROKU_COMMAND,
@@ -85,7 +93,7 @@ class EmulatedRoku:
EventOrigin.local,
)
- def on_keypress(self, roku_usn, key):
+ def on_keypress(self, roku_usn: str, key: str) -> None:
"""Handle keypress event."""
self.hass.bus.async_fire(
EVENT_ROKU_COMMAND,
@@ -97,7 +105,7 @@ class EmulatedRoku:
EventOrigin.local,
)
- def launch(self, roku_usn, app_id):
+ def launch(self, roku_usn: str, app_id: str) -> None:
"""Handle launch event."""
self.hass.bus.async_fire(
EVENT_ROKU_COMMAND,
@@ -129,17 +137,19 @@ class EmulatedRoku:
bind_multicast=self.bind_multicast,
)
- async def emulated_roku_stop(event):
+ async def emulated_roku_stop(event: Event | None) -> None:
"""Wrap the call to emulated_roku.close."""
LOGGER.debug("Stopping emulated_roku %s", self.roku_usn)
self._unsub_stop_listener = None
+ assert self._api_server is not None
await self._api_server.close()
- async def emulated_roku_start(event):
+ async def emulated_roku_start(event: Event | None) -> None:
"""Wrap the call to emulated_roku.start."""
try:
LOGGER.debug("Starting emulated_roku %s", self.roku_usn)
self._unsub_start_listener = None
+ assert self._api_server is not None
await self._api_server.start()
except OSError:
LOGGER.exception(
@@ -165,7 +175,7 @@ class EmulatedRoku:
return True
- async def unload(self):
+ async def unload(self) -> bool:
"""Unload the emulated_roku server."""
LOGGER.debug("Unloading emulated_roku %s", self.roku_usn)
@@ -177,6 +187,7 @@ class EmulatedRoku:
self._unsub_stop_listener()
self._unsub_stop_listener = None
+ assert self._api_server is not None
await self._api_server.close()
return True
diff --git a/homeassistant/components/energenie_power_sockets/strings.json b/homeassistant/components/energenie_power_sockets/strings.json
index 4e4e49c68fb..bd536568d2c 100644
--- a/homeassistant/components/energenie_power_sockets/strings.json
+++ b/homeassistant/components/energenie_power_sockets/strings.json
@@ -1,5 +1,5 @@
{
- "title": "Energenie Power Sockets Integration.",
+ "title": "Energenie Power Sockets",
"config": {
"step": {
"user": {
diff --git a/homeassistant/components/energenie_power_sockets/switch.py b/homeassistant/components/energenie_power_sockets/switch.py
index e4fb7653e5e..825566887bc 100644
--- a/homeassistant/components/energenie_power_sockets/switch.py
+++ b/homeassistant/components/energenie_power_sockets/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EnergenieConfigEntry
from .const import DOMAIN
@@ -19,7 +19,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnergenieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add EGPS sockets for passed config_entry in HA."""
powerstrip = config_entry.runtime_data
diff --git a/homeassistant/components/energy/data.py b/homeassistant/components/energy/data.py
index ff86177cf41..442aedf23b0 100644
--- a/homeassistant/components/energy/data.py
+++ b/homeassistant/components/energy/data.py
@@ -139,6 +139,10 @@ class DeviceConsumption(TypedDict):
# An optional custom name for display in energy graphs
name: str | None
+ # An optional statistic_id identifying a device
+ # that includes this device's consumption in its total
+ included_in_stat: str | None
+
class EnergyPreferences(TypedDict):
"""Dictionary holding the energy data."""
@@ -291,6 +295,7 @@ DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
{
vol.Required("stat_consumption"): str,
vol.Optional("name"): str,
+ vol.Optional("included_in_stat"): str,
}
)
diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py
index eec92c32f98..062601eb4c5 100644
--- a/homeassistant/components/energy/sensor.py
+++ b/homeassistant/components/energy/sensor.py
@@ -25,6 +25,7 @@ from homeassistant.core import (
split_entity_id,
valid_entity_id,
)
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
@@ -122,6 +123,10 @@ SOURCE_ADAPTERS: Final = (
)
+class EntityNotFoundError(HomeAssistantError):
+ """When a referenced entity was not found."""
+
+
class SensorManager:
"""Class to handle creation/removal of sensor data."""
@@ -311,43 +316,25 @@ class EnergyCostSensor(SensorEntity):
except ValueError:
return
- # Determine energy price
- if self._config["entity_energy_price"] is not None:
- energy_price_state = self.hass.states.get(
- self._config["entity_energy_price"]
+ try:
+ energy_price, energy_price_unit = self._get_energy_price(
+ valid_units, default_price_unit
)
-
- if energy_price_state is None:
- return
-
- try:
- energy_price = float(energy_price_state.state)
- except ValueError:
- if self._last_energy_sensor_state is None:
- # Initialize as it's the first time all required entities except
- # price are in place. This means that the cost will update the first
- # time the energy is updated after the price entity is in place.
- self._reset(energy_state)
- return
-
- energy_price_unit: str | None = energy_price_state.attributes.get(
- ATTR_UNIT_OF_MEASUREMENT, ""
- ).partition("/")[2]
-
- # For backwards compatibility we don't validate the unit of the price
- # If it is not valid, we assume it's our default price unit.
- if energy_price_unit not in valid_units:
- energy_price_unit = default_price_unit
-
- else:
- energy_price = cast(float, self._config["number_energy_price"])
- energy_price_unit = default_price_unit
+ except EntityNotFoundError:
+ return
+ except ValueError:
+ energy_price = None
if self._last_energy_sensor_state is None:
- # Initialize as it's the first time all required entities are in place.
+ # Initialize as it's the first time all required entities are in place or
+ # only the price is missing. In the later case, cost will update the first
+ # time the energy is updated after the price entity is in place.
self._reset(energy_state)
return
+ if energy_price is None:
+ return
+
energy_unit: str | None = energy_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
if energy_unit is None or energy_unit not in valid_units:
@@ -383,20 +370,9 @@ class EnergyCostSensor(SensorEntity):
old_energy_value = float(self._last_energy_sensor_state.state)
cur_value = cast(float, self._attr_native_value)
- if energy_price_unit is None:
- converted_energy_price = energy_price
- else:
- converter: Callable[[float, str, str], float]
- if energy_unit in VALID_ENERGY_UNITS:
- converter = unit_conversion.EnergyConverter.convert
- else:
- converter = unit_conversion.VolumeConverter.convert
-
- converted_energy_price = converter(
- energy_price,
- energy_unit,
- energy_price_unit,
- )
+ converted_energy_price = self._convert_energy_price(
+ energy_price, energy_price_unit, energy_unit
+ )
self._attr_native_value = (
cur_value + (energy - old_energy_value) * converted_energy_price
@@ -404,6 +380,52 @@ class EnergyCostSensor(SensorEntity):
self._last_energy_sensor_state = energy_state
+ def _get_energy_price(
+ self, valid_units: set[str], default_unit: str | None
+ ) -> tuple[float, str | None]:
+ """Get the energy price.
+
+ Raises:
+ EntityNotFoundError: When the energy price entity is not found.
+ ValueError: When the entity state is not a valid float.
+
+ """
+
+ if self._config["entity_energy_price"] is None:
+ return cast(float, self._config["number_energy_price"]), default_unit
+
+ energy_price_state = self.hass.states.get(self._config["entity_energy_price"])
+ if energy_price_state is None:
+ raise EntityNotFoundError
+
+ energy_price = float(energy_price_state.state)
+
+ energy_price_unit: str | None = energy_price_state.attributes.get(
+ ATTR_UNIT_OF_MEASUREMENT, ""
+ ).partition("/")[2]
+
+ # For backwards compatibility we don't validate the unit of the price
+ # If it is not valid, we assume it's our default price unit.
+ if energy_price_unit not in valid_units:
+ energy_price_unit = default_unit
+
+ return energy_price, energy_price_unit
+
+ def _convert_energy_price(
+ self, energy_price: float, energy_price_unit: str | None, energy_unit: str
+ ) -> float:
+ """Convert the energy price to the correct unit."""
+ if energy_price_unit is None:
+ return energy_price
+
+ converter: Callable[[float, str, str], float]
+ if energy_unit in VALID_ENERGY_UNITS:
+ converter = unit_conversion.EnergyConverter.convert
+ else:
+ converter = unit_conversion.VolumeConverter.convert
+
+ return converter(energy_price, energy_unit, energy_price_unit)
+
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
energy_state = self.hass.states.get(self._config[self._adapter.stat_energy_key])
diff --git a/homeassistant/components/energy/strings.json b/homeassistant/components/energy/strings.json
index e9d72247319..5eb2c93161e 100644
--- a/homeassistant/components/energy/strings.json
+++ b/homeassistant/components/energy/strings.json
@@ -7,7 +7,7 @@
},
"recorder_untracked": {
"title": "Entity not tracked",
- "description": "The recorder has been configured to exclude these configured entities:"
+ "description": "Home Assistant Recorder has been configured to exclude these configured entities:"
},
"entity_unavailable": {
"title": "Entity unavailable",
diff --git a/homeassistant/components/energyzero/sensor.py b/homeassistant/components/energyzero/sensor.py
index 141ac793fba..38349b89ff7 100644
--- a/homeassistant/components/energyzero/sensor.py
+++ b/homeassistant/components/energyzero/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, SERVICE_TYPE_DEVICE_NAMES
@@ -147,7 +147,7 @@ def get_gas_price(data: EnergyZeroData, hours: int) -> float | None:
async def async_setup_entry(
hass: HomeAssistant,
entry: EnergyZeroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EnergyZero Sensors based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json
index 7788f4d4d8e..48682ab31ee 100644
--- a/homeassistant/components/energyzero/strings.json
+++ b/homeassistant/components/energyzero/strings.json
@@ -54,10 +54,10 @@
"services": {
"get_gas_prices": {
"name": "Get gas prices",
- "description": "Request gas prices from EnergyZero.",
+ "description": "Requests gas prices from EnergyZero.",
"fields": {
"config_entry": {
- "name": "Config Entry",
+ "name": "Config entry",
"description": "The config entry to use for this action."
},
"incl_vat": {
@@ -76,7 +76,7 @@
},
"get_energy_prices": {
"name": "Get energy prices",
- "description": "Request energy prices from EnergyZero.",
+ "description": "Requests energy prices from EnergyZero.",
"fields": {
"config_entry": {
"name": "[%key:component::energyzero::services::get_gas_prices::fields::config_entry::name%]",
diff --git a/homeassistant/components/enigma2/config_flow.py b/homeassistant/components/enigma2/config_flow.py
index b0649a8368d..876d55128cf 100644
--- a/homeassistant/components/enigma2/config_flow.py
+++ b/homeassistant/components/enigma2/config_flow.py
@@ -1,5 +1,6 @@
"""Config flow for Enigma2."""
+import logging
from typing import Any, cast
from aiohttp.client_exceptions import ClientError
@@ -63,6 +64,8 @@ CONFIG_SCHEMA = vol.Schema(
}
)
+_LOGGER = logging.getLogger(__name__)
+
async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
"""Get the options schema."""
@@ -130,7 +133,8 @@ class Enigma2ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
errors = {"base": "invalid_auth"}
except ClientError:
errors = {"base": "cannot_connect"}
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors = {"base": "unknown"}
else:
unique_id = about["info"]["ifaces"][0]["mac"] or self.unique_id
diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py
index 9a2a4564d1c..a3cdd1858ed 100644
--- a/homeassistant/components/enigma2/media_player.py
+++ b/homeassistant/components/enigma2/media_player.py
@@ -15,7 +15,7 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import Enigma2ConfigEntry, Enigma2UpdateCoordinator
@@ -31,7 +31,7 @@ _LOGGER = getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: Enigma2ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Enigma2 media player platform."""
async_add_entities([Enigma2Device(entry.runtime_data)])
diff --git a/homeassistant/components/enocean/entity.py b/homeassistant/components/enocean/entity.py
index 5c12fc12a68..b2d73e65443 100644
--- a/homeassistant/components/enocean/entity.py
+++ b/homeassistant/components/enocean/entity.py
@@ -16,7 +16,7 @@ class EnOceanEntity(Entity):
"""Initialize the device."""
self.dev_id = dev_id
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self.async_on_remove(
async_dispatcher_connect(
diff --git a/homeassistant/components/enphase_envoy/binary_sensor.py b/homeassistant/components/enphase_envoy/binary_sensor.py
index 0258281661a..dcffef8271b 100644
--- a/homeassistant/components/enphase_envoy/binary_sensor.py
+++ b/homeassistant/components/enphase_envoy/binary_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
@@ -75,7 +75,7 @@ ENPOWER_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up envoy binary sensor platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py
index 654e2262730..5ee81dd8315 100644
--- a/homeassistant/components/enphase_envoy/config_flow.py
+++ b/homeassistant/components/enphase_envoy/config_flow.py
@@ -16,7 +16,13 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlow,
)
-from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_NAME,
+ CONF_PASSWORD,
+ CONF_TOKEN,
+ CONF_USERNAME,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -40,6 +46,13 @@ CONF_SERIAL = "serial"
INSTALLER_AUTH_USERNAME = "installer"
+AVOID_REFLECT_KEYS = {CONF_PASSWORD, CONF_TOKEN}
+
+
+def without_avoid_reflect_keys(dictionary: Mapping[str, Any]) -> dict[str, Any]:
+ """Return a dictionary without AVOID_REFLECT_KEYS."""
+ return {k: v for k, v in dictionary.items() if k not in AVOID_REFLECT_KEYS}
+
async def validate_input(
hass: HomeAssistant,
@@ -205,7 +218,10 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders["serial"] = serial
return self.async_show_form(
step_id="reauth_confirm",
- data_schema=self._async_generate_schema(),
+ data_schema=self.add_suggested_values_to_schema(
+ self._async_generate_schema(),
+ without_avoid_reflect_keys(user_input or reauth_entry.data),
+ ),
description_placeholders=description_placeholders,
errors=errors,
)
@@ -259,10 +275,12 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_SERIAL: self.unique_id,
CONF_HOST: host,
}
-
return self.async_show_form(
step_id="user",
- data_schema=self._async_generate_schema(),
+ data_schema=self.add_suggested_values_to_schema(
+ self._async_generate_schema(),
+ without_avoid_reflect_keys(user_input or {}),
+ ),
description_placeholders=description_placeholders,
errors=errors,
)
@@ -306,11 +324,11 @@ class EnphaseConfigFlow(ConfigFlow, domain=DOMAIN):
}
description_placeholders["serial"] = serial
- suggested_values: Mapping[str, Any] = user_input or reconfigure_entry.data
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
- self._async_generate_schema(), suggested_values
+ self._async_generate_schema(),
+ without_avoid_reflect_keys(user_input or reconfigure_entry.data),
),
description_placeholders=description_placeholders,
errors=errors,
diff --git a/homeassistant/components/enphase_envoy/diagnostics.py b/homeassistant/components/enphase_envoy/diagnostics.py
index d5b3880cf24..80eed76574f 100644
--- a/homeassistant/components/enphase_envoy/diagnostics.py
+++ b/homeassistant/components/enphase_envoy/diagnostics.py
@@ -66,16 +66,19 @@ async def _get_fixture_collection(envoy: Envoy, serial: str) -> dict[str, Any]:
]
for end_point in end_points:
- response = await envoy.request(end_point)
- fixture_data[end_point] = response.text.replace("\n", "").replace(
- serial, CLEAN_TEXT
- )
- fixture_data[f"{end_point}_log"] = json_dumps(
- {
- "headers": dict(response.headers.items()),
- "code": response.status_code,
- }
- )
+ try:
+ response = await envoy.request(end_point)
+ fixture_data[end_point] = response.text.replace("\n", "").replace(
+ serial, CLEAN_TEXT
+ )
+ fixture_data[f"{end_point}_log"] = json_dumps(
+ {
+ "headers": dict(response.headers.items()),
+ "code": response.status_code,
+ }
+ )
+ except EnvoyError as err:
+ fixture_data[f"{end_point}_log"] = {"Error": repr(err)}
return fixture_data
@@ -160,10 +163,7 @@ async def async_get_config_entry_diagnostics(
fixture_data: dict[str, Any] = {}
if entry.options.get(OPTION_DIAGNOSTICS_INCLUDE_FIXTURES, False):
- try:
- fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial)
- except EnvoyError as err:
- fixture_data["Error"] = repr(err)
+ fixture_data = await _get_fixture_collection(envoy=envoy, serial=old_serial)
diagnostic_data: dict[str, Any] = {
"config_entry": async_redact_data(entry.as_dict(), TO_REDACT),
diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json
index 0b1fd8b04b9..88183fe4cfd 100644
--- a/homeassistant/components/enphase_envoy/manifest.json
+++ b/homeassistant/components/enphase_envoy/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
- "requirements": ["pyenphase==1.23.1"],
+ "requirements": ["pyenphase==1.25.5"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."
diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py
index a62913a4c0b..91e93d9c59b 100644
--- a/homeassistant/components/enphase_envoy/number.py
+++ b/homeassistant/components/enphase_envoy/number.py
@@ -19,11 +19,11 @@ from homeassistant.components.number import (
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
-from .entity import EnvoyBaseEntity
+from .entity import EnvoyBaseEntity, exception_handler
PARALLEL_UPDATES = 1
@@ -73,7 +73,7 @@ STORAGE_RESERVE_SOC_ENTITY = EnvoyStorageSettingsNumberEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Enphase Envoy number platform."""
coordinator = config_entry.runtime_data
@@ -132,6 +132,7 @@ class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity):
self.data.dry_contact_settings[self._relay_id]
)
+ @exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Update the relay."""
await self.envoy.update_dry_contact(
@@ -185,6 +186,7 @@ class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity):
assert self.data.tariff.storage_settings is not None
return self.entity_description.value_fn(self.data.tariff.storage_settings)
+ @exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Update the storage setting."""
await self.entity_description.update_fn(self.envoy, value)
diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py
index 7dc275aab37..42b47e5d793 100644
--- a/homeassistant/components/enphase_envoy/select.py
+++ b/homeassistant/components/enphase_envoy/select.py
@@ -14,11 +14,11 @@ from pyenphase.models.tariff import EnvoyStorageMode, EnvoyStorageSettings
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
-from .entity import EnvoyBaseEntity
+from .entity import EnvoyBaseEntity, exception_handler
PARALLEL_UPDATES = 1
@@ -130,7 +130,7 @@ STORAGE_MODE_ENTITY = EnvoyStorageSettingsSelectEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Enphase Envoy select platform."""
coordinator = config_entry.runtime_data
@@ -192,6 +192,7 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity):
"""Return the state of the Enpower switch."""
return self.entity_description.value_fn(self.relay)
+ @exception_handler
async def async_select_option(self, option: str) -> None:
"""Update the relay."""
await self.entity_description.update_fn(self.envoy, self.relay, option)
@@ -243,6 +244,7 @@ class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity):
assert self.data.tariff.storage_settings is not None
return self.entity_description.value_fn(self.data.tariff.storage_settings)
+ @exception_handler
async def async_select_option(self, option: str) -> None:
"""Update the relay."""
await self.entity_description.update_fn(self.envoy, option)
diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py
index dcf062a5417..594f5f34088 100644
--- a/homeassistant/components/enphase_envoy/sensor.py
+++ b/homeassistant/components/enphase_envoy/sensor.py
@@ -49,7 +49,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -806,7 +806,7 @@ AGGREGATE_BATTERY_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up envoy sensor platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json
index e99c45c5c7a..ce3a8593226 100644
--- a/homeassistant/components/enphase_envoy/strings.json
+++ b/homeassistant/components/enphase_envoy/strings.json
@@ -57,7 +57,7 @@
"init": {
"title": "Envoy {serial} {host} options",
"data": {
- "diagnostics_include_fixtures": "Include test fixture data in diagnostic report. Use when requested to provide test data for troubleshooting or development activies. With this option enabled the diagnostic report may take more time to download. When report is created best disable this option again.",
+ "diagnostics_include_fixtures": "Include test fixture data in diagnostic report. Use when requested to provide test data for troubleshooting or development activities. With this option enabled the diagnostic report may take more time to download. When report is created best disable this option again.",
"disable_keep_alive": "Always use a new connection when requesting data from the Envoy. May resolve communication issues with some Envoy firmwares."
},
"data_description": {
@@ -187,13 +187,13 @@
"name": "Lifetime energy consumption {phase_name}"
},
"balanced_net_consumption": {
- "name": "balanced net power consumption"
+ "name": "Balanced net power consumption"
},
"lifetime_balanced_net_consumption": {
"name": "Lifetime balanced net energy consumption"
},
"balanced_net_consumption_phase": {
- "name": "balanced net power consumption {phase_name}"
+ "name": "Balanced net power consumption {phase_name}"
},
"lifetime_balanced_net_consumption_phase": {
"name": "Lifetime balanced net energy consumption {phase_name}"
@@ -217,7 +217,7 @@
"name": "Net consumption CT current"
},
"net_ct_powerfactor": {
- "name": "Powerfactor net consumption CT"
+ "name": "Power factor net consumption CT"
},
"net_ct_metering_status": {
"name": "Metering status net consumption CT"
@@ -235,7 +235,7 @@
"name": "Production CT current"
},
"production_ct_powerfactor": {
- "name": "powerfactor production CT"
+ "name": "Power factor production CT"
},
"production_ct_metering_status": {
"name": "Metering status production CT"
@@ -262,7 +262,7 @@
"name": "Storage CT current"
},
"storage_ct_powerfactor": {
- "name": "Powerfactor storage CT"
+ "name": "Power factor storage CT"
},
"storage_ct_metering_status": {
"name": "Metering status storage CT"
@@ -289,7 +289,7 @@
"name": "Net consumption CT current {phase_name}"
},
"net_ct_powerfactor_phase": {
- "name": "Powerfactor net consumption CT {phase_name}"
+ "name": "Power factor net consumption CT {phase_name}"
},
"net_ct_metering_status_phase": {
"name": "Metering status net consumption CT {phase_name}"
@@ -307,7 +307,7 @@
"name": "Production CT current {phase_name}"
},
"production_ct_powerfactor_phase": {
- "name": "Powerfactor production CT {phase_name}"
+ "name": "Power factor production CT {phase_name}"
},
"production_ct_metering_status_phase": {
"name": "Metering status production CT {phase_name}"
@@ -334,7 +334,7 @@
"name": "Storage CT current {phase_name}"
},
"storage_ct_powerfactor_phase": {
- "name": "Powerfactor storage CT {phase_name}"
+ "name": "Power factor storage CT {phase_name}"
},
"storage_ct_metering_status_phase": {
"name": "Metering status storage CT {phase_name}"
@@ -360,9 +360,9 @@
"acb_battery_state": {
"name": "Battery state",
"state": {
- "discharging": "Discharging",
+ "discharging": "[%key:common::state::discharging%]",
"idle": "[%key:common::state::idle%]",
- "charging": "Charging",
+ "charging": "[%key:common::state::charging%]",
"full": "Full"
}
},
diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py
index 8a3ca493562..bb4ed874b1d 100644
--- a/homeassistant/components/enphase_envoy/switch.py
+++ b/homeassistant/components/enphase_envoy/switch.py
@@ -14,7 +14,7 @@ from pyenphase.models.tariff import EnvoyStorageSettings
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
@@ -78,7 +78,7 @@ CHARGE_FROM_GRID_SWITCH = EnvoyStorageSettingsSwitchEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EnphaseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Enphase Envoy switch platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py
index 3ba059e2206..b051c572816 100644
--- a/homeassistant/components/environment_canada/camera.py
+++ b/homeassistant/components/environment_canada/camera.py
@@ -8,7 +8,7 @@ import voluptuous as vol
from homeassistant.components.camera import Camera
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.typing import VolDictType
@@ -26,7 +26,7 @@ SET_RADAR_TYPE_SCHEMA: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ECConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
coordinator = config_entry.runtime_data.radar_coordinator
diff --git a/homeassistant/components/environment_canada/config_flow.py b/homeassistant/components/environment_canada/config_flow.py
index c4fd16f9522..debe1c5ae43 100644
--- a/homeassistant/components/environment_canada/config_flow.py
+++ b/homeassistant/components/environment_canada/config_flow.py
@@ -35,7 +35,7 @@ async def validate_input(data):
lon = weather_data.lon
return {
- CONF_TITLE: weather_data.metadata.get("location"),
+ CONF_TITLE: weather_data.metadata.location,
CONF_STATION: weather_data.station_id,
CONF_LATITUDE: lat,
CONF_LONGITUDE: lon,
diff --git a/homeassistant/components/environment_canada/const.py b/homeassistant/components/environment_canada/const.py
index f1f6db2e0df..c2b58d8dcce 100644
--- a/homeassistant/components/environment_canada/const.py
+++ b/homeassistant/components/environment_canada/const.py
@@ -5,3 +5,4 @@ ATTR_STATION = "station"
CONF_STATION = "station"
CONF_TITLE = "title"
DOMAIN = "environment_canada"
+SERVICE_ENVIRONMENT_CANADA_FORECASTS = "get_forecasts"
diff --git a/homeassistant/components/environment_canada/coordinator.py b/homeassistant/components/environment_canada/coordinator.py
index e31e847cd2d..89fc92b462e 100644
--- a/homeassistant/components/environment_canada/coordinator.py
+++ b/homeassistant/components/environment_canada/coordinator.py
@@ -7,7 +7,7 @@ from datetime import timedelta
import logging
import xml.etree.ElementTree as ET
-from env_canada import ECAirQuality, ECRadar, ECWeather, ec_exc
+from env_canada import ECAirQuality, ECRadar, ECWeather, ECWeatherUpdateFailed, ec_exc
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -65,6 +65,6 @@ class ECDataUpdateCoordinator[DataT: ECDataType](DataUpdateCoordinator[DataT]):
"""Fetch data from EC."""
try:
await self.ec_data.update()
- except (ET.ParseError, ec_exc.UnknownStationId) as ex:
+ except (ET.ParseError, ECWeatherUpdateFailed, ec_exc.UnknownStationId) as ex:
raise UpdateFailed(f"Error fetching {self.name} data: {ex}") from ex
return self.ec_data
diff --git a/homeassistant/components/environment_canada/icons.json b/homeassistant/components/environment_canada/icons.json
index c3562ce1840..ca55254cc12 100644
--- a/homeassistant/components/environment_canada/icons.json
+++ b/homeassistant/components/environment_canada/icons.json
@@ -21,6 +21,9 @@
"services": {
"set_radar_type": {
"service": "mdi:radar"
+ },
+ "get_forecasts": {
+ "service": "mdi:weather-cloudy-clock"
}
}
}
diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json
index 76534662ff7..098f231a40f 100644
--- a/homeassistant/components/environment_canada/manifest.json
+++ b/homeassistant/components/environment_canada/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
"iot_class": "cloud_polling",
"loggers": ["env_canada"],
- "requirements": ["env-canada==0.7.2"]
+ "requirements": ["env-canada==0.10.1"]
}
diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py
index 989667fb1ac..d27da132a35 100644
--- a/homeassistant/components/environment_canada/sensor.py
+++ b/homeassistant/components/environment_canada/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTR_STATION
@@ -145,7 +145,7 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = (
key="timestamp",
translation_key="timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
- value_fn=lambda data: data.metadata.get("timestamp"),
+ value_fn=lambda data: data.metadata.timestamp,
),
ECSensorEntityDescription(
key="uv_index",
@@ -167,6 +167,8 @@ SENSOR_TYPES: tuple[ECSensorEntityDescription, ...] = (
translation_key="wind_bearing",
native_unit_of_measurement=DEGREE,
value_fn=lambda data: data.conditions.get("wind_bearing", {}).get("value"),
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
ECSensorEntityDescription(
key="wind_chill",
@@ -253,7 +255,7 @@ ALERT_TYPES: tuple[ECSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ECConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
weather_coordinator = config_entry.runtime_data.weather_coordinator
@@ -287,7 +289,7 @@ class ECBaseSensorEntity[DataT: ECDataType](
super().__init__(coordinator)
self.entity_description = description
self._ec_data = coordinator.ec_data
- self._attr_attribution = self._ec_data.metadata["attribution"]
+ self._attr_attribution = self._ec_data.metadata.attribution
self._attr_unique_id = f"{coordinator.config_entry.title}-{description.key}"
self._attr_device_info = coordinator.device_info
@@ -311,8 +313,8 @@ class ECSensorEntity[DataT: ECDataType](ECBaseSensorEntity[DataT]):
"""Initialize the sensor."""
super().__init__(coordinator, description)
self._attr_extra_state_attributes = {
- ATTR_LOCATION: self._ec_data.metadata.get("location"),
- ATTR_STATION: self._ec_data.metadata.get("station"),
+ ATTR_LOCATION: self._ec_data.metadata.location,
+ ATTR_STATION: self._ec_data.metadata.station,
}
@@ -327,8 +329,8 @@ class ECAlertSensorEntity(ECBaseSensorEntity[ECWeather]):
return None
extra_state_attrs = {
- ATTR_LOCATION: self._ec_data.metadata.get("location"),
- ATTR_STATION: self._ec_data.metadata.get("station"),
+ ATTR_LOCATION: self._ec_data.metadata.location,
+ ATTR_STATION: self._ec_data.metadata.station,
}
for index, alert in enumerate(value, start=1):
extra_state_attrs[f"alert_{index}"] = alert.get("title")
diff --git a/homeassistant/components/environment_canada/services.yaml b/homeassistant/components/environment_canada/services.yaml
index 4293b313f5c..0e33aeec933 100644
--- a/homeassistant/components/environment_canada/services.yaml
+++ b/homeassistant/components/environment_canada/services.yaml
@@ -1,3 +1,9 @@
+get_forecasts:
+ target:
+ entity:
+ integration: environment_canada
+ domain: weather
+
set_radar_type:
target:
entity:
diff --git a/homeassistant/components/environment_canada/strings.json b/homeassistant/components/environment_canada/strings.json
index 28ca55c6195..1ccff145bb3 100644
--- a/homeassistant/components/environment_canada/strings.json
+++ b/homeassistant/components/environment_canada/strings.json
@@ -113,6 +113,10 @@
}
},
"services": {
+ "get_forecasts": {
+ "name": "Get forecasts",
+ "description": "Retrieves the forecast from selected weather services."
+ },
"set_radar_type": {
"name": "Set radar type",
"description": "Sets the type of radar image to retrieve.",
diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py
index 156b9f4152b..a5acb224bd0 100644
--- a/homeassistant/components/environment_canada/weather.py
+++ b/homeassistant/components/environment_canada/weather.py
@@ -35,11 +35,16 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceResponse,
+ SupportsResponse,
+ callback,
+)
+from homeassistant.helpers import entity_platform, entity_registry as er
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
+from .const import DOMAIN, SERVICE_ENVIRONMENT_CANADA_FORECASTS
from .coordinator import ECConfigEntry, ECDataUpdateCoordinator
# Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/
@@ -63,7 +68,7 @@ ICON_CONDITION_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ECConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
entity_registry = er.async_get(hass)
@@ -78,6 +83,14 @@ async def async_setup_entry(
async_add_entities([ECWeatherEntity(config_entry.runtime_data.weather_coordinator)])
+ platform = entity_platform.async_get_current_platform()
+ platform.async_register_entity_service(
+ SERVICE_ENVIRONMENT_CANADA_FORECASTS,
+ None,
+ "_async_environment_canada_forecasts",
+ supports_response=SupportsResponse.ONLY,
+ )
+
def _calculate_unique_id(config_entry_unique_id: str | None, hourly: bool) -> str:
"""Calculate unique ID."""
@@ -102,7 +115,7 @@ class ECWeatherEntity(
"""Initialize Environment Canada weather."""
super().__init__(coordinator)
self.ec_data = coordinator.ec_data
- self._attr_attribution = self.ec_data.metadata["attribution"]
+ self._attr_attribution = self.ec_data.metadata.attribution
self._attr_translation_key = "forecast"
self._attr_unique_id = _calculate_unique_id(
coordinator.config_entry.unique_id, False
@@ -185,6 +198,23 @@ class ECWeatherEntity(
"""Return the hourly forecast in native units."""
return get_forecast(self.ec_data, True)
+ def _async_environment_canada_forecasts(self) -> ServiceResponse:
+ """Return the native Environment Canada forecast."""
+ daily = []
+ for f in self.ec_data.daily_forecasts:
+ day = f.copy()
+ day["timestamp"] = day["timestamp"].isoformat()
+ daily.append(day)
+
+ hourly = []
+ for f in self.ec_data.hourly_forecasts:
+ hour = f.copy()
+ hour["timestamp"] = hour["period"].isoformat()
+ del hour["period"]
+ hourly.append(hour)
+
+ return {"daily_forecast": daily, "hourly_forecast": hourly}
+
def get_forecast(ec_data, hourly) -> list[Forecast] | None:
"""Build the forecast array."""
diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py
index f92be005db6..dbd7ab9e25d 100644
--- a/homeassistant/components/ephember/climate.py
+++ b/homeassistant/components/ephember/climate.py
@@ -6,13 +6,13 @@ from datetime import timedelta
import logging
from typing import Any
-from pyephember.pyephember import (
+from pyephember2.pyephember2 import (
EphEmber,
ZoneMode,
zone_current_temperature,
zone_is_active,
zone_is_boost_active,
- zone_is_hot_water,
+ zone_is_hotwater,
zone_mode,
zone_name,
zone_target_temperature,
@@ -69,14 +69,18 @@ def setup_platform(
try:
ember = EphEmber(username, password)
- zones = ember.get_zones()
- for zone in zones:
- add_entities([EphEmberThermostat(ember, zone)])
except RuntimeError:
- _LOGGER.error("Cannot connect to EphEmber")
+ _LOGGER.error("Cannot login to EphEmber")
+
+ try:
+ homes = ember.get_zones()
+ except RuntimeError:
+ _LOGGER.error("Fail to get zones")
return
- return
+ add_entities(
+ EphEmberThermostat(ember, zone) for home in homes for zone in home["zones"]
+ )
class EphEmberThermostat(ClimateEntity):
@@ -85,33 +89,35 @@ class EphEmberThermostat(ClimateEntity):
_attr_hvac_modes = OPERATION_LIST
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- def __init__(self, ember, zone):
+ def __init__(self, ember, zone) -> None:
"""Initialize the thermostat."""
self._ember = ember
self._zone_name = zone_name(zone)
self._zone = zone
- self._hot_water = zone_is_hot_water(zone)
+
+ # hot water = true, is immersive device without target temperature control.
+ self._hot_water = zone_is_hotwater(zone)
self._attr_name = self._zone_name
- self._attr_supported_features = (
- ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.AUX_HEAT
- )
- self._attr_target_temperature_step = 0.5
if self._hot_water:
self._attr_supported_features = ClimateEntityFeature.AUX_HEAT
self._attr_target_temperature_step = None
- self._attr_supported_features |= (
- ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
- )
+ else:
+ self._attr_target_temperature_step = 0.5
+ self._attr_supported_features = (
+ ClimateEntityFeature.TURN_OFF
+ | ClimateEntityFeature.TURN_ON
+ | ClimateEntityFeature.TARGET_TEMPERATURE
+ )
@property
- def current_temperature(self):
+ def current_temperature(self) -> float | None:
"""Return the current temperature."""
return zone_current_temperature(self._zone)
@property
- def target_temperature(self):
+ def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
return zone_target_temperature(self._zone)
@@ -133,12 +139,12 @@ class EphEmberThermostat(ClimateEntity):
"""Set the operation mode."""
mode = self.map_mode_hass_eph(hvac_mode)
if mode is not None:
- self._ember.set_mode_by_name(self._zone_name, mode)
+ self._ember.set_zone_mode(self._zone["zoneid"], mode)
else:
_LOGGER.error("Invalid operation mode provided %s", hvac_mode)
@property
- def is_aux_heat(self):
+ def is_aux_heat(self) -> bool:
"""Return true if aux heater."""
return zone_is_boost_active(self._zone)
@@ -167,7 +173,7 @@ class EphEmberThermostat(ClimateEntity):
if temperature > self.max_temp or temperature < self.min_temp:
return
- self._ember.set_target_temperture_by_name(self._zone_name, temperature)
+ self._ember.set_zone_target_temperature(self._zone["zoneid"], temperature)
@property
def min_temp(self):
@@ -188,7 +194,8 @@ class EphEmberThermostat(ClimateEntity):
def update(self) -> None:
"""Get the latest data."""
- self._zone = self._ember.get_zone(self._zone_name)
+ self._ember.get_zones()
+ self._zone = self._ember.get_zone(self._zone["zoneid"])
@staticmethod
def map_mode_hass_eph(operation_mode):
diff --git a/homeassistant/components/ephember/manifest.json b/homeassistant/components/ephember/manifest.json
index 547ab2918f5..7d78149d068 100644
--- a/homeassistant/components/ephember/manifest.json
+++ b/homeassistant/components/ephember/manifest.json
@@ -1,10 +1,10 @@
{
"domain": "ephember",
"name": "EPH Controls",
- "codeowners": ["@ttroy50"],
+ "codeowners": ["@ttroy50", "@roberty99"],
"documentation": "https://www.home-assistant.io/integrations/ephember",
"iot_class": "local_polling",
- "loggers": ["pyephember"],
+ "loggers": ["pyephember2"],
"quality_scale": "legacy",
- "requirements": ["pyephember==0.3.1"]
+ "requirements": ["pyephember2==0.4.12"]
}
diff --git a/homeassistant/components/epic_games_store/calendar.py b/homeassistant/components/epic_games_store/calendar.py
index 5df1d6b756d..41edb5e31a7 100644
--- a/homeassistant/components/epic_games_store/calendar.py
+++ b/homeassistant/components/epic_games_store/calendar.py
@@ -9,7 +9,7 @@ from typing import Any
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, CalendarType
@@ -21,7 +21,7 @@ DateRange = namedtuple("DateRange", ["start", "end"]) # noqa: PYI024
async def async_setup_entry(
hass: HomeAssistant,
entry: EGSConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the local calendar platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/epic_games_store/strings.json b/homeassistant/components/epic_games_store/strings.json
index 58a87a55f81..ab4562a72ad 100644
--- a/homeassistant/components/epic_games_store/strings.json
+++ b/homeassistant/components/epic_games_store/strings.json
@@ -3,8 +3,8 @@
"step": {
"user": {
"data": {
- "language": "Language",
- "country": "Country"
+ "language": "[%key:common::config_flow::data::language%]",
+ "country": "[%key:common::config_flow::data::country%]"
}
}
},
diff --git a/homeassistant/components/epion/sensor.py b/homeassistant/components/epion/sensor.py
index 78027813ffa..360c1f1d8a7 100644
--- a/homeassistant/components/epion/sensor.py
+++ b/homeassistant/components/epion/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -59,7 +59,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: EpionConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add an Epion entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/epson/config_flow.py b/homeassistant/components/epson/config_flow.py
index c54bff2eea9..077b9cc31f7 100644
--- a/homeassistant/components/epson/config_flow.py
+++ b/homeassistant/components/epson/config_flow.py
@@ -72,5 +72,7 @@ class EpsonConfigFlow(ConfigFlow, domain=DOMAIN):
if projector:
projector.close()
return self.async_show_form(
- step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ step_id="user",
+ data_schema=self.add_suggested_values_to_schema(DATA_SCHEMA, user_input),
+ errors=errors,
)
diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py
index e0eac4a1cfb..c1582d6f0e5 100644
--- a/homeassistant/components/epson/media_player.py
+++ b/homeassistant/components/epson/media_player.py
@@ -43,7 +43,7 @@ from homeassistant.helpers import (
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import EpsonConfigEntry
from .const import ATTR_CMODE, DOMAIN, SERVICE_SELECT_CMODE
@@ -54,7 +54,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EpsonConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Epson projector from a config entry."""
projector_entity = EpsonProjectorMediaPlayer(
diff --git a/homeassistant/components/eq3btsmart/binary_sensor.py b/homeassistant/components/eq3btsmart/binary_sensor.py
index 27525d47972..55b1f4d6ced 100644
--- a/homeassistant/components/eq3btsmart/binary_sensor.py
+++ b/homeassistant/components/eq3btsmart/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Eq3ConfigEntry
from .const import ENTITY_KEY_BATTERY, ENTITY_KEY_DST, ENTITY_KEY_WINDOW
@@ -51,7 +51,7 @@ BINARY_SENSOR_ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the entry."""
diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py
index ae01d0fc9a7..738efa99187 100644
--- a/homeassistant/components/eq3btsmart/climate.py
+++ b/homeassistant/components/eq3btsmart/climate.py
@@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Eq3ConfigEntry
from .const import (
@@ -37,7 +37,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Handle config entry setup."""
diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json
index 0bc3ae55236..d99de32b09c 100644
--- a/homeassistant/components/eq3btsmart/manifest.json
+++ b/homeassistant/components/eq3btsmart/manifest.json
@@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
- "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"]
+ "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.13.1"]
}
diff --git a/homeassistant/components/eq3btsmart/number.py b/homeassistant/components/eq3btsmart/number.py
index 2e069180fa3..c3cbd8eae31 100644
--- a/homeassistant/components/eq3btsmart/number.py
+++ b/homeassistant/components/eq3btsmart/number.py
@@ -21,7 +21,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Eq3ConfigEntry
from .const import (
@@ -109,7 +109,7 @@ NUMBER_ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the entry."""
diff --git a/homeassistant/components/eq3btsmart/sensor.py b/homeassistant/components/eq3btsmart/sensor.py
index bd2605042f4..aab3cbf1925 100644
--- a/homeassistant/components/eq3btsmart/sensor.py
+++ b/homeassistant/components/eq3btsmart/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.components.sensor.const import SensorStateClass
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Eq3ConfigEntry
from .const import ENTITY_KEY_AWAY_UNTIL, ENTITY_KEY_VALVE
@@ -51,7 +51,7 @@ SENSOR_ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the entry."""
diff --git a/homeassistant/components/eq3btsmart/switch.py b/homeassistant/components/eq3btsmart/switch.py
index 7525d8ca494..61da133cb71 100644
--- a/homeassistant/components/eq3btsmart/switch.py
+++ b/homeassistant/components/eq3btsmart/switch.py
@@ -9,7 +9,7 @@ from eq3btsmart.models import Status
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Eq3ConfigEntry
from .const import ENTITY_KEY_AWAY, ENTITY_KEY_BOOST, ENTITY_KEY_LOCK
@@ -49,7 +49,7 @@ SWITCH_ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the entry."""
diff --git a/homeassistant/components/escea/climate.py b/homeassistant/components/escea/climate.py
index c3fb0015e68..a1ac83844a2 100644
--- a/homeassistant/components/escea/climate.py
+++ b/homeassistant/components/escea/climate.py
@@ -21,7 +21,7 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTempera
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DATA_DISCOVERY_SERVICE,
@@ -47,7 +47,7 @@ _HA_FAN_TO_ESCEA = {v: k for k, v in _ESCEA_FAN_TO_HA.items()}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize an Escea Controller."""
discovery_service = hass.data[DATA_DISCOVERY_SERVICE]
diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py
index fee2531fa20..467dbf74190 100644
--- a/homeassistant/components/esphome/__init__.py
+++ b/homeassistant/components/esphome/__init__.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from aioesphomeapi import APIClient
-from homeassistant.components import ffmpeg, zeroconf
+from homeassistant.components import zeroconf
from homeassistant.components.bluetooth import async_remove_scanner
from homeassistant.const import (
CONF_HOST,
@@ -14,16 +14,14 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.issue_registry import async_delete_issue
from homeassistant.helpers.typing import ConfigType
-from .const import CONF_NOISE_PSK, DATA_FFMPEG_PROXY, DOMAIN
-from .dashboard import async_setup as async_setup_dashboard
+from . import dashboard, ffmpeg_proxy
+from .const import CONF_BLUETOOTH_MAC_ADDRESS, CONF_NOISE_PSK, DOMAIN
from .domain_data import DomainData
-
-# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
-from .ffmpeg_proxy import FFmpegProxyData, FFmpegProxyView
-from .manager import ESPHomeManager, cleanup_instance
+from .manager import DEVICE_CONFLICT_ISSUE_FORMAT, ESPHomeManager, cleanup_instance
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -32,12 +30,8 @@ CLIENT_INFO = f"Home Assistant {ha_version}"
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the esphome component."""
- proxy_data = hass.data[DATA_FFMPEG_PROXY] = FFmpegProxyData()
-
- await async_setup_dashboard(hass)
- hass.http.register_view(
- FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data)
- )
+ ffmpeg_proxy.async_setup(hass)
+ await dashboard.async_setup(hass)
return True
@@ -87,6 +81,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) ->
async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None:
"""Remove an esphome config entry."""
- if mac_address := entry.unique_id:
- async_remove_scanner(hass, mac_address.upper())
+ if bluetooth_mac_address := entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS):
+ async_remove_scanner(hass, bluetooth_mac_address.upper())
+ async_delete_issue(
+ hass, DOMAIN, DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id)
+ )
await DomainData.get(hass).get_or_create_store(hass, entry).async_remove()
diff --git a/homeassistant/components/esphome/alarm_control_panel.py b/homeassistant/components/esphome/alarm_control_panel.py
index 8f1b5ae8b1a..6dc4647e42e 100644
--- a/homeassistant/components/esphome/alarm_control_panel.py
+++ b/homeassistant/components/esphome/alarm_control_panel.py
@@ -29,6 +29,8 @@ from .entity import (
)
from .enum_mapper import EsphomeEnumMapper
+PARALLEL_UPDATES = 0
+
_ESPHOME_ACP_STATE_TO_HASS_STATE: EsphomeEnumMapper[
ESPHomeAlarmControlPanelState, AlarmControlPanelState
] = EsphomeEnumMapper(
diff --git a/homeassistant/components/esphome/assist_satellite.py b/homeassistant/components/esphome/assist_satellite.py
index f60668b0a06..cf1e299a6f0 100644
--- a/homeassistant/components/esphome/assist_satellite.py
+++ b/homeassistant/components/esphome/assist_satellite.py
@@ -39,7 +39,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import EsphomeAssistEntity
@@ -47,6 +47,8 @@ from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .enum_mapper import EsphomeEnumMapper
from .ffmpeg_proxy import async_create_proxy_url
+PARALLEL_UPDATES = 0
+
_LOGGER = logging.getLogger(__name__)
_VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[
@@ -87,7 +89,7 @@ _CONFIG_TIMEOUT_SEC = 5
async def async_setup_entry(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Assist satellite entity."""
entry_data = entry.runtime_data
@@ -253,6 +255,11 @@ class EsphomeAssistSatellite(
# Will use media player for TTS/announcements
self._update_tts_format()
+ if feature_flags & VoiceAssistantFeature.START_CONVERSATION:
+ self._attr_supported_features |= (
+ assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
+ )
+
# Update wake word select when config is updated
self.async_on_remove(
self.entry_data.async_register_assist_satellite_set_wake_word_callback(
@@ -284,7 +291,10 @@ class EsphomeAssistSatellite(
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
assert event.data is not None
data_to_send = {
- "conversation_id": event.data["intent_output"]["conversation_id"] or "",
+ "conversation_id": event.data["intent_output"]["conversation_id"],
+ "continue_conversation": str(
+ int(event.data["intent_output"]["continue_conversation"])
+ ),
}
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
assert event.data is not None
@@ -302,12 +312,13 @@ class EsphomeAssistSatellite(
self.entry_data.api_version
)
)
- if feature_flags & VoiceAssistantFeature.SPEAKER:
- media_id = tts_output["media_id"]
+ if feature_flags & VoiceAssistantFeature.SPEAKER and (
+ stream := tts.async_get_stream(self.hass, tts_output["token"])
+ ):
self._tts_streaming_task = (
self.config_entry.async_create_background_task(
self.hass,
- self._stream_tts_audio(media_id),
+ self._stream_tts_audio(stream),
"esphome_voice_assistant_tts",
)
)
@@ -339,14 +350,33 @@ class EsphomeAssistSatellite(
Should block until the announcement is done playing.
"""
+ await self._do_announce(announcement, run_pipeline_after=False)
+
+ async def async_start_conversation(
+ self, start_announcement: assist_satellite.AssistSatelliteAnnouncement
+ ) -> None:
+ """Start a conversation from the satellite."""
+ await self._do_announce(start_announcement, run_pipeline_after=True)
+
+ async def _do_announce(
+ self,
+ announcement: assist_satellite.AssistSatelliteAnnouncement,
+ run_pipeline_after: bool,
+ ) -> None:
+ """Announce media on the satellite.
+
+ Optionally run a voice pipeline after the announcement has finished.
+ """
_LOGGER.debug(
"Waiting for announcement to finished (message=%s, media_id=%s)",
announcement.message,
announcement.media_id,
)
media_id = announcement.media_id
- if announcement.media_id_source != "tts":
- # Route non-TTS media through the proxy
+ is_media_tts = announcement.media_id_source == "tts"
+ preannounce_media_id = announcement.preannounce_media_id
+ if (not is_media_tts) or preannounce_media_id:
+ # Route media through the proxy
format_to_use: MediaPlayerSupportedFormat | None = None
for supported_format in chain(
*self.entry_data.media_player_formats.values()
@@ -359,19 +389,33 @@ class EsphomeAssistSatellite(
assert (self.registry_entry is not None) and (
self.registry_entry.device_id is not None
)
- proxy_url = async_create_proxy_url(
- self.hass,
- self.registry_entry.device_id,
- media_id,
+
+ make_proxy_url = partial(
+ async_create_proxy_url,
+ hass=self.hass,
+ device_id=self.registry_entry.device_id,
media_format=format_to_use.format,
rate=format_to_use.sample_rate or None,
channels=format_to_use.num_channels or None,
width=format_to_use.sample_bytes or None,
)
- media_id = async_process_play_media_url(self.hass, proxy_url)
+
+ if not is_media_tts:
+ media_id = async_process_play_media_url(
+ self.hass, make_proxy_url(media_url=media_id)
+ )
+
+ if preannounce_media_id:
+ preannounce_media_id = async_process_play_media_url(
+ self.hass, make_proxy_url(media_url=preannounce_media_id)
+ )
await self.cli.send_voice_assistant_announcement_await_response(
- media_id, _ANNOUNCEMENT_TIMEOUT_SEC, announcement.message
+ media_id,
+ _ANNOUNCEMENT_TIMEOUT_SEC,
+ announcement.message,
+ start_conversation=run_pipeline_after,
+ preannounce_media_id=preannounce_media_id or "",
)
async def handle_pipeline_start(
@@ -523,7 +567,7 @@ class EsphomeAssistSatellite(
async def _stream_tts_audio(
self,
- media_id: str,
+ tts_result: tts.ResultStream,
sample_rate: int = 16000,
sample_width: int = 2,
sample_channels: int = 1,
@@ -538,15 +582,14 @@ class EsphomeAssistSatellite(
if not self._is_running:
return
- extension, data = await tts.async_get_media_source_audio(
- self.hass,
- media_id,
- )
-
- if extension != "wav":
- _LOGGER.error("Only WAV audio can be streamed, got %s", extension)
+ if tts_result.extension != "wav":
+ _LOGGER.error(
+ "Only WAV audio can be streamed, got %s", tts_result.extension
+ )
return
+ data = b"".join([chunk async for chunk in tts_result.async_stream_result()])
+
with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file:
if (
(wav_file.getframerate() != sample_rate)
diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py
index ac759aa7b17..bf773fead0c 100644
--- a/homeassistant/components/esphome/binary_sensor.py
+++ b/homeassistant/components/esphome/binary_sensor.py
@@ -13,18 +13,20 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN
from .entity import EsphomeAssistEntity, EsphomeEntity, platform_async_setup_entry
from .entry_data import ESPHomeConfigEntry
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ESPHome binary sensors based on a config entry."""
await platform_async_setup_entry(
diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py
index f13fa65ede1..31121d98ff7 100644
--- a/homeassistant/components/esphome/button.py
+++ b/homeassistant/components/esphome/button.py
@@ -16,6 +16,8 @@ from .entity import (
platform_async_setup_entry,
)
+PARALLEL_UPDATES = 0
+
class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity):
"""A button implementation for ESPHome."""
diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py
index 6038bf52e06..e2213153092 100644
--- a/homeassistant/components/esphome/camera.py
+++ b/homeassistant/components/esphome/camera.py
@@ -16,6 +16,8 @@ from homeassistant.core import callback
from .entity import EsphomeEntity, platform_async_setup_entry
+PARALLEL_UPDATES = 0
+
class EsphomeCamera(Camera, EsphomeEntity[CameraInfo, CameraState]):
"""A camera implementation for ESPHome."""
diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py
index 478ce9bae2c..3f80f04e527 100644
--- a/homeassistant/components/esphome/climate.py
+++ b/homeassistant/components/esphome/climate.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from functools import partial
+from math import isfinite
from typing import Any, cast
from aioesphomeapi import (
@@ -64,6 +65,8 @@ from .entity import (
)
from .enum_mapper import EsphomeEnumMapper
+PARALLEL_UPDATES = 0
+
FAN_QUIET = "quiet"
@@ -238,9 +241,13 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
@esphome_state_property
def current_humidity(self) -> int | None:
"""Return the current humidity."""
- if not self._static_info.supports_current_humidity:
+ if (
+ not self._static_info.supports_current_humidity
+ or (val := self._state.current_humidity) is None
+ or not isfinite(val)
+ ):
return None
- return round(self._state.current_humidity)
+ return round(val)
@property
@esphome_float_state_property
diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py
index 695131b19f7..2b1babfc0ba 100644
--- a/homeassistant/components/esphome/config_flow.py
+++ b/homeassistant/components/esphome/config_flow.py
@@ -23,6 +23,7 @@ import voluptuous as vol
from homeassistant.components import zeroconf
from homeassistant.config_entries import (
SOURCE_REAUTH,
+ SOURCE_RECONFIGURE,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
@@ -41,15 +42,18 @@ from .const import (
CONF_ALLOW_SERVICE_CALLS,
CONF_DEVICE_NAME,
CONF_NOISE_PSK,
+ CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
+ DEFAULT_PORT,
DOMAIN,
)
from .dashboard import async_get_or_create_dashboard_manager, async_set_dashboard_info
+from .entry_data import ESPHomeConfigEntry
+from .manager import async_replace_device
ERROR_REQUIRES_ENCRYPTION_KEY = "requires_encryption_key"
ERROR_INVALID_ENCRYPTION_KEY = "invalid_psk"
-ESPHOME_URL = "https://esphome.io/"
_LOGGER = logging.getLogger(__name__)
ZERO_NOISE_PSK = "MDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA="
@@ -61,6 +65,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1
_reauth_entry: ConfigEntry
+ _reconfig_entry: ConfigEntry
def __init__(self) -> None:
"""Initialize flow."""
@@ -73,6 +78,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._device_info: DeviceInfo | None = None
# The ESPHome name as per its config
self._device_name: str | None = None
+ self._device_mac: str | None = None
+ self._entry_with_name_conflict: ConfigEntry | None = None
async def _async_step_user_base(
self, user_input: dict[str, Any] | None = None, error: str | None = None
@@ -84,7 +91,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
fields: dict[Any, type] = OrderedDict()
fields[vol.Required(CONF_HOST, default=self._host or vol.UNDEFINED)] = str
- fields[vol.Optional(CONF_PORT, default=self._port or 6053)] = int
+ fields[vol.Optional(CONF_PORT, default=self._port or DEFAULT_PORT)] = int
errors = {}
if error is not None:
@@ -94,7 +101,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=vol.Schema(fields),
errors=errors,
- description_placeholders={"esphome_url": ESPHOME_URL},
)
async def async_step_user(
@@ -127,8 +133,23 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self._password = ""
return await self._async_authenticate_or_add()
+ if error is None and entry_data.get(CONF_NOISE_PSK):
+ return await self.async_step_reauth_encryption_removed_confirm()
return await self.async_step_reauth_confirm()
+ async def async_step_reauth_encryption_removed_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reauthorization flow when encryption was removed."""
+ if user_input is not None:
+ self._noise_psk = None
+ return await self._async_validated_connection()
+
+ return self.async_show_form(
+ step_id="reauth_encryption_removed_confirm",
+ description_placeholders={"name": self._name},
+ )
+
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -154,6 +175,18 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
description_placeholders={"name": self._name},
)
+ async def async_step_reconfigure(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by a reconfig request."""
+ self._reconfig_entry = self._get_reconfigure_entry()
+ data = self._reconfig_entry.data
+ self._host = data[CONF_HOST]
+ self._port = data.get(CONF_PORT, DEFAULT_PORT)
+ self._noise_psk = data.get(CONF_NOISE_PSK)
+ self._device_name = data.get(CONF_DEVICE_NAME)
+ return await self._async_step_user_base()
+
@property
def _name(self) -> str:
return self.__name or "ESPHome"
@@ -212,7 +245,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return await self.async_step_authenticate()
self._password = ""
- return self._async_get_entry()
+ return await self._async_validated_connection()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
@@ -249,12 +282,32 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Check if already configured
await self.async_set_unique_id(mac_address)
- self._abort_if_unique_id_configured(
- updates={CONF_HOST: self._host, CONF_PORT: self._port}
+ await self._async_validate_mac_abort_configured(
+ mac_address, self._host, self._port
)
-
return await self.async_step_discovery_confirm()
+ async def _async_validate_mac_abort_configured(
+ self, formatted_mac: str, host: str, port: int | None
+ ) -> None:
+ """Validate if the MAC address is already configured."""
+ assert self.unique_id is not None
+ if not (
+ entry := self.hass.config_entries.async_entry_for_domain_unique_id(
+ self.handler, formatted_mac
+ )
+ ):
+ return
+ configured_port: int | None = entry.data.get(CONF_PORT)
+ configured_psk: str | None = entry.data.get(CONF_NOISE_PSK)
+ await self._fetch_device_info(host, port or configured_port, configured_psk)
+ updates: dict[str, Any] = {}
+ if self._device_mac == formatted_mac:
+ updates[CONF_HOST] = host
+ if port is not None:
+ updates[CONF_PORT] = port
+ self._abort_if_unique_id_configured(updates=updates)
+
async def async_step_mqtt(
self, discovery_info: MqttServiceInfo
) -> ConfigFlowResult:
@@ -298,8 +351,11 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
- await self.async_set_unique_id(format_mac(discovery_info.macaddress))
- self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
+ mac_address = format_mac(discovery_info.macaddress)
+ await self.async_set_unique_id(format_mac(mac_address))
+ await self._async_validate_mac_abort_configured(
+ mac_address, discovery_info.ip, None
+ )
# This should never happen since we only listen to DHCP requests
# for configured devices.
return self.async_abort(reason="already_configured")
@@ -316,9 +372,84 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
)
return self.async_abort(reason="service_received")
+ async def async_step_name_conflict(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle name conflict resolution."""
+ assert self._entry_with_name_conflict is not None
+ assert self._entry_with_name_conflict.unique_id is not None
+ assert self.unique_id is not None
+ assert self._device_name is not None
+ return self.async_show_menu(
+ step_id="name_conflict",
+ menu_options=["name_conflict_migrate", "name_conflict_overwrite"],
+ description_placeholders={
+ "existing_mac": format_mac(self._entry_with_name_conflict.unique_id),
+ "existing_title": self._entry_with_name_conflict.title,
+ "mac": format_mac(self.unique_id),
+ "name": self._device_name,
+ },
+ )
+
+ async def async_step_name_conflict_migrate(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle migration of existing entry."""
+ assert self._entry_with_name_conflict is not None
+ assert self._entry_with_name_conflict.unique_id is not None
+ assert self.unique_id is not None
+ assert self._device_name is not None
+ assert self._host is not None
+ old_mac = format_mac(self._entry_with_name_conflict.unique_id)
+ new_mac = format_mac(self.unique_id)
+ entry_id = self._entry_with_name_conflict.entry_id
+ self.hass.config_entries.async_update_entry(
+ self._entry_with_name_conflict,
+ data={
+ **self._entry_with_name_conflict.data,
+ CONF_HOST: self._host,
+ CONF_PORT: self._port or DEFAULT_PORT,
+ CONF_PASSWORD: self._password or "",
+ CONF_NOISE_PSK: self._noise_psk or "",
+ },
+ )
+ await async_replace_device(self.hass, entry_id, old_mac, new_mac)
+ self.hass.config_entries.async_schedule_reload(entry_id)
+ return self.async_abort(
+ reason="name_conflict_migrated",
+ description_placeholders={
+ "existing_mac": old_mac,
+ "mac": new_mac,
+ "name": self._device_name,
+ },
+ )
+
+ async def async_step_name_conflict_overwrite(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle creating a new entry by removing the old one and creating new."""
+ assert self._entry_with_name_conflict is not None
+ await self.hass.config_entries.async_remove(
+ self._entry_with_name_conflict.entry_id
+ )
+ return self._async_create_entry()
+
@callback
- def _async_get_entry(self) -> ConfigFlowResult:
- config_data = {
+ def _async_create_entry(self) -> ConfigFlowResult:
+ """Create the config entry."""
+ assert self._name is not None
+ return self.async_create_entry(
+ title=self._name,
+ data=self._async_make_config_data(),
+ options={
+ CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
+ },
+ )
+
+ @callback
+ def _async_make_config_data(self) -> dict[str, Any]:
+ """Return config data for the entry."""
+ return {
CONF_HOST: self._host,
CONF_PORT: self._port,
# The API uses protobuf, so empty string denotes absence
@@ -326,19 +457,99 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
CONF_NOISE_PSK: self._noise_psk or "",
CONF_DEVICE_NAME: self._device_name,
}
- config_options = {
- CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
- }
- if self.source == SOURCE_REAUTH:
- return self.async_update_reload_and_abort(
- self._reauth_entry, data=self._reauth_entry.data | config_data
- )
- assert self._name is not None
- return self.async_create_entry(
- title=self._name,
- data=config_data,
- options=config_options,
+ async def _async_validated_connection(self) -> ConfigFlowResult:
+ """Handle validated connection."""
+ if self.source == SOURCE_RECONFIGURE:
+ return await self._async_reconfig_validated_connection()
+ if self.source == SOURCE_REAUTH:
+ return await self._async_reauth_validated_connection()
+ for entry in self._async_current_entries(include_ignore=False):
+ if entry.data.get(CONF_DEVICE_NAME) == self._device_name:
+ self._entry_with_name_conflict = entry
+ return await self.async_step_name_conflict()
+ return self._async_create_entry()
+
+ async def _async_reauth_validated_connection(self) -> ConfigFlowResult:
+ """Handle reauth validated connection."""
+ assert self._reauth_entry.unique_id is not None
+ if self.unique_id == self._reauth_entry.unique_id:
+ return self.async_update_reload_and_abort(
+ self._reauth_entry,
+ data=self._reauth_entry.data | self._async_make_config_data(),
+ )
+ assert self._host is not None
+ self._abort_if_unique_id_configured(
+ updates={
+ CONF_HOST: self._host,
+ CONF_PORT: self._port,
+ CONF_NOISE_PSK: self._noise_psk,
+ }
+ )
+ # Reauth was triggered a while ago, and since than
+ # a new device resides at the same IP address.
+ assert self._device_name is not None
+ return self.async_abort(
+ reason="reauth_unique_id_changed",
+ description_placeholders={
+ "name": self._reauth_entry.data.get(
+ CONF_DEVICE_NAME, self._reauth_entry.title
+ ),
+ "host": self._host,
+ "expected_mac": format_mac(self._reauth_entry.unique_id),
+ "unexpected_mac": format_mac(self.unique_id),
+ "unexpected_device_name": self._device_name,
+ },
+ )
+
+ async def _async_reconfig_validated_connection(self) -> ConfigFlowResult:
+ """Handle reconfigure validated connection."""
+ assert self._reconfig_entry.unique_id is not None
+ assert self._host is not None
+ assert self._device_name is not None
+ if not (
+ unique_id_matches := (self.unique_id == self._reconfig_entry.unique_id)
+ ):
+ self._abort_if_unique_id_configured(
+ updates={
+ CONF_HOST: self._host,
+ CONF_PORT: self._port,
+ CONF_NOISE_PSK: self._noise_psk,
+ }
+ )
+ for entry in self._async_current_entries(include_ignore=False):
+ if (
+ entry.entry_id != self._reconfig_entry.entry_id
+ and entry.data.get(CONF_DEVICE_NAME) == self._device_name
+ ):
+ return self.async_abort(
+ reason="reconfigure_name_conflict",
+ description_placeholders={
+ "name": self._reconfig_entry.data[CONF_DEVICE_NAME],
+ "host": self._host,
+ "expected_mac": format_mac(self._reconfig_entry.unique_id),
+ "existing_title": entry.title,
+ },
+ )
+ if unique_id_matches:
+ return self.async_update_reload_and_abort(
+ self._reconfig_entry,
+ data=self._reconfig_entry.data | self._async_make_config_data(),
+ )
+ if self._reconfig_entry.data.get(CONF_DEVICE_NAME) == self._device_name:
+ self._entry_with_name_conflict = self._reconfig_entry
+ return await self.async_step_name_conflict()
+ return self.async_abort(
+ reason="reconfigure_unique_id_changed",
+ description_placeholders={
+ "name": self._reconfig_entry.data.get(
+ CONF_DEVICE_NAME, self._reconfig_entry.title
+ ),
+ "host": self._host,
+ "expected_mac": format_mac(self._reconfig_entry.unique_id),
+ "unexpected_mac": format_mac(self.unique_id),
+ "unexpected_device_name": self._device_name,
+ },
)
async def async_step_encryption_key(
@@ -369,7 +580,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
error = await self.try_login()
if error:
return await self.async_step_authenticate(error=error)
- return self._async_get_entry()
+ return await self._async_validated_connection()
errors = {}
if error is not None:
@@ -382,19 +593,18 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
- async def fetch_device_info(self) -> str | None:
+ async def _fetch_device_info(
+ self, host: str, port: int | None, noise_psk: str | None
+ ) -> str | None:
"""Fetch device info from API and return any errors."""
zeroconf_instance = await zeroconf.async_get_instance(self.hass)
- assert self._host is not None
- assert self._port is not None
cli = APIClient(
- self._host,
- self._port,
+ host,
+ port or DEFAULT_PORT,
"",
zeroconf_instance=zeroconf_instance,
- noise_psk=self._noise_psk,
+ noise_psk=noise_psk,
)
-
try:
await cli.connect()
self._device_info = await cli.device_info()
@@ -403,6 +613,8 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
except InvalidEncryptionKeyAPIError as ex:
if ex.received_name:
self._device_name = ex.received_name
+ if ex.received_mac:
+ self._device_mac = format_mac(ex.received_mac)
self._name = ex.received_name
return ERROR_INVALID_ENCRYPTION_KEY
except ResolveAPIError:
@@ -411,14 +623,29 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
return "connection_error"
finally:
await cli.disconnect(force=True)
-
self._name = self._device_info.friendly_name or self._device_info.name
self._device_name = self._device_info.name
+ self._device_mac = format_mac(self._device_info.mac_address)
+ return None
+
+ async def fetch_device_info(self) -> str | None:
+ """Fetch device info from API and return any errors."""
+ assert self._host is not None
+ assert self._port is not None
+ if error := await self._fetch_device_info(
+ self._host, self._port, self._noise_psk
+ ):
+ return error
+ assert self._device_info is not None
mac_address = format_mac(self._device_info.mac_address)
await self.async_set_unique_id(mac_address, raise_on_progress=False)
- if self.source != SOURCE_REAUTH:
+ if self.source not in (SOURCE_REAUTH, SOURCE_RECONFIGURE):
self._abort_if_unique_id_configured(
- updates={CONF_HOST: self._host, CONF_PORT: self._port}
+ updates={
+ CONF_HOST: self._host,
+ CONF_PORT: self._port,
+ CONF_NOISE_PSK: self._noise_psk,
+ }
)
return None
@@ -484,7 +711,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: ESPHomeConfigEntry,
) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler()
@@ -508,6 +735,10 @@ class OptionsFlowHandler(OptionsFlow):
CONF_ALLOW_SERVICE_CALLS, DEFAULT_ALLOW_SERVICE_CALLS
),
): bool,
+ vol.Required(
+ CONF_SUBSCRIBE_LOGS,
+ default=self.config_entry.options.get(CONF_SUBSCRIBE_LOGS, False),
+ ): bool,
}
)
return self.async_show_form(step_id="init", data_schema=data_schema)
diff --git a/homeassistant/components/esphome/const.py b/homeassistant/components/esphome/const.py
index 143aaa6342a..f793fd16bfe 100644
--- a/homeassistant/components/esphome/const.py
+++ b/homeassistant/components/esphome/const.py
@@ -1,22 +1,27 @@
"""ESPHome constants."""
+from typing import Final
+
from awesomeversion import AwesomeVersion
DOMAIN = "esphome"
CONF_ALLOW_SERVICE_CALLS = "allow_service_calls"
+CONF_SUBSCRIBE_LOGS = "subscribe_logs"
CONF_DEVICE_NAME = "device_name"
CONF_NOISE_PSK = "noise_psk"
+CONF_BLUETOOTH_MAC_ADDRESS = "bluetooth_mac_address"
DEFAULT_ALLOW_SERVICE_CALLS = True
DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS = False
+DEFAULT_PORT: Final = 6053
-STABLE_BLE_VERSION_STR = "2023.8.0"
+STABLE_BLE_VERSION_STR = "2025.2.2"
STABLE_BLE_VERSION = AwesomeVersion(STABLE_BLE_VERSION_STR)
PROJECT_URLS = {
"esphome.bluetooth-proxy": "https://esphome.github.io/bluetooth-proxies/",
}
-DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_VERSION_STR}.html"
-
-DATA_FFMPEG_PROXY = f"{DOMAIN}.ffmpeg_proxy"
+# ESPHome always uses .0 for the changelog URL
+STABLE_BLE_URL_VERSION = f"{STABLE_BLE_VERSION.major}.{STABLE_BLE_VERSION.minor}.0"
+DEFAULT_URL = f"https://esphome.io/changelog/{STABLE_BLE_URL_VERSION}.html"
diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py
index 83c749f89ca..4426724e3f4 100644
--- a/homeassistant/components/esphome/cover.py
+++ b/homeassistant/components/esphome/cover.py
@@ -24,6 +24,8 @@ from .entity import (
platform_async_setup_entry,
)
+PARALLEL_UPDATES = 0
+
class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity):
"""A cover implementation for ESPHome."""
diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py
index 334c16e5730..bbe4698f278 100644
--- a/homeassistant/components/esphome/dashboard.py
+++ b/homeassistant/components/esphome/dashboard.py
@@ -6,10 +6,11 @@ import asyncio
import logging
from typing import Any
-from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
+from homeassistant.config_entries import SOURCE_REAUTH
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.hass_dict import HassKey
@@ -60,11 +61,26 @@ class ESPHomeDashboardManager:
async def async_setup(self) -> None:
"""Restore the dashboard from storage."""
self._data = await self._store.async_load()
- if (data := self._data) and (info := data.get("info")):
- await self.async_set_dashboard_info(
- info["addon_slug"], info["host"], info["port"]
+ if not (data := self._data) or not (info := data.get("info")):
+ return
+ if is_hassio(self._hass):
+ from homeassistant.components.hassio import ( # pylint: disable=import-outside-toplevel
+ get_addons_info,
)
+ if (addons := get_addons_info(self._hass)) is not None and info[
+ "addon_slug"
+ ] not in addons:
+ # The addon is not installed anymore, but it make come back
+ # so we don't want to remove the dashboard, but for now
+ # we don't want to use it.
+ _LOGGER.debug("Addon %s is no longer installed", info["addon_slug"])
+ return
+
+ await self.async_set_dashboard_info(
+ info["addon_slug"], info["host"], info["port"]
+ )
+
@callback
def async_get(self) -> ESPHomeDashboardCoordinator | None:
"""Get the current dashboard."""
@@ -108,8 +124,7 @@ class ESPHomeDashboardManager:
reloads = [
hass.config_entries.async_reload(entry.entry_id)
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state is ConfigEntryState.LOADED
+ for entry in hass.config_entries.async_loaded_entries(DOMAIN)
]
# Re-auth flows will check the dashboard for encryption key when the form is requested
# but we only trigger reauth if the dashboard is available.
diff --git a/homeassistant/components/esphome/date.py b/homeassistant/components/esphome/date.py
index 28bc532918a..ef446cceac6 100644
--- a/homeassistant/components/esphome/date.py
+++ b/homeassistant/components/esphome/date.py
@@ -11,6 +11,8 @@ from homeassistant.components.date import DateEntity
from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry
+PARALLEL_UPDATES = 0
+
class EsphomeDate(EsphomeEntity[DateInfo, DateState], DateEntity):
"""A date implementation for esphome."""
diff --git a/homeassistant/components/esphome/datetime.py b/homeassistant/components/esphome/datetime.py
index d1bb0bb77ff..3ea285fa849 100644
--- a/homeassistant/components/esphome/datetime.py
+++ b/homeassistant/components/esphome/datetime.py
@@ -12,6 +12,8 @@ from homeassistant.util import dt as dt_util
from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry
+PARALLEL_UPDATES = 0
+
class EsphomeDateTime(EsphomeEntity[DateTimeInfo, DateTimeState], DateTimeEntity):
"""A datetime implementation for esphome."""
diff --git a/homeassistant/components/esphome/diagnostics.py b/homeassistant/components/esphome/diagnostics.py
index 58c9a8fe666..0903e874a15 100644
--- a/homeassistant/components/esphome/diagnostics.py
+++ b/homeassistant/components/esphome/diagnostics.py
@@ -13,9 +13,7 @@ from . import CONF_NOISE_PSK
from .dashboard import async_get_dashboard
from .entry_data import ESPHomeConfigEntry
-CONF_MAC_ADDRESS = "mac_address"
-
-REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, CONF_MAC_ADDRESS}
+REDACT_KEYS = {CONF_NOISE_PSK, CONF_PASSWORD, "mac_address", "bluetooth_mac_address"}
async def async_get_config_entry_diagnostics(
@@ -27,13 +25,17 @@ async def async_get_config_entry_diagnostics(
diag["config"] = config_entry.as_dict()
entry_data = config_entry.runtime_data
+ device_info = entry_data.device_info
if (storage_data := await entry_data.store.async_load()) is not None:
diag["storage_data"] = storage_data
if (
- config_entry.unique_id
- and (scanner := async_scanner_by_source(hass, config_entry.unique_id.upper()))
+ device_info
+ and (
+ scanner_mac := device_info.bluetooth_mac_address or device_info.mac_address
+ )
+ and (scanner := async_scanner_by_source(hass, scanner_mac.upper()))
and (bluetooth_device := entry_data.bluetooth_device)
):
diag["bluetooth"] = {
diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py
index ff08e5f578a..313785fd2df 100644
--- a/homeassistant/components/esphome/entity.py
+++ b/homeassistant/components/esphome/entity.py
@@ -28,6 +28,8 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from .const import DOMAIN
+
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
from .enum_mapper import EsphomeEnumMapper
@@ -167,7 +169,12 @@ def convert_api_error_ha_error[**_P, _R, _EntityT: EsphomeEntity[Any, Any]](
return await func(self, *args, **kwargs)
except APIConnectionError as error:
raise HomeAssistantError(
- f"Error communicating with device: {error}"
+ translation_domain=DOMAIN,
+ translation_key="error_communicating_with_device",
+ translation_placeholders={
+ "device_name": self._device_info.name,
+ "error": str(error),
+ },
) from error
return handler
@@ -191,9 +198,11 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
"""Define a base esphome entity."""
_attr_should_poll = False
+ _attr_has_entity_name = True
_static_info: _InfoT
_state: _StateT
_has_state: bool
+ device_entry: dr.DeviceEntry
def __init__(
self,
@@ -215,24 +224,6 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]):
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
)
- #
- # If `friendly_name` is set, we use the Friendly naming rules, if
- # `friendly_name` is not set we make an exception to the naming rules for
- # backwards compatibility and use the Legacy naming rules.
- #
- # Friendly naming
- # - Friendly name is prepended to entity names
- # - Device Name is prepended to entity ids
- # - Entity id is constructed from device name and object id
- #
- # Legacy naming
- # - Device name is not prepended to entity names
- # - Device name is not prepended to entity ids
- # - Entity id is constructed from entity name
- #
- if not device_info.friendly_name:
- return
- self._attr_has_entity_name = True
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py
index fc41ee99a00..023c6f70da4 100644
--- a/homeassistant/components/esphome/entry_data.py
+++ b/homeassistant/components/esphome/entry_data.py
@@ -282,15 +282,18 @@ class RuntimeEntryData:
) -> None:
"""Distribute an update of static infos to all platforms."""
# First, load all platforms
- needed_platforms = set()
- if async_get_dashboard(hass):
- needed_platforms.add(Platform.UPDATE)
+ needed_platforms: set[Platform] = set()
- if self.device_info and self.device_info.voice_assistant_feature_flags_compat(
- self.api_version
- ):
- needed_platforms.add(Platform.BINARY_SENSOR)
- needed_platforms.add(Platform.SELECT)
+ if self.device_info:
+ if async_get_dashboard(hass):
+ # Only load the update platform if the device_info is set
+ # When we restore the entry, the device_info may not be set yet
+ # and we don't want to load the update platform since it needs
+ # a complete device_info.
+ needed_platforms.add(Platform.UPDATE)
+ if self.device_info.voice_assistant_feature_flags_compat(self.api_version):
+ needed_platforms.add(Platform.BINARY_SENSOR)
+ needed_platforms.add(Platform.SELECT)
ent_reg = er.async_get(hass)
registry_get_entity = ent_reg.async_get_entity_id
@@ -312,18 +315,19 @@ class RuntimeEntryData:
# Make a dict of the EntityInfo by type and send
# them to the listeners for each specific EntityInfo type
- infos_by_type: dict[type[EntityInfo], list[EntityInfo]] = {}
+ infos_by_type: defaultdict[type[EntityInfo], list[EntityInfo]] = defaultdict(
+ list
+ )
for info in infos:
- info_type = type(info)
- if info_type not in infos_by_type:
- infos_by_type[info_type] = []
- infos_by_type[info_type].append(info)
+ infos_by_type[type(info)].append(info)
- callbacks_by_type = self.entity_info_callbacks
- for type_, entity_infos in infos_by_type.items():
- if callbacks_ := callbacks_by_type.get(type_):
- for callback_ in callbacks_:
- callback_(entity_infos)
+ for type_, callbacks in self.entity_info_callbacks.items():
+ # If all entities for a type are removed, we
+ # still need to call the callbacks with an empty list
+ # to make sure the entities are removed.
+ entity_infos = infos_by_type.get(type_, [])
+ for callback_ in callbacks:
+ callback_(entity_infos)
# Finally update static info subscriptions
for callback_ in self.static_info_update_subscriptions:
diff --git a/homeassistant/components/esphome/event.py b/homeassistant/components/esphome/event.py
index 11a5d0cfb33..4437292c5b4 100644
--- a/homeassistant/components/esphome/event.py
+++ b/homeassistant/components/esphome/event.py
@@ -12,6 +12,8 @@ from homeassistant.util.enum import try_parse_enum
from .entity import EsphomeEntity, platform_async_setup_entry
+PARALLEL_UPDATES = 0
+
class EsphomeEvent(EsphomeEntity[EventInfo, Event], EventEntity):
"""An event implementation for ESPHome."""
@@ -33,6 +35,16 @@ class EsphomeEvent(EsphomeEntity[EventInfo, Event], EventEntity):
self._trigger_event(self._state.event_type)
self.async_write_ha_state()
+ @callback
+ def _on_device_update(self) -> None:
+ """Call when device updates or entry data changes."""
+ super()._on_device_update()
+ if self._entry_data.available:
+ # Event entities should go available directly
+ # when the device comes online and not wait
+ # for the next data push.
+ self.async_write_ha_state()
+
async_setup_entry = partial(
platform_async_setup_entry,
diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py
index c09145c17b5..7e5922745cc 100644
--- a/homeassistant/components/esphome/fan.py
+++ b/homeassistant/components/esphome/fan.py
@@ -30,6 +30,8 @@ from .entity import (
)
from .enum_mapper import EsphomeEnumMapper
+PARALLEL_UPDATES = 0
+
ORDERED_NAMED_FAN_SPEEDS = [FanSpeed.LOW, FanSpeed.MEDIUM, FanSpeed.HIGH]
diff --git a/homeassistant/components/esphome/ffmpeg_proxy.py b/homeassistant/components/esphome/ffmpeg_proxy.py
index 9484d1e7593..b57a6762148 100644
--- a/homeassistant/components/esphome/ffmpeg_proxy.py
+++ b/homeassistant/components/esphome/ffmpeg_proxy.py
@@ -11,17 +11,20 @@ from typing import Final
from aiohttp import web
from aiohttp.abc import AbstractStreamWriter, BaseRequest
+from homeassistant.components import ffmpeg
from homeassistant.components.ffmpeg import FFmpegManager
from homeassistant.components.http import HomeAssistantView
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.util.hass_dict import HassKey
-from .const import DATA_FFMPEG_PROXY
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_MAX_CONVERSIONS_PER_DEVICE: Final[int] = 2
+@callback
def async_create_proxy_url(
hass: HomeAssistant,
device_id: str,
@@ -32,7 +35,7 @@ def async_create_proxy_url(
width: int | None = None,
) -> str:
"""Create a use proxy URL that automatically converts the media."""
- data: FFmpegProxyData = hass.data[DATA_FFMPEG_PROXY]
+ data = hass.data[DATA_FFMPEG_PROXY]
return data.async_create_proxy_url(
device_id, media_url, media_format, rate, channels, width
)
@@ -313,3 +316,16 @@ class FFmpegProxyView(HomeAssistantView):
assert writer is not None
await resp.transcode(request, writer)
return resp
+
+
+DATA_FFMPEG_PROXY: HassKey[FFmpegProxyData] = HassKey(f"{DOMAIN}.ffmpeg_proxy")
+
+
+@callback
+def async_setup(hass: HomeAssistant) -> None:
+ """Set up the ffmpeg proxy."""
+ proxy_data = FFmpegProxyData()
+ hass.data[DATA_FFMPEG_PROXY] = proxy_data
+ hass.http.register_view(
+ FFmpegProxyView(ffmpeg.get_ffmpeg_manager(hass), proxy_data)
+ )
diff --git a/homeassistant/components/esphome/icons.json b/homeassistant/components/esphome/icons.json
new file mode 100644
index 00000000000..fc0595b028e
--- /dev/null
+++ b/homeassistant/components/esphome/icons.json
@@ -0,0 +1,20 @@
+{
+ "entity": {
+ "binary_sensor": {
+ "assist_in_progress": {
+ "default": "mdi:timer-sand"
+ }
+ },
+ "select": {
+ "pipeline": {
+ "default": "mdi:filter-outline"
+ },
+ "vad_sensitivity": {
+ "default": "mdi:volume-high"
+ },
+ "wake_word": {
+ "default": "mdi:microphone"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py
index 8fecf34862b..2593f348680 100644
--- a/homeassistant/components/esphome/light.py
+++ b/homeassistant/components/esphome/light.py
@@ -38,6 +38,8 @@ from .entity import (
platform_async_setup_entry,
)
+PARALLEL_UPDATES = 0
+
FLASH_LENGTHS = {FLASH_SHORT: 2, FLASH_LONG: 10}
diff --git a/homeassistant/components/esphome/lock.py b/homeassistant/components/esphome/lock.py
index 502cd361277..21a76c71b3a 100644
--- a/homeassistant/components/esphome/lock.py
+++ b/homeassistant/components/esphome/lock.py
@@ -18,6 +18,8 @@ from .entity import (
platform_async_setup_entry,
)
+PARALLEL_UPDATES = 0
+
class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity):
"""A lock implementation for ESPHome."""
diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py
index 5f5ee1241f7..c173a3ada63 100644
--- a/homeassistant/components/esphome/manager.py
+++ b/homeassistant/components/esphome/manager.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
from functools import partial
import logging
+import re
from typing import TYPE_CHECKING, Any, NamedTuple
from aioesphomeapi import (
@@ -12,10 +13,12 @@ from aioesphomeapi import (
APIConnectionError,
APIVersion,
DeviceInfo as EsphomeDeviceInfo,
+ EncryptionPlaintextAPIError,
EntityInfo,
HomeassistantServiceCall,
InvalidAuthAPIError,
InvalidEncryptionKeyAPIError,
+ LogLevel,
ReconnectLogic,
RequiresEncryptionAPIError,
UserService,
@@ -33,6 +36,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import (
+ CALLBACK_TYPE,
Event,
EventStateChangedData,
HomeAssistant,
@@ -40,10 +44,11 @@ from homeassistant.core import (
State,
callback,
)
-from homeassistant.exceptions import TemplateError
+from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
+ entity_registry as er,
template,
)
from homeassistant.helpers.device_registry import format_mac
@@ -60,7 +65,9 @@ from homeassistant.util.async_ import create_eager_task
from .bluetooth import async_connect_scanner
from .const import (
CONF_ALLOW_SERVICE_CALLS,
+ CONF_BLUETOOTH_MAC_ADDRESS,
CONF_DEVICE_NAME,
+ CONF_SUBSCRIBE_LOGS,
DEFAULT_ALLOW_SERVICE_CALLS,
DEFAULT_URL,
DOMAIN,
@@ -74,8 +81,40 @@ from .domain_data import DomainData
# Import config flow so that it's added to the registry
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
+DEVICE_CONFLICT_ISSUE_FORMAT = "device_conflict-{}"
+
+if TYPE_CHECKING:
+ from aioesphomeapi.api_pb2 import ( # type: ignore[attr-defined]
+ SubscribeLogsResponse,
+ )
+
+
_LOGGER = logging.getLogger(__name__)
+LOG_LEVEL_TO_LOGGER = {
+ LogLevel.LOG_LEVEL_NONE: logging.DEBUG,
+ LogLevel.LOG_LEVEL_ERROR: logging.ERROR,
+ LogLevel.LOG_LEVEL_WARN: logging.WARNING,
+ LogLevel.LOG_LEVEL_INFO: logging.INFO,
+ LogLevel.LOG_LEVEL_CONFIG: logging.INFO,
+ LogLevel.LOG_LEVEL_DEBUG: logging.DEBUG,
+ LogLevel.LOG_LEVEL_VERBOSE: logging.DEBUG,
+ LogLevel.LOG_LEVEL_VERY_VERBOSE: logging.DEBUG,
+}
+LOGGER_TO_LOG_LEVEL = {
+ logging.NOTSET: LogLevel.LOG_LEVEL_VERY_VERBOSE,
+ logging.DEBUG: LogLevel.LOG_LEVEL_VERY_VERBOSE,
+ logging.INFO: LogLevel.LOG_LEVEL_CONFIG,
+ logging.WARNING: LogLevel.LOG_LEVEL_WARN,
+ logging.ERROR: LogLevel.LOG_LEVEL_ERROR,
+ logging.CRITICAL: LogLevel.LOG_LEVEL_ERROR,
+}
+# 7-bit and 8-bit C1 ANSI sequences
+# https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python
+ANSI_ESCAPE_78BIT = re.compile(
+ rb"(?:\x1B[@-Z\\-_]|[\x80-\x9A\x9C-\x9F]|(?:\x1B\[|\x9B)[0-?]*[ -/]*[@-~])"
+)
+
@callback
def _async_check_firmware_version(
@@ -136,6 +175,8 @@ class ESPHomeManager:
"""Class to manage an ESPHome connection."""
__slots__ = (
+ "_cancel_subscribe_logs",
+ "_log_level",
"cli",
"device_id",
"domain_data",
@@ -169,6 +210,8 @@ class ESPHomeManager:
self.reconnect_logic: ReconnectLogic | None = None
self.zeroconf_instance = zeroconf_instance
self.entry_data = entry.runtime_data
+ self._cancel_subscribe_logs: CALLBACK_TYPE | None = None
+ self._log_level = LogLevel.LOG_LEVEL_NONE
async def on_stop(self, event: Event) -> None:
"""Cleanup the socket client on HA close."""
@@ -341,6 +384,34 @@ class ESPHomeManager:
# Re-connection logic will trigger after this
await self.cli.disconnect()
+ def _async_on_log(self, msg: SubscribeLogsResponse) -> None:
+ """Handle a log message from the API."""
+ log: bytes = msg.message
+ _LOGGER.log(
+ LOG_LEVEL_TO_LOGGER.get(msg.level, logging.DEBUG),
+ "%s: %s",
+ self.entry.title,
+ ANSI_ESCAPE_78BIT.sub(b"", log).decode("utf-8", "backslashreplace"),
+ )
+
+ @callback
+ def _async_get_equivalent_log_level(self) -> LogLevel:
+ """Get the equivalent ESPHome log level for the current logger."""
+ return LOGGER_TO_LOG_LEVEL.get(
+ _LOGGER.getEffectiveLevel(), LogLevel.LOG_LEVEL_VERY_VERBOSE
+ )
+
+ @callback
+ def _async_subscribe_logs(self, log_level: LogLevel) -> None:
+ """Subscribe to logs."""
+ if self._cancel_subscribe_logs is not None:
+ self._cancel_subscribe_logs()
+ self._cancel_subscribe_logs = None
+ self._log_level = log_level
+ self._cancel_subscribe_logs = self.cli.subscribe_logs(
+ self._async_on_log, self._log_level
+ )
+
async def _on_connnect(self) -> None:
"""Subscribe to states and list entities on successful API login."""
entry = self.entry
@@ -350,8 +421,10 @@ class ESPHomeManager:
assert reconnect_logic is not None, "Reconnect logic must be set"
hass = self.hass
cli = self.cli
- stored_device_name = entry.data.get(CONF_DEVICE_NAME)
+ stored_device_name: str | None = entry.data.get(CONF_DEVICE_NAME)
unique_id_is_mac_address = unique_id and ":" in unique_id
+ if entry.options.get(CONF_SUBSCRIBE_LOGS):
+ self._async_subscribe_logs(self._async_get_equivalent_log_level())
results = await asyncio.gather(
create_eager_task(cli.device_info()),
create_eager_task(cli.list_entities_services()),
@@ -363,6 +436,13 @@ class ESPHomeManager:
device_mac = format_mac(device_info.mac_address)
mac_address_matches = unique_id == device_mac
+ if (
+ bluetooth_mac_address := device_info.bluetooth_mac_address
+ ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address:
+ hass.config_entries.async_update_entry(
+ entry,
+ data={**entry.data, CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address},
+ )
#
# Migrate config entry to new unique ID if the current
# unique id is not a mac address.
@@ -371,12 +451,36 @@ class ESPHomeManager:
if not mac_address_matches and not unique_id_is_mac_address:
hass.config_entries.async_update_entry(entry, unique_id=device_mac)
+ issue = DEVICE_CONFLICT_ISSUE_FORMAT.format(entry.entry_id)
if not mac_address_matches and unique_id_is_mac_address:
# If the unique id is a mac address
# and does not match we have the wrong device and we need
# to abort the connection. This can happen if the DHCP
# server changes the IP address of the device and we end up
# connecting to the wrong device.
+ if stored_device_name == device_info.name:
+ # If the device name matches it might be a device replacement
+ # or they made a mistake and flashed the same firmware on
+ # multiple devices. In this case we start a repair flow
+ # to ask them if its a mistake, or if they want to migrate
+ # the config entry to the replacement hardware.
+ shared_data = {
+ "name": device_info.name,
+ "mac": format_mac(device_mac),
+ "stored_mac": format_mac(unique_id),
+ "model": device_info.model,
+ "ip": self.host,
+ }
+ async_create_issue(
+ hass,
+ DOMAIN,
+ issue,
+ is_fixable=True,
+ severity=IssueSeverity.ERROR,
+ translation_key="device_conflict",
+ translation_placeholders=shared_data,
+ data={**shared_data, "entry_id": entry.entry_id},
+ )
_LOGGER.error(
"Unexpected device found at %s; "
"expected `%s` with mac address `%s`, "
@@ -398,6 +502,7 @@ class ESPHomeManager:
# flow.
return
+ async_delete_issue(hass, DOMAIN, issue)
# Make sure we have the correct device name stored
# so we can map the device to ESPHome Dashboard config
# If we got here, we know the mac address matches or we
@@ -415,6 +520,15 @@ class ESPHomeManager:
if device_info.name:
reconnect_logic.name = device_info.name
+ if not device_info.friendly_name:
+ _LOGGER.info(
+ "No `friendly_name` set in the `esphome:` section of the "
+ "YAML config for device '%s' (MAC: %s); It's recommended "
+ "to add one for easier identification and better alignment "
+ "with Home Assistant naming conventions",
+ device_info.name,
+ device_mac,
+ )
self.device_id = _async_setup_device_registry(hass, entry, entry_data)
entry_data.async_update_device_state()
@@ -430,7 +544,9 @@ class ESPHomeManager:
)
)
else:
- bluetooth.async_remove_scanner(hass, device_info.mac_address)
+ bluetooth.async_remove_scanner(
+ hass, device_info.bluetooth_mac_address or device_info.mac_address
+ )
if device_info.voice_assistant_feature_flags_compat(api_version) and (
Platform.ASSIST_SATELLITE not in entry_data.loaded_platforms
@@ -489,20 +605,54 @@ class ESPHomeManager:
async def on_connect_error(self, err: Exception) -> None:
"""Start reauth flow if appropriate connect error type."""
- if isinstance(
+ if not isinstance(
err,
(
+ EncryptionPlaintextAPIError,
RequiresEncryptionAPIError,
InvalidEncryptionKeyAPIError,
InvalidAuthAPIError,
),
):
- self.entry.async_start_reauth(self.hass)
+ return
+ if isinstance(err, InvalidEncryptionKeyAPIError):
+ if (
+ (received_name := err.received_name)
+ and (received_mac := err.received_mac)
+ and (unique_id := self.entry.unique_id)
+ and ":" in unique_id
+ ):
+ formatted_received_mac = format_mac(received_mac)
+ formatted_expected_mac = format_mac(unique_id)
+ if formatted_received_mac != formatted_expected_mac:
+ _LOGGER.error(
+ "Unexpected device found at %s; "
+ "expected `%s` with mac address `%s`, "
+ "found `%s` with mac address `%s`",
+ self.host,
+ self.entry.data.get(CONF_DEVICE_NAME),
+ formatted_expected_mac,
+ received_name,
+ formatted_received_mac,
+ )
+ # If the device comes back online, discovery
+ # will update the config entry with the new IP address
+ # and reload which will try again to connect to the device.
+ # In the mean time we stop the reconnect logic
+ # so we don't keep trying to connect to the wrong device.
+ if self.reconnect_logic:
+ await self.reconnect_logic.stop()
+ return
+ self.entry.async_start_reauth(self.hass)
@callback
def _async_handle_logging_changed(self, _event: Event) -> None:
"""Handle when the logging level changes."""
self.cli.set_debug(_LOGGER.isEnabledFor(logging.DEBUG))
+ if self.entry.options.get(CONF_SUBSCRIBE_LOGS) and self._log_level != (
+ new_log_level := self._async_get_equivalent_log_level()
+ ):
+ self._async_subscribe_logs(new_log_level)
async def async_start(self) -> None:
"""Start the esphome connection manager."""
@@ -545,11 +695,22 @@ class ESPHomeManager:
)
_setup_services(hass, entry_data, services)
- if entry_data.device_info is not None and entry_data.device_info.name:
- reconnect_logic.name = entry_data.device_info.name
+ if (device_info := entry_data.device_info) is not None:
+ if device_info.name:
+ reconnect_logic.name = device_info.name
+ if (
+ bluetooth_mac_address := device_info.bluetooth_mac_address
+ ) and entry.data.get(CONF_BLUETOOTH_MAC_ADDRESS) != bluetooth_mac_address:
+ hass.config_entries.async_update_entry(
+ entry,
+ data={
+ **entry.data,
+ CONF_BLUETOOTH_MAC_ADDRESS: bluetooth_mac_address,
+ },
+ )
if entry.unique_id is None:
hass.config_entries.async_update_entry(
- entry, unique_id=format_mac(entry_data.device_info.mac_address)
+ entry, unique_id=format_mac(device_info.mac_address)
)
await reconnect_logic.start()
@@ -604,7 +765,7 @@ def _async_setup_device_registry(
config_entry_id=entry.entry_id,
configuration_url=configuration_url,
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)},
- name=entry_data.friendly_name,
+ name=entry_data.friendly_name or entry_data.name,
manufacturer=manufacturer,
model=model,
sw_version=sw_version,
@@ -675,7 +836,18 @@ def execute_service(
entry_data: RuntimeEntryData, service: UserService, call: ServiceCall
) -> None:
"""Execute a service on a node."""
- entry_data.client.execute_service(service, call.data)
+ try:
+ entry_data.client.execute_service(service, call.data)
+ except APIConnectionError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="action_call_failed",
+ translation_placeholders={
+ "call_name": service.name,
+ "device_name": entry_data.name,
+ "error": str(err),
+ },
+ ) from err
def build_service_name(device_info: EsphomeDeviceInfo, service: UserService) -> str:
@@ -778,3 +950,40 @@ async def cleanup_instance(
await data.async_cleanup()
await data.client.disconnect()
return data
+
+
+async def async_replace_device(
+ hass: HomeAssistant,
+ entry_id: str,
+ old_mac: str, # will be lower case (format_mac)
+ new_mac: str, # will be lower case (format_mac)
+) -> None:
+ """Migrate an ESPHome entry to replace an existing device."""
+ entry = hass.config_entries.async_get_entry(entry_id)
+ assert entry is not None
+ hass.config_entries.async_update_entry(entry, unique_id=new_mac)
+
+ dev_reg = dr.async_get(hass)
+ for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id):
+ dev_reg.async_update_device(
+ device.id,
+ new_connections={(dr.CONNECTION_NETWORK_MAC, new_mac)},
+ )
+
+ ent_reg = er.async_get(hass)
+ upper_mac = new_mac.upper()
+ old_upper_mac = old_mac.upper()
+ for entity in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
+ # --
+ old_unique_id = entity.unique_id.split("-")
+ new_unique_id = "-".join([upper_mac, *old_unique_id[1:]])
+ if entity.unique_id != new_unique_id and entity.unique_id.startswith(
+ old_upper_mac
+ ):
+ ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
+
+ domain_data = DomainData.get(hass)
+ store = domain_data.get_or_create_store(hass, entry)
+ if data := await store.async_load():
+ data["device_info"]["mac_address"] = upper_mac
+ await store.async_save(data)
diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json
index 185f9ea5cf0..5433056c2bb 100644
--- a/homeassistant/components/esphome/manifest.json
+++ b/homeassistant/components/esphome/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "esphome",
"name": "ESPHome",
- "after_dependencies": ["zeroconf", "tag"],
+ "after_dependencies": ["hassio", "zeroconf", "tag"],
"codeowners": ["@OttoWinter", "@jesserockz", "@kbx81", "@bdraco"],
"config_flow": true,
"dependencies": ["assist_pipeline", "bluetooth", "intent", "ffmpeg", "http"],
@@ -16,9 +16,9 @@
"loggers": ["aioesphomeapi", "noiseprotocol", "bleak_esphome"],
"mqtt": ["esphome/discover/#"],
"requirements": [
- "aioesphomeapi==29.0.0",
- "esphome-dashboard-api==1.2.3",
- "bleak-esphome==2.7.1"
+ "aioesphomeapi==30.0.1",
+ "esphome-dashboard-api==1.3.0",
+ "bleak-esphome==2.13.1"
],
"zeroconf": ["_esphomelib._tcp.local."]
}
diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py
index 8a30814aa2c..b05a453aca2 100644
--- a/homeassistant/components/esphome/media_player.py
+++ b/homeassistant/components/esphome/media_player.py
@@ -41,6 +41,8 @@ from .entity import (
from .enum_mapper import EsphomeEnumMapper
from .ffmpeg_proxy import async_create_proxy_url
+PARALLEL_UPDATES = 0
+
_LOGGER = logging.getLogger(__name__)
_STATES: EsphomeEnumMapper[EspMediaPlayerState, MediaPlayerState] = EsphomeEnumMapper(
@@ -146,10 +148,6 @@ class EsphomeMediaPlayer(
announcement: bool,
) -> str | None:
"""Get URL for ffmpeg proxy."""
- if self.device_entry is None:
- # Device id is required
- return None
-
# Choose the first default or announcement supported format
format_to_use: MediaPlayerSupportedFormat | None = None
for supported_format in supported_formats:
diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py
index 2d74dad1bcf..4a6800e1041 100644
--- a/homeassistant/components/esphome/number.py
+++ b/homeassistant/components/esphome/number.py
@@ -23,6 +23,8 @@ from .entity import (
)
from .enum_mapper import EsphomeEnumMapper
+PARALLEL_UPDATES = 0
+
NUMBER_MODES: EsphomeEnumMapper[EsphomeNumberMode, NumberMode] = EsphomeEnumMapper(
{
EsphomeNumberMode.AUTO: NumberMode.AUTO,
diff --git a/homeassistant/components/esphome/repairs.py b/homeassistant/components/esphome/repairs.py
index 31e4b88c689..42396fb8670 100644
--- a/homeassistant/components/esphome/repairs.py
+++ b/homeassistant/components/esphome/repairs.py
@@ -2,11 +2,95 @@
from __future__ import annotations
+from typing import cast
+
+import voluptuous as vol
+
+from homeassistant import data_entry_flow
from homeassistant.components.assist_pipeline.repair_flows import (
AssistInProgressDeprecatedRepairFlow,
)
from homeassistant.components.repairs import RepairsFlow
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import issue_registry as ir
+
+from .manager import async_replace_device
+
+
+class ESPHomeRepair(RepairsFlow):
+ """Handler for an issue fixing flow."""
+
+ def __init__(self, data: dict[str, str | int | float | None] | None) -> None:
+ """Initialize."""
+ self._data = data
+ super().__init__()
+
+ @callback
+ def _async_get_placeholders(self) -> dict[str, str]:
+ issue_registry = ir.async_get(self.hass)
+ issue = issue_registry.async_get_issue(self.handler, self.issue_id)
+ assert issue is not None
+ return issue.translation_placeholders or {}
+
+
+class DeviceConflictRepair(ESPHomeRepair):
+ """Handler for an issue fixing device conflict."""
+
+ @property
+ def entry_id(self) -> str:
+ """Return the config entry id."""
+ assert isinstance(self._data, dict)
+ return cast(str, self._data["entry_id"])
+
+ @property
+ def mac(self) -> str:
+ """Return the MAC address of the new device."""
+ assert isinstance(self._data, dict)
+ return cast(str, self._data["mac"])
+
+ @property
+ def stored_mac(self) -> str:
+ """Return the MAC address of the stored device."""
+ assert isinstance(self._data, dict)
+ return cast(str, self._data["stored_mac"])
+
+ async def async_step_init(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the first step of a fix flow."""
+ return self.async_show_menu(
+ step_id="init",
+ menu_options=["migrate", "manual"],
+ description_placeholders=self._async_get_placeholders(),
+ )
+
+ async def async_step_migrate(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the migrate step of a fix flow."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="migrate",
+ data_schema=vol.Schema({}),
+ description_placeholders=self._async_get_placeholders(),
+ )
+ entry_id = self.entry_id
+ await async_replace_device(self.hass, entry_id, self.stored_mac, self.mac)
+ self.hass.config_entries.async_schedule_reload(entry_id)
+ return self.async_create_entry(data={})
+
+ async def async_step_manual(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the manual step of a fix flow."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="manual",
+ data_schema=vol.Schema({}),
+ description_placeholders=self._async_get_placeholders(),
+ )
+ self.hass.config_entries.async_schedule_reload(self.entry_id)
+ return self.async_create_entry(data={})
async def async_create_fix_flow(
@@ -17,6 +101,8 @@ async def async_create_fix_flow(
"""Create flow."""
if issue_id.startswith("assist_in_progress_deprecated"):
return AssistInProgressDeprecatedRepairFlow(data)
+ if issue_id.startswith("device_conflict"):
+ return DeviceConflictRepair(data)
# If ESPHome adds confirm-only repairs in the future, this should be changed
# to return a ConfirmRepairFlow instead of raising a ValueError
raise ValueError(f"unknown repair {issue_id}")
diff --git a/homeassistant/components/esphome/select.py b/homeassistant/components/esphome/select.py
index 71a21186d3d..f37f774fb1f 100644
--- a/homeassistant/components/esphome/select.py
+++ b/homeassistant/components/esphome/select.py
@@ -13,7 +13,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import restore_state
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import (
@@ -25,11 +25,13 @@ from .entity import (
)
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
entry: ESPHomeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up esphome selects based on a config entry."""
await platform_async_setup_entry(
diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py
index 670c92d291e..611d7056ff7 100644
--- a/homeassistant/components/esphome/sensor.py
+++ b/homeassistant/components/esphome/sensor.py
@@ -20,18 +20,22 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
from .entity import EsphomeEntity, platform_async_setup_entry
+from .entry_data import ESPHomeConfigEntry
from .enum_mapper import EsphomeEnumMapper
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ESPHomeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up esphome sensors based on a config entry."""
await platform_async_setup_entry(
diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json
index 81b58de8df2..6c10a2e5fe8 100644
--- a/homeassistant/components/esphome/strings.json
+++ b/homeassistant/components/esphome/strings.json
@@ -4,12 +4,17 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
- "mdns_missing_mac": "Missing MAC address in MDNS properties.",
+ "mdns_missing_mac": "Missing MAC address in mDNS properties.",
"service_received": "Action received",
"mqtt_missing_mac": "Missing MAC address in MQTT properties.",
"mqtt_missing_api": "Missing API port in MQTT properties.",
"mqtt_missing_ip": "Missing IP address in MQTT properties.",
- "mqtt_missing_payload": "Missing MQTT Payload."
+ "mqtt_missing_payload": "Missing MQTT Payload.",
+ "name_conflict_migrated": "The configuration for `{name}` has been migrated to a new device with MAC address `{mac}` from `{existing_mac}`.",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "reauth_unique_id_changed": "**Re-authentication of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`).",
+ "reconfigure_name_conflict": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a device named `{name}` (MAC: `{expected_mac}`), which is already in use by another configuration entry: `{existing_title}`.",
+ "reconfigure_unique_id_changed": "**Reconfiguration of `{name}` was aborted** because the address `{host}` points to a different device: `{unexpected_device_name}` (MAC: `{unexpected_mac}`) instead of the expected one (MAC: `{expected_mac}`)."
},
"error": {
"resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address",
@@ -23,29 +28,53 @@
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
- "description": "Please enter connection settings of your [ESPHome]({esphome_url}) node."
+ "data_description": {
+ "host": "IP address or hostname of the ESPHome device",
+ "port": "Port that the native API is running on"
+ },
+ "description": "Please enter connection settings of your ESPHome device."
},
"authenticate": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
- "description": "Please enter the password you set in your configuration for {name}."
+ "data_description": {
+ "password": "Passwords are deprecated and will be removed in a future version. Please update your ESPHome device YAML configuration to use an encryption key instead."
+ },
+ "description": "Please enter the password you set in your ESPHome device YAML configuration for {name}."
},
"encryption_key": {
"data": {
"noise_psk": "Encryption key"
},
- "description": "Please enter the encryption key for {name}. You can find it in the ESPHome Dashboard or in your device configuration."
+ "data_description": {
+ "noise_psk": "The encryption key is used to encrypt the connection between Home Assistant and the ESPHome device. You can find this in the api: section of your ESPHome device YAML configuration."
+ },
+ "description": "Please enter the encryption key for {name}. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
},
"reauth_confirm": {
"data": {
"noise_psk": "[%key:component::esphome::config::step::encryption_key::data::noise_psk%]"
},
- "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your device configuration."
+ "data_description": {
+ "noise_psk": "[%key:component::esphome::config::step::encryption_key::data_description::noise_psk%]"
+ },
+ "description": "The ESPHome device {name} enabled transport encryption or changed the encryption key. Please enter the updated key. You can find it in the ESPHome Dashboard or in your ESPHome device YAML configuration."
+ },
+ "reauth_encryption_removed_confirm": {
+ "description": "The ESPHome device {name} disabled transport encryption. Please confirm that you want to remove the encryption key and allow unencrypted connections."
},
"discovery_confirm": {
- "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?",
- "title": "Discovered ESPHome node"
+ "description": "Do you want to add the device `{name}` to Home Assistant?",
+ "title": "Discovered ESPHome device"
+ },
+ "name_conflict": {
+ "title": "Name conflict",
+ "description": "**The name `{name}` is already being used by another device: {existing_title} (MAC address: `{existing_mac}`)**\n\nTo continue, please choose one of the following options:\n\n**Migrate configuration to new device:** If this is a replacement, migrate the existing settings to the new device (`{mac}`).\n**Overwrite the existing configuration:** If this is not a replacement, delete the old configuration for `{existing_mac}` and use the new device instead.",
+ "menu_options": {
+ "name_conflict_migrate": "Migrate configuration to new device",
+ "name_conflict_overwrite": "Overwrite the existing configuration"
+ }
}
},
"flow_title": "{name}"
@@ -54,7 +83,12 @@
"step": {
"init": {
"data": {
- "allow_service_calls": "Allow the device to perform Home Assistant actions."
+ "allow_service_calls": "Allow the device to perform Home Assistant actions.",
+ "subscribe_logs": "Subscribe to logs from the device."
+ },
+ "data_description": {
+ "allow_service_calls": "When enabled, ESPHome devices can perform Home Assistant actions, such as calling services or sending events. Only enable this if you trust the device.",
+ "subscribe_logs": "When enabled, the device will send logs to Home Assistant and you can view them in the logs panel."
}
}
}
@@ -126,6 +160,43 @@
"service_calls_not_allowed": {
"title": "{name} is not permitted to perform Home Assistant actions",
"description": "The ESPHome device attempted to perform a Home Assistant action, but this functionality is not enabled.\n\nIf you trust this device and want to allow it to perform Home Assistant action, you can enable this functionality in the options flow."
+ },
+ "device_conflict": {
+ "title": "Device conflict for {name}",
+ "fix_flow": {
+ "step": {
+ "init": {
+ "title": "Device conflict for {name}",
+ "description": "**The device `{name}` (`{model}`) at `{ip}` has reported a MAC address change from `{stored_mac}` to `{mac}`.**\n\nIf you have multiple devices with the same name, please rename or remove the one with MAC address `{mac}` to avoid conflicts.\n\nIf this is a hardware replacement, please confirm that you would like to migrate the Home Assistant configuration to the new device with MAC address `{mac}`.",
+ "menu_options": {
+ "migrate": "Migrate configuration to new device",
+ "manual": "Remove or rename device"
+ }
+ },
+ "migrate": {
+ "title": "Confirm device replacement for {name}",
+ "description": "Are you sure you want to migrate the Home Assistant configuration for `{name}` (`{model}`) at `{ip}` from `{stored_mac}` to `{mac}`?"
+ },
+ "manual": {
+ "title": "Remove or rename device {name}",
+ "description": "To resolve the conflict, either remove the device with MAC address `{mac}` from the network and restart the one with MAC address `{stored_mac}`, or re-flash the device with MAC address `{mac}` using a different name than `{name}`. Submit again once done."
+ }
+ }
+ }
+ }
+ },
+ "exceptions": {
+ "action_call_failed": {
+ "message": "Failed to execute the action call {call_name} on {device_name}: {error}"
+ },
+ "error_communicating_with_device": {
+ "message": "Error communicating with the device {device_name}: {error}"
+ },
+ "error_compiling": {
+ "message": "Error compiling {configuration}; Try again in ESPHome dashboard for more information."
+ },
+ "error_uploading": {
+ "message": "Error during OTA of {configuration}; Try again in ESPHome dashboard for more information."
}
}
}
diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py
index c210ae1440b..96b2a426869 100644
--- a/homeassistant/components/esphome/switch.py
+++ b/homeassistant/components/esphome/switch.py
@@ -18,6 +18,8 @@ from .entity import (
platform_async_setup_entry,
)
+PARALLEL_UPDATES = 0
+
class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity):
"""A switch implementation for ESPHome."""
diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py
index 36d77aac4a0..c36621b8f4e 100644
--- a/homeassistant/components/esphome/text.py
+++ b/homeassistant/components/esphome/text.py
@@ -17,6 +17,8 @@ from .entity import (
)
from .enum_mapper import EsphomeEnumMapper
+PARALLEL_UPDATES = 0
+
TEXT_MODES: EsphomeEnumMapper[EsphomeTextMode, TextMode] = EsphomeEnumMapper(
{
EsphomeTextMode.TEXT: TextMode.TEXT,
diff --git a/homeassistant/components/esphome/time.py b/homeassistant/components/esphome/time.py
index 477c47cf636..b0e586e1792 100644
--- a/homeassistant/components/esphome/time.py
+++ b/homeassistant/components/esphome/time.py
@@ -11,6 +11,8 @@ from homeassistant.components.time import TimeEntity
from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry
+PARALLEL_UPDATES = 0
+
class EsphomeTime(EsphomeEntity[TimeInfo, TimeState], TimeEntity):
"""A time implementation for esphome."""
diff --git a/homeassistant/components/esphome/update.py b/homeassistant/components/esphome/update.py
index 2b593051742..9125e92a552 100644
--- a/homeassistant/components/esphome/update.py
+++ b/homeassistant/components/esphome/update.py
@@ -18,15 +18,15 @@ from homeassistant.components.update import (
UpdateEntity,
UpdateEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.enum import try_parse_enum
+from .const import DOMAIN
from .coordinator import ESPHomeDashboardCoordinator
from .dashboard import async_get_dashboard
from .domain_data import DomainData
@@ -36,7 +36,9 @@ from .entity import (
esphome_state_property,
platform_async_setup_entry,
)
-from .entry_data import RuntimeEntryData
+from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
+
+PARALLEL_UPDATES = 0
KEY_UPDATE_LOCK = "esphome_update_lock"
@@ -45,8 +47,8 @@ NO_FEATURES = UpdateEntityFeature(0)
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: ESPHomeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ESPHome update based on a config entry."""
await platform_async_setup_entry(
@@ -200,16 +202,23 @@ class ESPHomeDashboardUpdateEntity(
api = coordinator.api
device = coordinator.data.get(self._device_info.name)
assert device is not None
+ configuration = device["configuration"]
try:
- if not await api.compile(device["configuration"]):
+ if not await api.compile(configuration):
raise HomeAssistantError(
- f"Error compiling {device['configuration']}; "
- "Try again in ESPHome dashboard for more information."
+ translation_domain=DOMAIN,
+ translation_key="error_compiling",
+ translation_placeholders={
+ "configuration": configuration,
+ },
)
- if not await api.upload(device["configuration"], "OTA"):
+ if not await api.upload(configuration, "OTA"):
raise HomeAssistantError(
- f"Error updating {device['configuration']} via OTA; "
- "Try again in ESPHome dashboard for more information."
+ translation_domain=DOMAIN,
+ translation_key="error_uploading",
+ translation_placeholders={
+ "configuration": configuration,
+ },
)
finally:
await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/esphome/valve.py b/homeassistant/components/esphome/valve.py
index d779a6abb9f..e366fc08d19 100644
--- a/homeassistant/components/esphome/valve.py
+++ b/homeassistant/components/esphome/valve.py
@@ -22,6 +22,8 @@ from .entity import (
platform_async_setup_entry,
)
+PARALLEL_UPDATES = 0
+
class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity):
"""A valve implementation for ESPHome."""
diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py
index d9cef45ce4d..0c65be89879 100644
--- a/homeassistant/components/eufylife_ble/sensor.py
+++ b/homeassistant/components/eufylife_ble/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, UnitOfMass
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from .models import EufyLifeConfigEntry, EufyLifeData
@@ -27,7 +27,7 @@ IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN}
async def async_setup_entry(
hass: HomeAssistant,
entry: EufyLifeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the EufyLife sensors."""
data = entry.runtime_data
diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py
index ae159d77240..c153f01e83c 100644
--- a/homeassistant/components/everlights/light.py
+++ b/homeassistant/components/everlights/light.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
-from typing import Any
+from typing import Any, cast
import pyeverlights
import voluptuous as vol
@@ -84,7 +84,7 @@ class EverLightsLight(LightEntity):
api: pyeverlights.EverLights,
channel: int,
status: dict[str, Any],
- effects,
+ effects: list[str],
) -> None:
"""Initialize the light."""
self._api = api
@@ -106,8 +106,10 @@ class EverLightsLight(LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
- hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color)
- brightness = kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness)
+ hs_color = cast(
+ tuple[float, float], kwargs.get(ATTR_HS_COLOR, self._attr_hs_color)
+ )
+ brightness = cast(int, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness))
effect = kwargs.get(ATTR_EFFECT)
if effect is not None:
@@ -116,7 +118,7 @@ class EverLightsLight(LightEntity):
rgb = color_int_to_rgb(colors[0])
hsv = color_util.color_RGB_to_hsv(*rgb)
hs_color = hsv[:2]
- brightness = hsv[2] / 100 * 255
+ brightness = round(hsv[2] / 100 * 255)
else:
rgb = color_util.color_hsv_to_RGB(
diff --git a/homeassistant/components/evil_genius_labs/light.py b/homeassistant/components/evil_genius_labs/light.py
index a6d1d9531b5..3dd9b763ae1 100644
--- a/homeassistant/components/evil_genius_labs/light.py
+++ b/homeassistant/components/evil_genius_labs/light.py
@@ -8,7 +8,7 @@ from typing import Any, cast
from homeassistant.components import light
from homeassistant.components.light import ColorMode, LightEntity, LightEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EvilGeniusConfigEntry, EvilGeniusUpdateCoordinator
from .entity import EvilGeniusEntity
@@ -21,7 +21,7 @@ FIB_NO_EFFECT = "Solid Color"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: EvilGeniusConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Evil Genius light platform."""
async_add_entities([EvilGeniusLight(config_entry.runtime_data)])
diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py
index e322e266b8a..9dce352df30 100644
--- a/homeassistant/components/evohome/__init__.py
+++ b/homeassistant/components/evohome/__init__.py
@@ -25,6 +25,7 @@ import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
+ ATTR_MODE,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_USERNAME,
@@ -40,11 +41,10 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import (
- ATTR_DURATION_DAYS,
- ATTR_DURATION_HOURS,
+ ATTR_DURATION,
ATTR_DURATION_UNTIL,
- ATTR_SYSTEM_MODE,
- ATTR_ZONE_TEMP,
+ ATTR_PERIOD,
+ ATTR_SETPOINT,
CONF_LOCATION_IDX,
DOMAIN,
SCAN_INTERVAL_DEFAULT,
@@ -81,7 +81,7 @@ RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
- vol.Required(ATTR_ZONE_TEMP): vol.All(
+ vol.Required(ATTR_SETPOINT): vol.All(
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
),
vol.Optional(ATTR_DURATION_UNTIL): vol.All(
@@ -222,7 +222,7 @@ def setup_service_functions(
# Permanent-only modes will use this schema
perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]]
if perm_modes: # any of: "Auto", "HeatingOff": permanent only
- schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)})
+ schema = vol.Schema({vol.Required(ATTR_MODE): vol.In(perm_modes)})
system_mode_schemas.append(schema)
modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]]
@@ -232,8 +232,8 @@ def setup_service_functions(
if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours
schema = vol.Schema(
{
- vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes),
- vol.Optional(ATTR_DURATION_HOURS): vol.All(
+ vol.Required(ATTR_MODE): vol.In(temp_modes),
+ vol.Optional(ATTR_DURATION): vol.All(
cv.time_period,
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
),
@@ -246,8 +246,8 @@ def setup_service_functions(
if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days
schema = vol.Schema(
{
- vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes),
- vol.Optional(ATTR_DURATION_DAYS): vol.All(
+ vol.Required(ATTR_MODE): vol.In(temp_modes),
+ vol.Optional(ATTR_PERIOD): vol.All(
cv.time_period,
vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
),
diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py
index 8a455b300f8..40439c1eb02 100644
--- a/homeassistant/components/evohome/climate.py
+++ b/homeassistant/components/evohome/climate.py
@@ -29,7 +29,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
-from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature
+from homeassistant.const import ATTR_MODE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -38,11 +38,10 @@ from homeassistant.util import dt as dt_util
from . import EVOHOME_KEY
from .const import (
- ATTR_DURATION_DAYS,
- ATTR_DURATION_HOURS,
+ ATTR_DURATION,
ATTR_DURATION_UNTIL,
- ATTR_SYSTEM_MODE,
- ATTR_ZONE_TEMP,
+ ATTR_PERIOD,
+ ATTR_SETPOINT,
EvoService,
)
from .coordinator import EvoDataUpdateCoordinator
@@ -153,7 +152,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
super().__init__(coordinator, evo_device)
self._evo_id = evo_device.id
- if evo_device.model.startswith("VisionProWifi"):
+ if evo_device.id == evo_device.tcs.id:
# this system does not have a distinct ID for the zone
self._attr_unique_id = f"{evo_device.id}z"
else:
@@ -180,7 +179,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
return
# otherwise it is EvoService.SET_ZONE_OVERRIDE
- temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp)
+ temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp)
if ATTR_DURATION_UNTIL in data:
duration: timedelta = data[ATTR_DURATION_UNTIL]
@@ -349,16 +348,16 @@ class EvoController(EvoClimateEntity):
Data validation is not required, it will have been done upstream.
"""
if service == EvoService.SET_SYSTEM_MODE:
- mode = data[ATTR_SYSTEM_MODE]
+ mode = data[ATTR_MODE]
else: # otherwise it is EvoService.RESET_SYSTEM
mode = EvoSystemMode.AUTO_WITH_RESET
- if ATTR_DURATION_DAYS in data:
+ if ATTR_PERIOD in data:
until = dt_util.start_of_local_day()
- until += data[ATTR_DURATION_DAYS]
+ until += data[ATTR_PERIOD]
- elif ATTR_DURATION_HOURS in data:
- until = dt_util.now() + data[ATTR_DURATION_HOURS]
+ elif ATTR_DURATION in data:
+ until = dt_util.now() + data[ATTR_DURATION]
else:
until = None
diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py
index 12642addfa4..9da5969df1e 100644
--- a/homeassistant/components/evohome/const.py
+++ b/homeassistant/components/evohome/const.py
@@ -18,11 +18,10 @@ USER_DATA: Final = "user_data"
SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300)
SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60)
-ATTR_SYSTEM_MODE: Final = "mode"
-ATTR_DURATION_DAYS: Final = "period"
-ATTR_DURATION_HOURS: Final = "duration"
+ATTR_PERIOD: Final = "period" # number of days
+ATTR_DURATION: Final = "duration" # number of minutes, <24h
-ATTR_ZONE_TEMP: Final = "setpoint"
+ATTR_SETPOINT: Final = "setpoint"
ATTR_DURATION_UNTIL: Final = "duration"
diff --git a/homeassistant/components/evohome/coordinator.py b/homeassistant/components/evohome/coordinator.py
index 7b197f1b643..33af90089a4 100644
--- a/homeassistant/components/evohome/coordinator.py
+++ b/homeassistant/components/evohome/coordinator.py
@@ -11,6 +11,7 @@ from typing import Any
import evohomeasync as ec1
import evohomeasync2 as ec2
from evohomeasync2.const import (
+ SZ_DHW,
SZ_GATEWAY_ID,
SZ_GATEWAY_INFO,
SZ_GATEWAYS,
@@ -19,8 +20,9 @@ from evohomeasync2.const import (
SZ_TEMPERATURE_CONTROL_SYSTEMS,
SZ_TIME_ZONE,
SZ_USE_DAYLIGHT_SAVE_SWITCHING,
+ SZ_ZONES,
)
-from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT
+from evohomeasync2.schemas.typedefs import EvoLocStatusResponseT, EvoTcsConfigResponseT
from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
@@ -113,17 +115,19 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator):
SZ_USE_DAYLIGHT_SAVE_SWITCHING
],
}
+ tcs_info: EvoTcsConfigResponseT = self.tcs.config # type: ignore[assignment]
+ tcs_info[SZ_ZONES] = [zone.config for zone in self.tcs.zones]
+ if self.tcs.hotwater:
+ tcs_info[SZ_DHW] = self.tcs.hotwater.config
gwy_info = {
SZ_GATEWAY_ID: self.loc.gateways[0].id,
- SZ_TEMPERATURE_CONTROL_SYSTEMS: [
- self.loc.gateways[0].systems[0].config
- ],
+ SZ_TEMPERATURE_CONTROL_SYSTEMS: [tcs_info],
}
config = {
SZ_LOCATION_INFO: loc_info,
SZ_GATEWAYS: [{SZ_GATEWAY_INFO: gwy_info}],
}
- self.logger.debug("Config = %s", config)
+ self.logger.debug("Config = %s", [config])
async def call_client_api(
self,
@@ -203,10 +207,18 @@ class EvoDataUpdateCoordinator(DataUpdateCoordinator):
async def _update_v2_schedules(self) -> None:
for zone in self.tcs.zones:
- await zone.get_schedule()
+ try:
+ await zone.get_schedule()
+ except ec2.InvalidScheduleError as err:
+ self.logger.warning(
+ "Zone '%s' has an invalid/missing schedule: %r", zone.name, err
+ )
if dhw := self.tcs.hotwater:
- await dhw.get_schedule()
+ try:
+ await dhw.get_schedule()
+ except ec2.InvalidScheduleError as err:
+ self.logger.warning("DHW has an invalid/missing schedule: %r", err)
async def _async_update_data(self) -> EvoLocStatusResponseT: # type: ignore[override]
"""Fetch the latest state of an entire TCC Location.
diff --git a/homeassistant/components/evohome/entity.py b/homeassistant/components/evohome/entity.py
index 11215dd47b6..2f93f0fb143 100644
--- a/homeassistant/components/evohome/entity.py
+++ b/homeassistant/components/evohome/entity.py
@@ -6,6 +6,7 @@ import logging
from typing import Any
import evohomeasync2 as evo
+from evohomeasync2.schemas.typedefs import DayOfWeekDhwT
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -102,7 +103,7 @@ class EvoChild(EvoEntity):
self._evo_tcs = evo_device.tcs
- self._schedule: dict[str, Any] | None = None
+ self._schedule: list[DayOfWeekDhwT] | None = None
self._setpoints: dict[str, Any] = {}
@property
@@ -123,6 +124,9 @@ class EvoChild(EvoEntity):
Only Zones & DHW controllers (but not the TCS) can have schedules.
"""
+ if not self._schedule:
+ return self._setpoints
+
this_sp_dtm, this_sp_val = self._evo_device.this_switchpoint
next_sp_dtm, next_sp_val = self._evo_device.next_switchpoint
@@ -152,10 +156,10 @@ class EvoChild(EvoEntity):
self._evo_device,
err,
)
- self._schedule = {}
+ self._schedule = []
return
else:
- self._schedule = schedule or {} # mypy hint
+ self._schedule = schedule # type: ignore[assignment]
_LOGGER.debug("Schedule['%s'] = %s", self.name, schedule)
diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json
index 823ad7be5df..21c8874135a 100644
--- a/homeassistant/components/evohome/manifest.json
+++ b/homeassistant/components/evohome/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["evohome", "evohomeasync", "evohomeasync2"],
"quality_scale": "legacy",
- "requirements": ["evohome-async==1.0.2"]
+ "requirements": ["evohome-async==1.0.5"]
}
diff --git a/homeassistant/components/evohome/strings.json b/homeassistant/components/evohome/strings.json
index ca032643c9d..4fc51c30b97 100644
--- a/homeassistant/components/evohome/strings.json
+++ b/homeassistant/components/evohome/strings.json
@@ -10,17 +10,17 @@
},
"period": {
"name": "Period",
- "description": "A period of time in days; used only with Away, DayOff, or Custom. The system will revert to Auto at midnight (up to 99 days, today is day 1)."
+ "description": "A period of time in days; used only with Away, DayOff, or Custom mode. The system will revert to Auto mode at midnight (up to 99 days, today is day 1)."
},
"duration": {
"name": "Duration",
- "description": "The duration in hours; used only with AutoWithEco (up to 24 hours)."
+ "description": "The duration in hours; used only with AutoWithEco mode (up to 24 hours)."
}
}
},
"reset_system": {
"name": "Reset system",
- "description": "Sets the system to Auto mode and reset all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode)."
+ "description": "Sets the system to Auto mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode)."
},
"refresh_system": {
"name": "Refresh system",
diff --git a/homeassistant/components/ezviz/alarm_control_panel.py b/homeassistant/components/ezviz/alarm_control_panel.py
index 66a76df2cdc..08fa0a68ee8 100644
--- a/homeassistant/components/ezviz/alarm_control_panel.py
+++ b/homeassistant/components/ezviz/alarm_control_panel.py
@@ -18,7 +18,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
@@ -50,7 +50,7 @@ ALARM_TYPE = EzvizAlarmControlPanelEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ezviz alarm control panel."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py
index 6f0d87c8218..5e069e0277a 100644
--- a/homeassistant/components/ezviz/binary_sensor.py
+++ b/homeassistant/components/ezviz/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
@@ -34,7 +34,7 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ezviz/button.py b/homeassistant/components/ezviz/button.py
index b99674b0693..6dbb419c903 100644
--- a/homeassistant/components/ezviz/button.py
+++ b/homeassistant/components/ezviz/button.py
@@ -13,7 +13,7 @@ from pyezviz.exceptions import HTTPError, PyEzvizError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
@@ -68,7 +68,7 @@ BUTTON_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EZVIZ button based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py
index d96fc949c86..e3d01bef83e 100644
--- a/homeassistant/components/ezviz/camera.py
+++ b/homeassistant/components/ezviz/camera.py
@@ -15,7 +15,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
@@ -36,7 +36,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EZVIZ cameras based on a config entry."""
@@ -141,11 +141,6 @@ class EzvizCamera(EzvizEntity, Camera):
if camera_password:
self._attr_supported_features = CameraEntityFeature.STREAM
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self.data["status"] != 2
-
@property
def is_on(self) -> bool:
"""Return true if on."""
diff --git a/homeassistant/components/ezviz/const.py b/homeassistant/components/ezviz/const.py
index e6de538335c..1d165c7bbe8 100644
--- a/homeassistant/components/ezviz/const.py
+++ b/homeassistant/components/ezviz/const.py
@@ -32,4 +32,4 @@ EU_URL = "apiieu.ezvizlife.com"
RUSSIA_URL = "apirus.ezvizru.com"
DEFAULT_CAMERA_USERNAME = "admin"
DEFAULT_TIMEOUT = 25
-DEFAULT_FFMPEG_ARGUMENTS = ""
+DEFAULT_FFMPEG_ARGUMENTS = "/Streaming/Channels/102"
diff --git a/homeassistant/components/ezviz/entity.py b/homeassistant/components/ezviz/entity.py
index 44de4a0c9c7..54614e4899a 100644
--- a/homeassistant/components/ezviz/entity.py
+++ b/homeassistant/components/ezviz/entity.py
@@ -42,6 +42,11 @@ class EzvizEntity(CoordinatorEntity[EzvizDataUpdateCoordinator], Entity):
"""Return coordinator data for this entity."""
return self.coordinator.data[self._serial]
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self.data["status"] != 2
+
class EzvizBaseEntity(Entity):
"""Generic entity for EZVIZ individual poll entities."""
@@ -72,3 +77,8 @@ class EzvizBaseEntity(Entity):
def data(self) -> dict[str, Any]:
"""Return coordinator data for this entity."""
return self.coordinator.data[self._serial]
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self.data["status"] != 2
diff --git a/homeassistant/components/ezviz/image.py b/homeassistant/components/ezviz/image.py
index d4c7a267b1e..28ebc7279e6 100644
--- a/homeassistant/components/ezviz/image.py
+++ b/homeassistant/components/ezviz/image.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
+from propcache.api import cached_property
from pyezviz.exceptions import PyEzvizError
from pyezviz.utils import decrypt_image
@@ -11,7 +12,7 @@ from homeassistant.components.image import Image, ImageEntity, ImageEntityDescri
from homeassistant.config_entries import SOURCE_IGNORE
from homeassistant.const import CONF_PASSWORD
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -29,7 +30,7 @@ IMAGE_TYPE = ImageEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EZVIZ image entities based on a config entry."""
@@ -62,6 +63,11 @@ class EzvizLastMotion(EzvizEntity, ImageEntity):
else None
)
+ @cached_property
+ def available(self) -> bool:
+ """Entity gets data from ezviz API so always available."""
+ return True
+
async def _async_load_image_from_url(self, url: str) -> Image | None:
"""Load an image by url."""
if response := await self._fetch_url(url):
diff --git a/homeassistant/components/ezviz/light.py b/homeassistant/components/ezviz/light.py
index 145c8b1ca20..ba398dd3ed4 100644
--- a/homeassistant/components/ezviz/light.py
+++ b/homeassistant/components/ezviz/light.py
@@ -10,7 +10,7 @@ from pyezviz.exceptions import HTTPError, PyEzvizError
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -26,7 +26,7 @@ BRIGHTNESS_RANGE = (1, 255)
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EZVIZ lights based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py
index 9e8a20f36dd..9bdd1feb81d 100644
--- a/homeassistant/components/ezviz/number.py
+++ b/homeassistant/components/ezviz/number.py
@@ -19,7 +19,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizBaseEntity
@@ -51,7 +51,7 @@ NUMBER_TYPE = EzvizNumberEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ezviz/select.py b/homeassistant/components/ezviz/select.py
index 8e037fe6c33..486564bff6e 100644
--- a/homeassistant/components/ezviz/select.py
+++ b/homeassistant/components/ezviz/select.py
@@ -11,7 +11,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
@@ -38,7 +38,7 @@ SELECT_TYPE = EzvizSelectEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EZVIZ select entities based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py
index f3d50836bc7..c441b34b42d 100644
--- a/homeassistant/components/ezviz/sensor.py
+++ b/homeassistant/components/ezviz/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
@@ -72,7 +72,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ezviz/siren.py b/homeassistant/components/ezviz/siren.py
index 5a612aa0772..a2c88f58972 100644
--- a/homeassistant/components/ezviz/siren.py
+++ b/homeassistant/components/ezviz/siren.py
@@ -17,7 +17,7 @@ from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import event as evt
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
@@ -35,7 +35,7 @@ SIREN_ENTITY_TYPE = SirenEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json
index f1653661cdd..cd8bbc9d199 100644
--- a/homeassistant/components/ezviz/strings.json
+++ b/homeassistant/components/ezviz/strings.json
@@ -54,7 +54,7 @@
"init": {
"data": {
"timeout": "Request timeout (seconds)",
- "ffmpeg_arguments": "Arguments passed to ffmpeg for cameras"
+ "ffmpeg_arguments": "Arguments passed to FFmpeg for cameras"
}
}
}
diff --git a/homeassistant/components/ezviz/switch.py b/homeassistant/components/ezviz/switch.py
index 1a347c931a6..01f7cac1a55 100644
--- a/homeassistant/components/ezviz/switch.py
+++ b/homeassistant/components/ezviz/switch.py
@@ -15,7 +15,7 @@ from homeassistant.components.switch import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
@@ -107,7 +107,7 @@ SWITCH_TYPES: dict[int, EzvizSwitchEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EZVIZ switch based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py
index 3027e048688..c9f8038b336 100644
--- a/homeassistant/components/ezviz/update.py
+++ b/homeassistant/components/ezviz/update.py
@@ -14,7 +14,7 @@ from homeassistant.components.update import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EzvizConfigEntry, EzvizDataUpdateCoordinator
from .entity import EzvizEntity
@@ -30,7 +30,7 @@ UPDATE_ENTITY_TYPES = UpdateEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: EzvizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up EZVIZ sensors based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py
index 0fbc028f111..6822e2620fd 100644
--- a/homeassistant/components/faa_delays/binary_sensor.py
+++ b/homeassistant/components/faa_delays/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FAAConfigEntry, FAADataUpdateCoordinator
@@ -83,7 +83,9 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: FAAConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FAAConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a FAA sensor based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py
index b633cb25628..5b6429b0754 100644
--- a/homeassistant/components/fastdotcom/sensor.py
+++ b/homeassistant/components/fastdotcom/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import UnitOfDataRate
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -20,7 +20,7 @@ from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: FastdotcomConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fast.com sensor."""
async_add_entities([SpeedtestSensor(entry.entry_id, entry.runtime_data)])
diff --git a/homeassistant/components/feedreader/event.py b/homeassistant/components/feedreader/event.py
index ad6aed0fc76..578b5b1e175 100644
--- a/homeassistant/components/feedreader/event.py
+++ b/homeassistant/components/feedreader/event.py
@@ -10,7 +10,7 @@ from feedparser import FeedParserDict
from homeassistant.components.event import EventEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FeedReaderConfigEntry
@@ -28,7 +28,7 @@ ATTR_TITLE = "title"
async def async_setup_entry(
hass: HomeAssistant,
entry: FeedReaderConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up event entities for feedreader."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/feedreader/strings.json b/homeassistant/components/feedreader/strings.json
index 0f0492eb6c9..35022e82bb1 100644
--- a/homeassistant/components/feedreader/strings.json
+++ b/homeassistant/components/feedreader/strings.json
@@ -36,7 +36,7 @@
"issues": {
"import_yaml_error_url_error": {
"title": "The Feedreader YAML configuration import failed",
- "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessable for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually."
+ "description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that the URL is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually."
}
}
}
diff --git a/homeassistant/components/ffmpeg/strings.json b/homeassistant/components/ffmpeg/strings.json
index 66c1f19de5b..cac7fcfc48c 100644
--- a/homeassistant/components/ffmpeg/strings.json
+++ b/homeassistant/components/ffmpeg/strings.json
@@ -2,7 +2,7 @@
"services": {
"restart": {
"name": "[%key:common::action::restart%]",
- "description": "Sends a restart command to a ffmpeg based sensor.",
+ "description": "Sends a restart command to an FFmpeg-based sensor.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -12,7 +12,7 @@
},
"start": {
"name": "[%key:common::action::start%]",
- "description": "Sends a start command to a ffmpeg based sensor.",
+ "description": "Sends a start command to an FFmpeg-based sensor.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -22,7 +22,7 @@
},
"stop": {
"name": "[%key:common::action::stop%]",
- "description": "Sends a stop command to a ffmpeg based sensor.",
+ "description": "Sends a stop command to an FFmpeg-based sensor.",
"fields": {
"entity_id": {
"name": "Entity",
diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py
index 8ede0169482..a74656eef11 100644
--- a/homeassistant/components/fibaro/__init__.py
+++ b/homeassistant/components/fibaro/__init__.py
@@ -7,21 +7,22 @@ from collections.abc import Callable, Mapping
import logging
from typing import Any
-from pyfibaro.fibaro_client import FibaroClient
+from pyfibaro.fibaro_client import (
+ FibaroAuthenticationFailed,
+ FibaroClient,
+ FibaroConnectFailed,
+)
+from pyfibaro.fibaro_data_helper import find_master_devices, read_rooms
from pyfibaro.fibaro_device import DeviceModel
-from pyfibaro.fibaro_room import RoomModel
+from pyfibaro.fibaro_device_manager import FibaroDeviceManager
+from pyfibaro.fibaro_info import InfoModel
from pyfibaro.fibaro_scene import SceneModel
-from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver
-from requests.exceptions import HTTPError
+from pyfibaro.fibaro_state_resolver import FibaroEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import (
- ConfigEntryAuthFailed,
- ConfigEntryNotReady,
- HomeAssistantError,
-)
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo
from homeassistant.util import slugify
@@ -74,128 +75,47 @@ FIBARO_TYPEMAP = {
class FibaroController:
"""Initiate Fibaro Controller Class."""
- def __init__(self, config: Mapping[str, Any]) -> None:
+ def __init__(
+ self, fibaro_client: FibaroClient, info: InfoModel, import_plugins: bool
+ ) -> None:
"""Initialize the Fibaro controller."""
+ self._client = fibaro_client
+ self._fibaro_info = info
- # The FibaroClient uses the correct API version automatically
- self._client = FibaroClient(config[CONF_URL])
- self._client.set_authentication(config[CONF_USERNAME], config[CONF_PASSWORD])
-
- # Whether to import devices from plugins
- self._import_plugins = config[CONF_IMPORT_PLUGINS]
- self._room_map: dict[int, RoomModel] # Mapping roomId to room object
+ # The fibaro device manager exposes higher level API to access fibaro devices
+ self._fibaro_device_manager = FibaroDeviceManager(fibaro_client, import_plugins)
+ # Mapping roomId to room object
+ self._room_map = read_rooms(fibaro_client)
self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object
self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict(
list
) # List of devices by entity platform
# All scenes
- self._scenes: list[SceneModel] = []
- self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId
- # Event callbacks by device id
- self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {}
- self.hub_serial: str # Unique serial number of the hub
- self.hub_name: str # The friendly name of the hub
- self.hub_model: str
- self.hub_software_version: str
- self.hub_api_url: str = config[CONF_URL]
+ self._scenes = self._client.read_scenes()
+ # Unique serial number of the hub
+ self.hub_serial = info.serial_number
# Device infos by fibaro device id
self._device_infos: dict[int, DeviceInfo] = {}
-
- def connect(self) -> None:
- """Start the communication with the Fibaro controller."""
-
- # Return value doesn't need to be checked,
- # it is only relevant when connecting without credentials
- self._client.connect()
- info = self._client.read_info()
- self.hub_serial = info.serial_number
- self.hub_name = info.hc_name
- self.hub_model = info.platform
- self.hub_software_version = info.current_version
-
- self._room_map = {room.fibaro_id: room for room in self._client.read_rooms()}
self._read_devices()
- self._scenes = self._client.read_scenes()
- def connect_with_error_handling(self) -> None:
- """Translate connect errors to easily differentiate auth and connect failures.
+ def disconnect(self) -> None:
+ """Close push channel."""
+ self._fibaro_device_manager.close()
- When there is a better error handling in the used library this can be improved.
- """
- try:
- self.connect()
- except HTTPError as http_ex:
- if http_ex.response.status_code == 403:
- raise FibaroAuthFailed from http_ex
-
- raise FibaroConnectFailed from http_ex
- except Exception as ex:
- raise FibaroConnectFailed from ex
-
- def enable_state_handler(self) -> None:
- """Start StateHandler thread for monitoring updates."""
- self._client.register_update_handler(self._on_state_change)
-
- def disable_state_handler(self) -> None:
- """Stop StateHandler thread used for monitoring updates."""
- self._client.unregister_update_handler()
-
- def _on_state_change(self, state: Any) -> None:
- """Handle change report received from the HomeCenter."""
- callback_set = set()
- for change in state.get("changes", []):
- try:
- dev_id = change.pop("id")
- if dev_id not in self._device_map:
- continue
- device = self._device_map[dev_id]
- for property_name, value in change.items():
- if property_name == "log":
- if value and value != "transfer OK":
- _LOGGER.debug("LOG %s: %s", device.friendly_name, value)
- continue
- if property_name == "logTemp":
- continue
- if property_name in device.properties:
- device.properties[property_name] = value
- _LOGGER.debug(
- "<- %s.%s = %s", device.ha_id, property_name, str(value)
- )
- else:
- _LOGGER.warning("%s.%s not found", device.ha_id, property_name)
- if dev_id in self._callbacks:
- callback_set.add(dev_id)
- except (ValueError, KeyError):
- pass
- for item in callback_set:
- for callback in self._callbacks[item]:
- callback()
-
- resolver = FibaroStateResolver(state)
- for event in resolver.get_events():
- # event does not always have a fibaro id, therefore it is
- # essential that we first check for relevant event type
- if (
- event.event_type.lower() == "centralsceneevent"
- and event.fibaro_id in self._event_callbacks
- ):
- for callback in self._event_callbacks[event.fibaro_id]:
- callback(event)
-
- def register(self, device_id: int, callback: Any) -> None:
+ def register(
+ self, device_id: int, callback: Callable[[DeviceModel], None]
+ ) -> Callable[[], None]:
"""Register device with a callback for updates."""
- device_callbacks = self._callbacks.setdefault(device_id, [])
- device_callbacks.append(callback)
+ return self._fibaro_device_manager.add_change_listener(device_id, callback)
def register_event(
self, device_id: int, callback: Callable[[FibaroEvent], None]
- ) -> None:
+ ) -> Callable[[], None]:
"""Register device with a callback for central scene events.
The callback receives one parameter with the event.
"""
- device_callbacks = self._event_callbacks.setdefault(device_id, [])
- device_callbacks.append(callback)
+ return self._fibaro_device_manager.add_event_listener(device_id, callback)
def get_children(self, device_id: int) -> list[DeviceModel]:
"""Get a list of child devices."""
@@ -256,35 +176,18 @@ class FibaroController:
platform = Platform.LIGHT
return platform
- def _create_device_info(
- self, device: DeviceModel, devices: list[DeviceModel]
- ) -> None:
- """Create the device info. Unrooted entities are directly shown below the home center."""
+ def _create_device_info(self, main_device: DeviceModel) -> None:
+ """Create the device info for a main device."""
- # The home center is always id 1 (z-wave primary controller)
- if device.parent_fibaro_id <= 1:
- return
-
- master_entity: DeviceModel | None = None
- if device.parent_fibaro_id == 1:
- master_entity = device
- else:
- for parent in devices:
- if parent.fibaro_id == device.parent_fibaro_id:
- master_entity = parent
- if master_entity is None:
- _LOGGER.error("Parent with id %s not found", device.parent_fibaro_id)
- return
-
- if "zwaveCompany" in master_entity.properties:
- manufacturer = master_entity.properties.get("zwaveCompany")
+ if "zwaveCompany" in main_device.properties:
+ manufacturer = main_device.properties.get("zwaveCompany")
else:
manufacturer = None
- self._device_infos[master_entity.fibaro_id] = DeviceInfo(
- identifiers={(DOMAIN, master_entity.fibaro_id)},
+ self._device_infos[main_device.fibaro_id] = DeviceInfo(
+ identifiers={(DOMAIN, main_device.fibaro_id)},
manufacturer=manufacturer,
- name=master_entity.name,
+ name=main_device.name,
via_device=(DOMAIN, self.hub_serial),
)
@@ -302,40 +205,50 @@ class FibaroController:
def get_room_name(self, room_id: int) -> str | None:
"""Get the room name by room id."""
- assert self._room_map
- room = self._room_map.get(room_id)
- return room.name if room else None
+ return self._room_map.get(room_id)
def read_scenes(self) -> list[SceneModel]:
"""Return list of scenes."""
return self._scenes
+ def get_all_devices(self) -> list[DeviceModel]:
+ """Return list of all fibaro devices."""
+ return self._fibaro_device_manager.get_devices()
+
+ def read_fibaro_info(self) -> InfoModel:
+ """Return the general info about the hub."""
+ return self._fibaro_info
+
+ def get_frontend_url(self) -> str:
+ """Return the url to the Fibaro hub web UI."""
+ return self._client.frontend_url()
+
def _read_devices(self) -> None:
"""Read and process the device list."""
- devices = self._client.read_devices()
+ devices = self._fibaro_device_manager.get_devices()
+
+ for main_device in find_master_devices(devices):
+ self._create_device_info(main_device)
+
self._device_map = {}
last_climate_parent = None
last_endpoint = None
for device in devices:
try:
device.fibaro_controller = self
- if device.room_id == 0:
+ room_name = self.get_room_name(device.room_id)
+ if not room_name:
room_name = "Unknown"
- else:
- room_name = self._room_map[device.room_id].name
device.room_name = room_name
device.friendly_name = f"{room_name} {device.name}"
device.ha_id = (
f"{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}"
)
- if device.enabled and (not device.is_plugin or self._import_plugins):
- device.mapped_platform = self._map_device_to_platform(device)
- else:
- device.mapped_platform = None
- if (platform := device.mapped_platform) is None:
+
+ platform = self._map_device_to_platform(device)
+ if platform is None:
continue
device.unique_id_str = f"{slugify(self.hub_serial)}.{device.fibaro_id}"
- self._create_device_info(device, devices)
self._device_map[device.fibaro_id] = device
_LOGGER.debug(
"%s (%s, %s) -> %s %s",
@@ -375,11 +288,17 @@ class FibaroController:
pass
+def connect_fibaro_client(data: Mapping[str, Any]) -> tuple[InfoModel, FibaroClient]:
+ """Connect to the fibaro hub and read some basic data."""
+ client = FibaroClient(data[CONF_URL])
+ info = client.connect_with_credentials(data[CONF_USERNAME], data[CONF_PASSWORD])
+ return (info, client)
+
+
def init_controller(data: Mapping[str, Any]) -> FibaroController:
- """Validate the user input allows us to connect to fibaro."""
- controller = FibaroController(data)
- controller.connect_with_error_handling()
- return controller
+ """Connect to the fibaro hub and init the controller."""
+ info, client = connect_fibaro_client(data)
+ return FibaroController(client, info, data[CONF_IMPORT_PLUGINS])
async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bool:
@@ -393,28 +312,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> bo
raise ConfigEntryNotReady(
f"Could not connect to controller at {entry.data[CONF_URL]}"
) from connect_ex
- except FibaroAuthFailed as auth_ex:
+ except FibaroAuthenticationFailed as auth_ex:
raise ConfigEntryAuthFailed from auth_ex
entry.runtime_data = controller
# register the hub device info separately as the hub has sometimes no entities
+ fibaro_info = controller.read_fibaro_info()
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, controller.hub_serial)},
serial_number=controller.hub_serial,
- manufacturer="Fibaro",
- name=controller.hub_name,
- model=controller.hub_model,
- sw_version=controller.hub_software_version,
- configuration_url=controller.hub_api_url.removesuffix("/api/"),
+ manufacturer=fibaro_info.manufacturer_name,
+ name=fibaro_info.hc_name,
+ model=fibaro_info.model_name,
+ sw_version=fibaro_info.current_version,
+ configuration_url=controller.get_frontend_url(),
+ connections={(dr.CONNECTION_NETWORK_MAC, fibaro_info.mac_address)},
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- controller.enable_state_handler()
-
return True
@@ -423,8 +342,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: FibaroConfigEntry) -> b
_LOGGER.debug("Shutting down Fibaro connection")
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- entry.runtime_data.disable_state_handler()
-
+ entry.runtime_data.disconnect()
return unload_ok
@@ -443,11 +361,3 @@ async def async_remove_config_entry_device(
return False
return True
-
-
-class FibaroConnectFailed(HomeAssistantError):
- """Error to indicate we cannot connect to fibaro home center."""
-
-
-class FibaroAuthFailed(HomeAssistantError):
- """Error to indicate that authentication failed on fibaro home center."""
diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py
index 16e79c0c1d0..14c8f03f3ec 100644
--- a/homeassistant/components/fibaro/binary_sensor.py
+++ b/homeassistant/components/fibaro/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FibaroConfigEntry
from .entity import FibaroEntity
@@ -42,7 +42,7 @@ SENSOR_TYPES = {
async def async_setup_entry(
hass: HomeAssistant,
entry: FibaroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Fibaro controller devices."""
controller = entry.runtime_data
diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py
index 45f700026a0..7a8cc3fd2a9 100644
--- a/homeassistant/components/fibaro/climate.py
+++ b/homeassistant/components/fibaro/climate.py
@@ -19,7 +19,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FibaroConfigEntry
from .entity import FibaroEntity
@@ -110,7 +110,7 @@ OP_MODE_ACTIONS = ("setMode", "setOperatingMode", "setThermostatMode")
async def async_setup_entry(
hass: HomeAssistant,
entry: FibaroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Fibaro controller devices."""
controller = entry.runtime_data
@@ -129,13 +129,13 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
def __init__(self, fibaro_device: DeviceModel) -> None:
"""Initialize the Fibaro device."""
super().__init__(fibaro_device)
- self._temp_sensor_device: FibaroEntity | None = None
- self._target_temp_device: FibaroEntity | None = None
- self._op_mode_device: FibaroEntity | None = None
- self._fan_mode_device: FibaroEntity | None = None
+ self._temp_sensor_device: DeviceModel | None = None
+ self._target_temp_device: DeviceModel | None = None
+ self._op_mode_device: DeviceModel | None = None
+ self._fan_mode_device: DeviceModel | None = None
self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
- siblings = fibaro_device.fibaro_controller.get_siblings(fibaro_device)
+ siblings = self.controller.get_siblings(fibaro_device)
_LOGGER.debug("%s siblings: %s", fibaro_device.ha_id, siblings)
tempunit = "C"
for device in siblings:
@@ -147,23 +147,23 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
and (device.value.has_value or device.has_heating_thermostat_setpoint)
and device.unit in ("C", "F")
):
- self._temp_sensor_device = FibaroEntity(device)
+ self._temp_sensor_device = device
tempunit = device.unit
if any(
action for action in TARGET_TEMP_ACTIONS if action in device.actions
):
- self._target_temp_device = FibaroEntity(device)
+ self._target_temp_device = device
self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
if device.has_unit:
tempunit = device.unit
if any(action for action in OP_MODE_ACTIONS if action in device.actions):
- self._op_mode_device = FibaroEntity(device)
+ self._op_mode_device = device
self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE
if "setFanMode" in device.actions:
- self._fan_mode_device = FibaroEntity(device)
+ self._fan_mode_device = device
self._attr_supported_features |= ClimateEntityFeature.FAN_MODE
if tempunit == "F":
@@ -172,7 +172,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
if self._fan_mode_device:
- fan_modes = self._fan_mode_device.fibaro_device.supported_modes
+ fan_modes = self._fan_mode_device.supported_modes
self._attr_fan_modes = []
for mode in fan_modes:
if mode not in FANMODES:
@@ -184,7 +184,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
if self._op_mode_device:
self._attr_preset_modes = []
self._attr_hvac_modes: list[HVACMode] = []
- device = self._op_mode_device.fibaro_device
+ device = self._op_mode_device
if device.has_supported_thermostat_modes:
for mode in device.supported_thermostat_modes:
try:
@@ -222,15 +222,15 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
"- _fan_mode_device %s"
),
self.ha_id,
- self._temp_sensor_device.ha_id if self._temp_sensor_device else "None",
- self._target_temp_device.ha_id if self._target_temp_device else "None",
- self._op_mode_device.ha_id if self._op_mode_device else "None",
- self._fan_mode_device.ha_id if self._fan_mode_device else "None",
+ self._temp_sensor_device.fibaro_id if self._temp_sensor_device else "None",
+ self._target_temp_device.fibaro_id if self._target_temp_device else "None",
+ self._op_mode_device.fibaro_id if self._op_mode_device else "None",
+ self._fan_mode_device.fibaro_id if self._fan_mode_device else "None",
)
await super().async_added_to_hass()
# Register update callback for child devices
- siblings = self.fibaro_device.fibaro_controller.get_siblings(self.fibaro_device)
+ siblings = self.controller.get_siblings(self.fibaro_device)
for device in siblings:
if device != self.fibaro_device:
self.controller.register(device.fibaro_id, self._update_callback)
@@ -240,14 +240,14 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
"""Return the fan setting."""
if not self._fan_mode_device:
return None
- mode = self._fan_mode_device.fibaro_device.mode
+ mode = self._fan_mode_device.mode
return FANMODES[mode]
def set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
if not self._fan_mode_device:
return
- self._fan_mode_device.action("setFanMode", HA_FANMODES[fan_mode])
+ self._fan_mode_device.execute_action("setFanMode", [HA_FANMODES[fan_mode]])
@property
def fibaro_op_mode(self) -> str | int:
@@ -255,7 +255,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
if not self._op_mode_device:
return HA_OPMODES_HVAC[HVACMode.AUTO]
- device = self._op_mode_device.fibaro_device
+ device = self._op_mode_device
if device.has_operating_mode:
return device.operating_mode
@@ -281,17 +281,17 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
if not self._op_mode_device:
return
- if "setOperatingMode" in self._op_mode_device.fibaro_device.actions:
- self._op_mode_device.action("setOperatingMode", HA_OPMODES_HVAC[hvac_mode])
- elif "setThermostatMode" in self._op_mode_device.fibaro_device.actions:
- device = self._op_mode_device.fibaro_device
+ device = self._op_mode_device
+ if "setOperatingMode" in device.actions:
+ device.execute_action("setOperatingMode", [HA_OPMODES_HVAC[hvac_mode]])
+ elif "setThermostatMode" in device.actions:
if device.has_supported_thermostat_modes:
for mode in device.supported_thermostat_modes:
if mode.lower() == hvac_mode:
- self._op_mode_device.action("setThermostatMode", mode)
+ device.execute_action("setThermostatMode", [mode])
break
- elif "setMode" in self._op_mode_device.fibaro_device.actions:
- self._op_mode_device.action("setMode", HA_OPMODES_HVAC[hvac_mode])
+ elif "setMode" in device.actions:
+ device.execute_action("setMode", [HA_OPMODES_HVAC[hvac_mode]])
@property
def hvac_action(self) -> HVACAction | None:
@@ -299,7 +299,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
if not self._op_mode_device:
return None
- device = self._op_mode_device.fibaro_device
+ device = self._op_mode_device
if device.has_thermostat_operating_state:
with suppress(ValueError):
return HVACAction(device.thermostat_operating_state.lower())
@@ -315,15 +315,15 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
if not self._op_mode_device:
return None
- if self._op_mode_device.fibaro_device.has_thermostat_mode:
- mode = self._op_mode_device.fibaro_device.thermostat_mode
+ if self._op_mode_device.has_thermostat_mode:
+ mode = self._op_mode_device.thermostat_mode
if self.preset_modes is not None and mode in self.preset_modes:
return mode
return None
- if self._op_mode_device.fibaro_device.has_operating_mode:
- mode = self._op_mode_device.fibaro_device.operating_mode
+ if self._op_mode_device.has_operating_mode:
+ mode = self._op_mode_device.operating_mode
else:
- mode = self._op_mode_device.fibaro_device.mode
+ mode = self._op_mode_device.mode
if mode not in OPMODES_PRESET:
return None
@@ -334,20 +334,22 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
if self._op_mode_device is None:
return
- if "setThermostatMode" in self._op_mode_device.fibaro_device.actions:
- self._op_mode_device.action("setThermostatMode", preset_mode)
- elif "setOperatingMode" in self._op_mode_device.fibaro_device.actions:
- self._op_mode_device.action(
- "setOperatingMode", HA_OPMODES_PRESET[preset_mode]
+ if "setThermostatMode" in self._op_mode_device.actions:
+ self._op_mode_device.execute_action("setThermostatMode", [preset_mode])
+ elif "setOperatingMode" in self._op_mode_device.actions:
+ self._op_mode_device.execute_action(
+ "setOperatingMode", [HA_OPMODES_PRESET[preset_mode]]
+ )
+ elif "setMode" in self._op_mode_device.actions:
+ self._op_mode_device.execute_action(
+ "setMode", [HA_OPMODES_PRESET[preset_mode]]
)
- elif "setMode" in self._op_mode_device.fibaro_device.actions:
- self._op_mode_device.action("setMode", HA_OPMODES_PRESET[preset_mode])
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self._temp_sensor_device:
- device = self._temp_sensor_device.fibaro_device
+ device = self._temp_sensor_device
if device.has_heating_thermostat_setpoint:
return device.heating_thermostat_setpoint
return device.value.float_value()
@@ -357,7 +359,7 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
if self._target_temp_device:
- device = self._target_temp_device.fibaro_device
+ device = self._target_temp_device
if device.has_heating_thermostat_setpoint_future:
return device.heating_thermostat_setpoint_future
return device.target_level
@@ -368,9 +370,11 @@ class FibaroThermostat(FibaroEntity, ClimateEntity):
temperature = kwargs.get(ATTR_TEMPERATURE)
target = self._target_temp_device
if target is not None and temperature is not None:
- if "setThermostatSetpoint" in target.fibaro_device.actions:
- target.action("setThermostatSetpoint", self.fibaro_op_mode, temperature)
- elif "setHeatingThermostatSetpoint" in target.fibaro_device.actions:
- target.action("setHeatingThermostatSetpoint", temperature)
+ if "setThermostatSetpoint" in target.actions:
+ target.execute_action(
+ "setThermostatSetpoint", [self.fibaro_op_mode, temperature]
+ )
+ elif "setHeatingThermostatSetpoint" in target.actions:
+ target.execute_action("setHeatingThermostatSetpoint", [temperature])
else:
- target.action("setTargetLevel", temperature)
+ target.execute_action("setTargetLevel", [temperature])
diff --git a/homeassistant/components/fibaro/config_flow.py b/homeassistant/components/fibaro/config_flow.py
index 0ffd9aaa48f..d941ceab37f 100644
--- a/homeassistant/components/fibaro/config_flow.py
+++ b/homeassistant/components/fibaro/config_flow.py
@@ -6,6 +6,7 @@ from collections.abc import Mapping
import logging
from typing import Any
+from pyfibaro.fibaro_client import FibaroAuthenticationFailed, FibaroConnectFailed
from slugify import slugify
import voluptuous as vol
@@ -13,7 +14,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from . import FibaroAuthFailed, FibaroConnectFailed, init_controller
+from . import connect_fibaro_client
from .const import CONF_IMPORT_PLUGINS, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -33,16 +34,16 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
- controller = await hass.async_add_executor_job(init_controller, data)
+ info, _ = await hass.async_add_executor_job(connect_fibaro_client, data)
_LOGGER.debug(
"Successfully connected to fibaro home center %s with name %s",
- controller.hub_serial,
- controller.hub_name,
+ info.serial_number,
+ info.hc_name,
)
return {
- "serial_number": slugify(controller.hub_serial),
- "name": controller.hub_name,
+ "serial_number": slugify(info.serial_number),
+ "name": info.hc_name,
}
@@ -75,7 +76,7 @@ class FibaroConfigFlow(ConfigFlow, domain=DOMAIN):
info = await _validate_input(self.hass, user_input)
except FibaroConnectFailed:
errors["base"] = "cannot_connect"
- except FibaroAuthFailed:
+ except FibaroAuthenticationFailed:
errors["base"] = "invalid_auth"
else:
await self.async_set_unique_id(info["serial_number"])
@@ -106,7 +107,7 @@ class FibaroConfigFlow(ConfigFlow, domain=DOMAIN):
await _validate_input(self.hass, new_data)
except FibaroConnectFailed:
errors["base"] = "cannot_connect"
- except FibaroAuthFailed:
+ except FibaroAuthenticationFailed:
errors["base"] = "invalid_auth"
else:
return self.async_update_reload_and_abort(
diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py
index bfebbf87bd2..0008b56345e 100644
--- a/homeassistant/components/fibaro/cover.py
+++ b/homeassistant/components/fibaro/cover.py
@@ -15,7 +15,7 @@ from homeassistant.components.cover import (
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FibaroConfigEntry
from .entity import FibaroEntity
@@ -24,7 +24,7 @@ from .entity import FibaroEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FibaroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fibaro covers."""
controller = entry.runtime_data
diff --git a/homeassistant/components/fibaro/diagnostics.py b/homeassistant/components/fibaro/diagnostics.py
new file mode 100644
index 00000000000..2f1f397a69a
--- /dev/null
+++ b/homeassistant/components/fibaro/diagnostics.py
@@ -0,0 +1,56 @@
+"""Diagnostics support for fibaro integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+from pyfibaro.fibaro_device import DeviceModel
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntry
+
+from . import CONF_IMPORT_PLUGINS, FibaroConfigEntry
+
+TO_REDACT = {"password"}
+
+
+def _create_diagnostics_data(
+ config_entry: FibaroConfigEntry, devices: list[DeviceModel]
+) -> dict[str, Any]:
+ """Combine diagnostics information and redact sensitive information."""
+ return {
+ "config": {CONF_IMPORT_PLUGINS: config_entry.data.get(CONF_IMPORT_PLUGINS)},
+ "fibaro_devices": async_redact_data([d.raw_data for d in devices], TO_REDACT),
+ }
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: FibaroConfigEntry
+) -> Mapping[str, Any]:
+ """Return diagnostics for a config entry."""
+ controller = config_entry.runtime_data
+ devices = controller.get_all_devices()
+ return _create_diagnostics_data(config_entry, devices)
+
+
+async def async_get_device_diagnostics(
+ hass: HomeAssistant, config_entry: FibaroConfigEntry, device: DeviceEntry
+) -> Mapping[str, Any]:
+ """Return diagnostics for a device."""
+ controller = config_entry.runtime_data
+ devices = controller.get_all_devices()
+
+ ha_device_id = next(iter(device.identifiers))[1]
+ if ha_device_id == controller.hub_serial:
+ # special case where the device is representing the fibaro hub
+ return _create_diagnostics_data(config_entry, devices)
+
+ # normal devices are represented by a parent / child structure
+ filtered_devices = [
+ device
+ for device in devices
+ if ha_device_id in (device.fibaro_id, device.parent_fibaro_id)
+ ]
+ return _create_diagnostics_data(config_entry, filtered_devices)
diff --git a/homeassistant/components/fibaro/entity.py b/homeassistant/components/fibaro/entity.py
index 6a8e12136c8..e8ed5afc500 100644
--- a/homeassistant/components/fibaro/entity.py
+++ b/homeassistant/components/fibaro/entity.py
@@ -11,6 +11,8 @@ from pyfibaro.fibaro_device import DeviceModel
from homeassistant.const import ATTR_ARMED, ATTR_BATTERY_LEVEL
from homeassistant.helpers.entity import Entity
+from . import FibaroController
+
_LOGGER = logging.getLogger(__name__)
@@ -22,7 +24,7 @@ class FibaroEntity(Entity):
def __init__(self, fibaro_device: DeviceModel) -> None:
"""Initialize the device."""
self.fibaro_device = fibaro_device
- self.controller = fibaro_device.fibaro_controller
+ self.controller: FibaroController = fibaro_device.fibaro_controller
self.ha_id = fibaro_device.ha_id
self._attr_name = fibaro_device.friendly_name
self._attr_unique_id = fibaro_device.unique_id_str
@@ -34,9 +36,13 @@ class FibaroEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
- self.controller.register(self.fibaro_device.fibaro_id, self._update_callback)
+ self.async_on_remove(
+ self.controller.register(
+ self.fibaro_device.fibaro_id, self._update_callback
+ )
+ )
- def _update_callback(self) -> None:
+ def _update_callback(self, fibaro_device: DeviceModel) -> None:
"""Update the state."""
self.schedule_update_ha_state(True)
@@ -54,15 +60,6 @@ class FibaroEntity(Entity):
return self.fibaro_device.value_2.int_value()
return None
- def dont_know_message(self, cmd: str) -> None:
- """Make a warning in case we don't know how to perform an action."""
- _LOGGER.warning(
- "Not sure how to %s: %s (available actions: %s)",
- cmd,
- str(self.ha_id),
- str(self.fibaro_device.actions),
- )
-
def set_level(self, level: int) -> None:
"""Set the level of Fibaro device."""
self.action("setValue", level)
@@ -97,11 +94,7 @@ class FibaroEntity(Entity):
def action(self, cmd: str, *args: Any) -> None:
"""Perform an action on the Fibaro HC."""
- if cmd in self.fibaro_device.actions:
- self.fibaro_device.execute_action(cmd, args)
- _LOGGER.debug("-> %s.%s%s called", str(self.ha_id), str(cmd), str(args))
- else:
- self.dont_know_message(cmd)
+ self.fibaro_device.execute_action(cmd, args)
@property
def current_binary_state(self) -> bool:
diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py
index a2d5da7f877..ad44719c8be 100644
--- a/homeassistant/components/fibaro/event.py
+++ b/homeassistant/components/fibaro/event.py
@@ -12,7 +12,7 @@ from homeassistant.components.event import (
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FibaroConfigEntry
from .entity import FibaroEntity
@@ -21,7 +21,7 @@ from .entity import FibaroEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FibaroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fibaro event entities."""
controller = entry.runtime_data
@@ -60,11 +60,16 @@ class FibaroEventEntity(FibaroEntity, EventEntity):
await super().async_added_to_hass()
# Register event callback
- self.controller.register_event(
- self.fibaro_device.fibaro_id, self._event_callback
+ self.async_on_remove(
+ self.controller.register_event(
+ self.fibaro_device.fibaro_id, self._event_callback
+ )
)
def _event_callback(self, event: FibaroEvent) -> None:
- if event.key_id == self._button:
+ if (
+ event.event_type.lower() == "centralsceneevent"
+ and event.key_id == self._button
+ ):
self._trigger_event(event.key_event_type)
self.schedule_update_ha_state()
diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py
index d40e26244f3..446b9b9f7ff 100644
--- a/homeassistant/components/fibaro/light.py
+++ b/homeassistant/components/fibaro/light.py
@@ -19,7 +19,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FibaroConfigEntry
from .entity import FibaroEntity
@@ -51,7 +51,7 @@ def scaleto99(value: int | None) -> int:
async def async_setup_entry(
hass: HomeAssistant,
entry: FibaroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Fibaro controller devices."""
controller = entry.runtime_data
diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py
index 62a9dfa43b1..a1e76109e2d 100644
--- a/homeassistant/components/fibaro/lock.py
+++ b/homeassistant/components/fibaro/lock.py
@@ -9,7 +9,7 @@ from pyfibaro.fibaro_device import DeviceModel
from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FibaroConfigEntry
from .entity import FibaroEntity
@@ -18,7 +18,7 @@ from .entity import FibaroEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FibaroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fibaro locks."""
controller = entry.runtime_data
diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json
index d2a1186b05b..cd4d1de838c 100644
--- a/homeassistant/components/fibaro/manifest.json
+++ b/homeassistant/components/fibaro/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyfibaro"],
- "requirements": ["pyfibaro==0.8.0"]
+ "requirements": ["pyfibaro==0.8.2"]
}
diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py
index a4c0f1bd7f1..8a594506f27 100644
--- a/homeassistant/components/fibaro/scene.py
+++ b/homeassistant/components/fibaro/scene.py
@@ -9,7 +9,7 @@ from pyfibaro.fibaro_scene import SceneModel
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from . import FibaroConfigEntry, FibaroController
@@ -19,7 +19,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: FibaroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Fibaro scenes."""
controller = entry.runtime_data
diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py
index 245a0d087d8..9034bd7d05e 100644
--- a/homeassistant/components/fibaro/sensor.py
+++ b/homeassistant/components/fibaro/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import convert
from . import FibaroConfigEntry
@@ -102,7 +102,7 @@ FIBARO_TO_HASS_UNIT: dict[str, str] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: FibaroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fibaro controller devices."""
diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py
index f67683dff6a..8d77685c1e7 100644
--- a/homeassistant/components/fibaro/switch.py
+++ b/homeassistant/components/fibaro/switch.py
@@ -9,7 +9,7 @@ from pyfibaro.fibaro_device import DeviceModel
from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FibaroConfigEntry
from .entity import FibaroEntity
@@ -18,7 +18,7 @@ from .entity import FibaroEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FibaroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fibaro switches."""
controller = entry.runtime_data
diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py
index 3d61dbb04e0..90af1677bce 100644
--- a/homeassistant/components/file/notify.py
+++ b/homeassistant/components/file/notify.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILE_PATH, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON
@@ -23,7 +23,7 @@ from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up notify entity."""
unique_id = entry.entry_id
diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py
index 879c06e29f3..b8d174afe2c 100644
--- a/homeassistant/components/file/sensor.py
+++ b/homeassistant/components/file/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.template import Template
from .const import DEFAULT_NAME, FILE_ICON
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the file sensor."""
config = dict(entry.data)
diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py
index bd8b9b6c462..966e253660d 100644
--- a/homeassistant/components/filesize/sensor.py
+++ b/homeassistant/components/filesize/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import EntityCategory, UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -59,7 +59,7 @@ SENSOR_TYPES = (
async def async_setup_entry(
hass: HomeAssistant,
entry: FileSizeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform from config entry."""
async_add_entities(
diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py
index 330e61f499e..eb1337002e4 100644
--- a/homeassistant/components/filter/sensor.py
+++ b/homeassistant/components/filter/sensor.py
@@ -44,7 +44,10 @@ from homeassistant.core import (
callback,
)
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.start import async_at_started
@@ -201,7 +204,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Filter sensor entry."""
name: str = entry.options[CONF_NAME]
diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py
index 318325dbb09..f5188d5bf21 100644
--- a/homeassistant/components/fints/sensor.py
+++ b/homeassistant/components/fints/sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections import namedtuple
from datetime import timedelta
import logging
-from typing import Any
+from typing import Any, cast
from fints.client import FinTS3PinTanClient
from fints.models import SEPAAccount
@@ -73,7 +73,7 @@ def setup_platform(
credentials = BankCredentials(
config[CONF_BIN], config[CONF_USERNAME], config[CONF_PIN], config[CONF_URL]
)
- fints_name = config.get(CONF_NAME, config[CONF_BIN])
+ fints_name = cast(str, config.get(CONF_NAME, config[CONF_BIN]))
account_config = {
acc[CONF_ACCOUNT]: acc[CONF_NAME] for acc in config[CONF_ACCOUNTS]
diff --git a/homeassistant/components/fireservicerota/__init__.py b/homeassistant/components/fireservicerota/__init__.py
index bf5385b6f2a..0f30a29cfba 100644
--- a/homeassistant/components/fireservicerota/__init__.py
+++ b/homeassistant/components/fireservicerota/__init__.py
@@ -4,50 +4,43 @@ from __future__ import annotations
from datetime import timedelta
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN
-from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator
+from .coordinator import (
+ FireServiceConfigEntry,
+ FireServiceRotaClient,
+ FireServiceUpdateCoordinator,
+)
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: FireServiceConfigEntry) -> bool:
"""Set up FireServiceRota from a config entry."""
- hass.data.setdefault(DOMAIN, {})
-
client = FireServiceRotaClient(hass, entry)
await client.setup()
if client.token_refresh_failure:
return False
+ entry.async_on_unload(client.async_stop_listener)
coordinator = FireServiceUpdateCoordinator(hass, client, entry)
await coordinator.async_config_entry_first_refresh()
- hass.data[DOMAIN][entry.entry_id] = {
- DATA_CLIENT: client,
- DATA_COORDINATOR: coordinator,
- }
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: FireServiceConfigEntry
+) -> bool:
"""Unload FireServiceRota config entry."""
-
- await hass.async_add_executor_job(
- hass.data[DOMAIN][entry.entry_id][DATA_CLIENT].websocket.stop_listener
- )
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- del hass.data[DOMAIN][entry.entry_id]
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/fireservicerota/binary_sensor.py b/homeassistant/components/fireservicerota/binary_sensor.py
index b6d3aa67a0a..be7add191c0 100644
--- a/homeassistant/components/fireservicerota/binary_sensor.py
+++ b/homeassistant/components/fireservicerota/binary_sensor.py
@@ -7,25 +7,25 @@ from typing import Any
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN
-from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator
+from .coordinator import (
+ FireServiceConfigEntry,
+ FireServiceRotaClient,
+ FireServiceUpdateCoordinator,
+)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FireServiceConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up FireServiceRota binary sensor based on a config entry."""
- client: FireServiceRotaClient = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][
- DATA_CLIENT
- ]
-
- coordinator: FireServiceUpdateCoordinator = hass.data[FIRESERVICEROTA_DOMAIN][
- entry.entry_id
- ][DATA_COORDINATOR]
+ coordinator = entry.runtime_data
+ client = coordinator.client
async_add_entities([ResponseBinarySensor(coordinator, client, entry)])
diff --git a/homeassistant/components/fireservicerota/coordinator.py b/homeassistant/components/fireservicerota/coordinator.py
index 14a8c40e469..6815bf39104 100644
--- a/homeassistant/components/fireservicerota/coordinator.py
+++ b/homeassistant/components/fireservicerota/coordinator.py
@@ -28,12 +28,19 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
+type FireServiceConfigEntry = ConfigEntry[FireServiceUpdateCoordinator]
+
class FireServiceUpdateCoordinator(DataUpdateCoordinator[dict | None]):
"""Data update coordinator for FireServiceRota."""
+ config_entry: FireServiceConfigEntry
+
def __init__(
- self, hass: HomeAssistant, client: FireServiceRotaClient, entry: ConfigEntry
+ self,
+ hass: HomeAssistant,
+ client: FireServiceRotaClient,
+ entry: FireServiceConfigEntry,
) -> None:
"""Initialize the FireServiceRota DataUpdateCoordinator."""
super().__init__(
@@ -213,3 +220,7 @@ class FireServiceRotaClient:
)
await self.update_call(self.fsr.set_incident_response, self.incident_id, value)
+
+ async def async_stop_listener(self) -> None:
+ """Stop listener."""
+ await self._hass.async_add_executor_job(self.websocket.stop_listener)
diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py
index b09d1295025..5ed65609dc8 100644
--- a/homeassistant/components/fireservicerota/sensor.py
+++ b/homeassistant/components/fireservicerota/sensor.py
@@ -4,25 +4,24 @@ import logging
from typing import Any
from homeassistant.components.sensor import SensorEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
-from .const import DATA_CLIENT, DOMAIN as FIRESERVICEROTA_DOMAIN
-from .coordinator import FireServiceRotaClient
+from .const import DOMAIN as FIRESERVICEROTA_DOMAIN
+from .coordinator import FireServiceConfigEntry, FireServiceRotaClient
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FireServiceConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up FireServiceRota sensor based on a config entry."""
- client = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_CLIENT]
-
- async_add_entities([IncidentsSensor(client)])
+ async_add_entities([IncidentsSensor(entry.runtime_data.client)])
# pylint: disable-next=hass-invalid-inheritance # needs fixing
diff --git a/homeassistant/components/fireservicerota/strings.json b/homeassistant/components/fireservicerota/strings.json
index 7b4bd583b63..9a23161b7ec 100644
--- a/homeassistant/components/fireservicerota/strings.json
+++ b/homeassistant/components/fireservicerota/strings.json
@@ -9,7 +9,7 @@
}
},
"reauth_confirm": {
- "description": "Authentication tokens became invalid, login to recreate them.",
+ "description": "Authentication tokens became invalid, log in to recreate them.",
"data": {
"password": "[%key:common::config_flow::data::password%]"
}
diff --git a/homeassistant/components/fireservicerota/switch.py b/homeassistant/components/fireservicerota/switch.py
index affd46c91bd..d9fe382e4b1 100644
--- a/homeassistant/components/fireservicerota/switch.py
+++ b/homeassistant/components/fireservicerota/switch.py
@@ -7,21 +7,26 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN as FIRESERVICEROTA_DOMAIN
-from .coordinator import FireServiceRotaClient, FireServiceUpdateCoordinator
+from .const import DOMAIN as FIRESERVICEROTA_DOMAIN
+from .coordinator import (
+ FireServiceConfigEntry,
+ FireServiceRotaClient,
+ FireServiceUpdateCoordinator,
+)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FireServiceConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up FireServiceRota switch based on a config entry."""
- client = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_CLIENT]
-
- coordinator = hass.data[FIRESERVICEROTA_DOMAIN][entry.entry_id][DATA_COORDINATOR]
+ coordinator = entry.runtime_data
+ client = coordinator.client
async_add_entities([ResponseSwitch(coordinator, client, entry)])
diff --git a/homeassistant/components/firmata/binary_sensor.py b/homeassistant/components/firmata/binary_sensor.py
index 4973afa6960..de79f676ae6 100644
--- a/homeassistant/components/firmata/binary_sensor.py
+++ b/homeassistant/components/firmata/binary_sensor.py
@@ -5,7 +5,7 @@ import logging
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import CONF_NAME, CONF_PIN
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FirmataConfigEntry
from .const import CONF_NEGATE_STATE, CONF_PIN_MODE
@@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FirmataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Firmata binary sensors."""
new_entities = []
diff --git a/homeassistant/components/firmata/light.py b/homeassistant/components/firmata/light.py
index 4f27143b774..f866ce9dbe5 100644
--- a/homeassistant/components/firmata/light.py
+++ b/homeassistant/components/firmata/light.py
@@ -9,7 +9,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME, CONF_PIN
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FirmataConfigEntry
from .board import FirmataPinType
@@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FirmataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Firmata lights."""
new_entities = []
diff --git a/homeassistant/components/firmata/sensor.py b/homeassistant/components/firmata/sensor.py
index 569d97fe1ec..7b49950e948 100644
--- a/homeassistant/components/firmata/sensor.py
+++ b/homeassistant/components/firmata/sensor.py
@@ -5,7 +5,7 @@ import logging
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import CONF_NAME, CONF_PIN
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FirmataConfigEntry
from .const import CONF_DIFFERENTIAL, CONF_PIN_MODE
@@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FirmataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Firmata sensors."""
new_entities = []
diff --git a/homeassistant/components/firmata/switch.py b/homeassistant/components/firmata/switch.py
index 33953b78974..e84b18e9c74 100644
--- a/homeassistant/components/firmata/switch.py
+++ b/homeassistant/components/firmata/switch.py
@@ -6,7 +6,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import CONF_NAME, CONF_PIN
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FirmataConfigEntry
from .const import CONF_INITIAL_STATE, CONF_NEGATE_STATE, CONF_PIN_MODE
@@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FirmataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Firmata switches."""
new_entities = []
diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py
index bbb3da46e52..f5c8a81ca26 100644
--- a/homeassistant/components/fitbit/sensor.py
+++ b/homeassistant/components/fitbit/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -524,7 +524,7 @@ FITBIT_RESOURCE_BATTERY_LEVEL = FitbitSensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: FitbitConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fitbit sensor platform."""
diff --git a/homeassistant/components/fivem/binary_sensor.py b/homeassistant/components/fivem/binary_sensor.py
index 42119939d4a..92aab58349b 100644
--- a/homeassistant/components/fivem/binary_sensor.py
+++ b/homeassistant/components/fivem/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NAME_STATUS
from .coordinator import FiveMConfigEntry
@@ -34,7 +34,7 @@ BINARY_SENSORS: tuple[FiveMBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: FiveMConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FiveM binary sensor platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fivem/sensor.py b/homeassistant/components/fivem/sensor.py
index 88290171756..c4f5856c636 100644
--- a/homeassistant/components/fivem/sensor.py
+++ b/homeassistant/components/fivem/sensor.py
@@ -4,7 +4,7 @@ from dataclasses import dataclass
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
@@ -50,7 +50,7 @@ SENSORS: tuple[FiveMSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: FiveMConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FiveM sensor platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py
index 2703fc5a30e..961be04fd8d 100644
--- a/homeassistant/components/fjaraskupan/__init__.py
+++ b/homeassistant/components/fjaraskupan/__init__.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Callable
-from dataclasses import dataclass
import logging
from fjaraskupan import Device
@@ -16,7 +15,6 @@ from homeassistant.components.bluetooth import (
async_rediscover_address,
async_register_callback,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
@@ -29,7 +27,7 @@ from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DISPATCH_DETECTION, DOMAIN
-from .coordinator import FjaraskupanCoordinator
+from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -42,26 +40,17 @@ PLATFORMS = [
_LOGGER = logging.getLogger(__name__)
-@dataclass
-class EntryState:
- """Store state of config entry."""
-
- coordinators: dict[str, FjaraskupanCoordinator]
-
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: FjaraskupanConfigEntry) -> bool:
"""Set up Fjäråskupan from a config entry."""
- state = EntryState({})
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = state
+ entry.runtime_data = {}
def detection_callback(
service_info: BluetoothServiceInfoBleak, change: BluetoothChange
) -> None:
if change != BluetoothChange.ADVERTISEMENT:
return
- if data := state.coordinators.get(service_info.address):
+ if data := entry.runtime_data.get(service_info.address):
_LOGGER.debug("Update: %s", service_info)
data.detection_callback(service_info)
else:
@@ -80,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
coordinator.detection_callback(service_info)
- state.coordinators[service_info.address] = coordinator
+ entry.runtime_data[service_info.address] = coordinator
async_dispatcher_send(
hass, f"{DISPATCH_DETECTION}.{entry.entry_id}", coordinator
)
@@ -105,16 +94,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@callback
def async_setup_entry_platform(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: FjaraskupanConfigEntry,
async_add_entities: AddEntitiesCallback,
constructor: Callable[[FjaraskupanCoordinator], list[Entity]],
) -> None:
"""Set up a platform with added entities."""
- entry_state: EntryState = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
entity
- for coordinator in entry_state.coordinators.values()
+ for coordinator in entry.runtime_data.values()
for entity in constructor(coordinator)
)
@@ -129,12 +117,12 @@ def async_setup_entry_platform(
)
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: FjaraskupanConfigEntry
+) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
for device_entry in dr.async_entries_for_config_entry(
dr.async_get(hass), entry.entry_id
):
diff --git a/homeassistant/components/fjaraskupan/binary_sensor.py b/homeassistant/components/fjaraskupan/binary_sensor.py
index 93886a2ac6a..7364fa85b2e 100644
--- a/homeassistant/components/fjaraskupan/binary_sensor.py
+++ b/homeassistant/components/fjaraskupan/binary_sensor.py
@@ -12,15 +12,14 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import async_setup_entry_platform
-from .coordinator import FjaraskupanCoordinator
+from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator
@dataclass(frozen=True)
@@ -48,8 +47,8 @@ SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FjaraskupanConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors dynamically through discovery."""
diff --git a/homeassistant/components/fjaraskupan/coordinator.py b/homeassistant/components/fjaraskupan/coordinator.py
index bfea5e5f4fc..7fc4585a722 100644
--- a/homeassistant/components/fjaraskupan/coordinator.py
+++ b/homeassistant/components/fjaraskupan/coordinator.py
@@ -29,6 +29,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN
+type FjaraskupanConfigEntry = ConfigEntry[dict[str, FjaraskupanCoordinator]]
+
_LOGGER = logging.getLogger(__name__)
@@ -65,12 +67,12 @@ class UnableToConnect(HomeAssistantError):
class FjaraskupanCoordinator(DataUpdateCoordinator[State]):
"""Update coordinator for each device."""
- config_entry: ConfigEntry
+ config_entry: FjaraskupanConfigEntry
def __init__(
self,
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: FjaraskupanConfigEntry,
device: Device,
device_info: DeviceInfo,
) -> None:
diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py
index 540a7dd410d..b35bb728131 100644
--- a/homeassistant/components/fjaraskupan/fan.py
+++ b/homeassistant/components/fjaraskupan/fan.py
@@ -13,11 +13,10 @@ from fjaraskupan import (
)
from homeassistant.components.fan import FanEntity, FanEntityFeature
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
@@ -25,7 +24,7 @@ from homeassistant.util.percentage import (
)
from . import async_setup_entry_platform
-from .coordinator import FjaraskupanCoordinator
+from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator
ORDERED_NAMED_FAN_SPEEDS = ["1", "2", "3", "4", "5", "6", "7", "8"]
@@ -51,8 +50,8 @@ class UnsupportedPreset(HomeAssistantError):
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FjaraskupanConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors dynamically through discovery."""
diff --git a/homeassistant/components/fjaraskupan/light.py b/homeassistant/components/fjaraskupan/light.py
index f0083591d4d..c39e3ca4736 100644
--- a/homeassistant/components/fjaraskupan/light.py
+++ b/homeassistant/components/fjaraskupan/light.py
@@ -5,21 +5,20 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import async_setup_entry_platform
-from .coordinator import FjaraskupanCoordinator
+from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FjaraskupanConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tuya sensors dynamically through tuya discovery."""
diff --git a/homeassistant/components/fjaraskupan/number.py b/homeassistant/components/fjaraskupan/number.py
index 1828c4cdea5..93fd31273e9 100644
--- a/homeassistant/components/fjaraskupan/number.py
+++ b/homeassistant/components/fjaraskupan/number.py
@@ -3,22 +3,21 @@
from __future__ import annotations
from homeassistant.components.number import NumberEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import async_setup_entry_platform
-from .coordinator import FjaraskupanCoordinator
+from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FjaraskupanConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number entities dynamically through discovery."""
diff --git a/homeassistant/components/fjaraskupan/sensor.py b/homeassistant/components/fjaraskupan/sensor.py
index 36db4d7ed9f..039feb5913c 100644
--- a/homeassistant/components/fjaraskupan/sensor.py
+++ b/homeassistant/components/fjaraskupan/sensor.py
@@ -9,23 +9,22 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import async_setup_entry_platform
-from .coordinator import FjaraskupanCoordinator
+from .coordinator import FjaraskupanConfigEntry, FjaraskupanCoordinator
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FjaraskupanConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors dynamically through discovery."""
diff --git a/homeassistant/components/flexit_bacnet/__init__.py b/homeassistant/components/flexit_bacnet/__init__.py
index b0ebc5a40fd..01e0051f53f 100644
--- a/homeassistant/components/flexit_bacnet/__init__.py
+++ b/homeassistant/components/flexit_bacnet/__init__.py
@@ -2,12 +2,10 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import FlexitCoordinator
+from .coordinator import FlexitConfigEntry, FlexitCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -18,21 +16,18 @@ PLATFORMS: list[Platform] = [
]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: FlexitConfigEntry) -> bool:
"""Set up Flexit Nordic (BACnet) from a config entry."""
coordinator = FlexitCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: FlexitConfigEntry) -> bool:
"""Unload the Flexit Nordic (BACnet) config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/flexit_bacnet/binary_sensor.py b/homeassistant/components/flexit_bacnet/binary_sensor.py
index 901cc52de47..50c49f45e3e 100644
--- a/homeassistant/components/flexit_bacnet/binary_sensor.py
+++ b/homeassistant/components/flexit_bacnet/binary_sensor.py
@@ -10,12 +10,10 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import FlexitCoordinator
-from .const import DOMAIN
+from .coordinator import FlexitConfigEntry, FlexitCoordinator
from .entity import FlexitEntity
@@ -38,17 +36,21 @@ SENSOR_TYPES: tuple[FlexitBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FlexitConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Flexit (bacnet) binary sensor from a config entry."""
- coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
async_add_entities(
FlexitBinarySensor(coordinator, description) for description in SENSOR_TYPES
)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
class FlexitBinarySensor(FlexitEntity, BinarySensorEntity):
"""Representation of a Flexit binary Sensor."""
diff --git a/homeassistant/components/flexit_bacnet/climate.py b/homeassistant/components/flexit_bacnet/climate.py
index a2291dea9d6..878b63f938f 100644
--- a/homeassistant/components/flexit_bacnet/climate.py
+++ b/homeassistant/components/flexit_bacnet/climate.py
@@ -19,11 +19,10 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
@@ -32,19 +31,20 @@ from .const import (
PRESET_TO_VENTILATION_MODE_MAP,
VENTILATION_TO_PRESET_MODE_MAP,
)
-from .coordinator import FlexitCoordinator
+from .coordinator import FlexitConfigEntry, FlexitCoordinator
from .entity import FlexitEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FlexitConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Flexit Nordic unit."""
- coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ async_add_entities([FlexitClimateEntity(config_entry.runtime_data)])
- async_add_entities([FlexitClimateEntity(coordinator)])
+
+PARALLEL_UPDATES = 1
class FlexitClimateEntity(FlexitEntity, ClimateEntity):
@@ -80,10 +80,6 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
super().__init__(coordinator)
self._attr_unique_id = coordinator.device.serial_number
- async def async_update(self) -> None:
- """Refresh unit state."""
- await self.device.update()
-
@property
def hvac_action(self) -> HVACAction | None:
"""Return current HVAC action."""
@@ -115,7 +111,13 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
else:
await self.device.set_air_temp_setpoint_home(temperature)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
- raise HomeAssistantError from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_temperature",
+ translation_placeholders={
+ "temperature": str(temperature),
+ },
+ ) from exc
finally:
await self.coordinator.async_refresh()
@@ -134,7 +136,13 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
try:
await self.device.set_ventilation_mode(ventilation_mode)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
- raise HomeAssistantError from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_preset_mode",
+ translation_placeholders={
+ "preset": str(ventilation_mode),
+ },
+ ) from exc
finally:
await self.coordinator.async_refresh()
@@ -154,6 +162,12 @@ class FlexitClimateEntity(FlexitEntity, ClimateEntity):
else:
await self.device.set_ventilation_mode(VENTILATION_MODE_HOME)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
- raise HomeAssistantError from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_hvac_mode",
+ translation_placeholders={
+ "mode": str(hvac_mode),
+ },
+ ) from exc
finally:
await self.coordinator.async_refresh()
diff --git a/homeassistant/components/flexit_bacnet/coordinator.py b/homeassistant/components/flexit_bacnet/coordinator.py
index f723117c9ef..9148ec87883 100644
--- a/homeassistant/components/flexit_bacnet/coordinator.py
+++ b/homeassistant/components/flexit_bacnet/coordinator.py
@@ -1,5 +1,7 @@
"""DataUpdateCoordinator for Flexit Nordic (BACnet) integration.."""
+from __future__ import annotations
+
import asyncio.exceptions
from datetime import timedelta
import logging
@@ -17,13 +19,15 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
+type FlexitConfigEntry = ConfigEntry[FlexitCoordinator]
+
class FlexitCoordinator(DataUpdateCoordinator[FlexitBACnet]):
"""Class to manage fetching data from a Flexit Nordic (BACnet) device."""
- config_entry: ConfigEntry
+ config_entry: FlexitConfigEntry
- def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: FlexitConfigEntry) -> None:
"""Initialize my coordinator."""
super().__init__(
hass,
@@ -45,7 +49,11 @@ class FlexitCoordinator(DataUpdateCoordinator[FlexitBACnet]):
await self.device.update()
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
raise ConfigEntryNotReady(
- f"Timeout while connecting to {self.config_entry.data[CONF_IP_ADDRESS]}"
+ translation_domain=DOMAIN,
+ translation_key="not_ready",
+ translation_placeholders={
+ "ip": str(self.config_entry.data[CONF_IP_ADDRESS]),
+ },
) from exc
return self.device
diff --git a/homeassistant/components/flexit_bacnet/icons.json b/homeassistant/components/flexit_bacnet/icons.json
index a0c5ccd5a6e..d03cffab9ad 100644
--- a/homeassistant/components/flexit_bacnet/icons.json
+++ b/homeassistant/components/flexit_bacnet/icons.json
@@ -47,6 +47,12 @@
"state": {
"off": "mdi:fireplace-off"
}
+ },
+ "cooker_hood_mode": {
+ "default": "mdi:kettle-steam",
+ "state": {
+ "off": "mdi:kettle"
+ }
}
}
}
diff --git a/homeassistant/components/flexit_bacnet/manifest.json b/homeassistant/components/flexit_bacnet/manifest.json
index 6f6b094c950..2e94dd2f4c7 100644
--- a/homeassistant/components/flexit_bacnet/manifest.json
+++ b/homeassistant/components/flexit_bacnet/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/flexit_bacnet",
"integration_type": "device",
"iot_class": "local_polling",
+ "quality_scale": "silver",
"requirements": ["flexit_bacnet==2.2.3"]
}
diff --git a/homeassistant/components/flexit_bacnet/number.py b/homeassistant/components/flexit_bacnet/number.py
index 30df5370868..b8c329bd1d4 100644
--- a/homeassistant/components/flexit_bacnet/number.py
+++ b/homeassistant/components/flexit_bacnet/number.py
@@ -13,14 +13,13 @@ from homeassistant.components.number import (
NumberEntityDescription,
NumberMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import FlexitCoordinator
from .const import DOMAIN
+from .coordinator import FlexitConfigEntry, FlexitCoordinator
from .entity import FlexitEntity
_MAX_FAN_SETPOINT = 100
@@ -196,17 +195,20 @@ NUMBERS: tuple[FlexitNumberEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FlexitConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Flexit (bacnet) number from a config entry."""
- coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
async_add_entities(
FlexitNumber(coordinator, description) for description in NUMBERS
)
+PARALLEL_UPDATES = 1
+
+
class FlexitNumber(FlexitEntity, NumberEntity):
"""Representation of a Flexit Number."""
@@ -248,6 +250,12 @@ class FlexitNumber(FlexitEntity, NumberEntity):
try:
await set_native_value_fn(int(value))
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
- raise HomeAssistantError from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_value_error",
+ translation_placeholders={
+ "value": str(value),
+ },
+ ) from exc
finally:
await self.coordinator.async_refresh()
diff --git a/homeassistant/components/flexit_bacnet/quality_scale.yaml b/homeassistant/components/flexit_bacnet/quality_scale.yaml
new file mode 100644
index 00000000000..7a98eda4eb3
--- /dev/null
+++ b/homeassistant/components/flexit_bacnet/quality_scale.yaml
@@ -0,0 +1,91 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ Integration does not define 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: |
+ This integration does not use any actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities don't subscribe to events explicitly
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup:
+ status: done
+ comment: |
+ Done implicitly with `await coordinator.async_config_entry_first_refresh()`.
+ unique-config-entry: done
+ # Silver
+ action-exceptions: done
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ Integration does not use options flow.
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: done
+ comment: |
+ Done implicitly with coordinator.
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: |
+ Done implicitly with coordinator.
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ Integration doesn't require any form of authentication.
+ test-coverage: done
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: todo
+ entity-disabled-by-default: todo
+ discovery: todo
+ stale-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+ diagnostics: todo
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+ discovery-update-info: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This is not applicable for this integration.
+ docs-use-cases: todo
+ docs-supported-devices: todo
+ docs-supported-functions: todo
+ docs-data-update: done
+ docs-known-limitations: todo
+ docs-troubleshooting: todo
+ docs-examples: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: done
diff --git a/homeassistant/components/flexit_bacnet/sensor.py b/homeassistant/components/flexit_bacnet/sensor.py
index be5f12e480e..0506b13892b 100644
--- a/homeassistant/components/flexit_bacnet/sensor.py
+++ b/homeassistant/components/flexit_bacnet/sensor.py
@@ -11,7 +11,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
@@ -20,11 +19,10 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
-from . import FlexitCoordinator
-from .const import DOMAIN
+from .coordinator import FlexitConfigEntry, FlexitCoordinator
from .entity import FlexitEntity
@@ -152,17 +150,21 @@ SENSOR_TYPES: tuple[FlexitSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FlexitConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Flexit (bacnet) sensor from a config entry."""
- coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
async_add_entities(
FlexitSensor(coordinator, description) for description in SENSOR_TYPES
)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
class FlexitSensor(FlexitEntity, SensorEntity):
"""Representation of a Flexit (bacnet) Sensor."""
diff --git a/homeassistant/components/flexit_bacnet/strings.json b/homeassistant/components/flexit_bacnet/strings.json
index 8888b02a3ef..6364d59e4e8 100644
--- a/homeassistant/components/flexit_bacnet/strings.json
+++ b/homeassistant/components/flexit_bacnet/strings.json
@@ -5,6 +5,10 @@
"data": {
"ip_address": "[%key:common::config_flow::data::ip%]",
"device_id": "[%key:common::config_flow::data::device%]"
+ },
+ "data_description": {
+ "ip_address": "The IP address of the Flexit Nordic device",
+ "device_id": "The device ID of the Flexit Nordic device"
}
}
},
@@ -110,7 +114,30 @@
},
"fireplace_mode": {
"name": "Fireplace mode"
+ },
+ "cooker_hood_mode": {
+ "name": "Cooker hood mode"
}
}
+ },
+ "exceptions": {
+ "set_value_error": {
+ "message": "Failed setting the value {value}."
+ },
+ "switch_turn": {
+ "message": "Failed to turn the switch {state}."
+ },
+ "set_preset_mode": {
+ "message": "Failed to set preset mode {preset}."
+ },
+ "set_temperature": {
+ "message": "Failed to set temperature {temperature}."
+ },
+ "set_hvac_mode": {
+ "message": "Failed to set HVAC mode {mode}."
+ },
+ "not_ready": {
+ "message": "Timeout while connecting to {ip}."
+ }
}
}
diff --git a/homeassistant/components/flexit_bacnet/switch.py b/homeassistant/components/flexit_bacnet/switch.py
index 7f12a7524b6..bdeff006181 100644
--- a/homeassistant/components/flexit_bacnet/switch.py
+++ b/homeassistant/components/flexit_bacnet/switch.py
@@ -13,13 +13,12 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import FlexitCoordinator
from .const import DOMAIN
+from .coordinator import FlexitConfigEntry, FlexitCoordinator
from .entity import FlexitEntity
@@ -47,22 +46,32 @@ SWITCHES: tuple[FlexitSwitchEntityDescription, ...] = (
turn_on_fn=lambda data: data.trigger_fireplace_mode(),
turn_off_fn=lambda data: data.trigger_fireplace_mode(),
),
+ FlexitSwitchEntityDescription(
+ key="cooker_hood_mode",
+ translation_key="cooker_hood_mode",
+ is_on_fn=lambda data: data.cooker_hood_status,
+ turn_on_fn=lambda data: data.activate_cooker_hood(),
+ turn_off_fn=lambda data: data.deactivate_cooker_hood(),
+ ),
)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FlexitConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Flexit (bacnet) switch from a config entry."""
- coordinator: FlexitCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
async_add_entities(
FlexitSwitch(coordinator, description) for description in SWITCHES
)
+PARALLEL_UPDATES = 1
+
+
class FlexitSwitch(FlexitEntity, SwitchEntity):
"""Representation of a Flexit Switch."""
@@ -89,19 +98,31 @@ class FlexitSwitch(FlexitEntity, SwitchEntity):
return self.entity_description.is_on_fn(self.coordinator.data)
async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn electric heater on."""
+ """Turn switch on."""
try:
await self.entity_description.turn_on_fn(self.coordinator.data)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
- raise HomeAssistantError from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="switch_turn",
+ translation_placeholders={
+ "state": "on",
+ },
+ ) from exc
finally:
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn electric heater off."""
+ """Turn switch off."""
try:
await self.entity_description.turn_off_fn(self.coordinator.data)
except (asyncio.exceptions.TimeoutError, ConnectionError, DecodingError) as exc:
- raise HomeAssistantError from exc
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="switch_turn",
+ translation_placeholders={
+ "state": "off",
+ },
+ ) from exc
finally:
await self.coordinator.async_refresh()
diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py
index 73b6f8793fb..636d12525ad 100644
--- a/homeassistant/components/flick_electric/sensor.py
+++ b/homeassistant/components/flick_electric/sensor.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import CURRENCY_CENT, UnitOfEnergy
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT
@@ -21,7 +21,7 @@ SCAN_INTERVAL = timedelta(minutes=5)
async def async_setup_entry(
hass: HomeAssistant,
entry: FlickConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Flick Sensor Setup."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py
index 81e61f2554a..4aea43f0bec 100644
--- a/homeassistant/components/flipr/__init__.py
+++ b/homeassistant/components/flipr/__init__.py
@@ -1,6 +1,5 @@
"""The Flipr integration."""
-from collections import Counter
import logging
from flipr_api import FliprAPIRestClient
@@ -8,10 +7,7 @@ from flipr_api import FliprAPIRestClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryError
-from homeassistant.helpers import issue_registry as ir
-from .const import DOMAIN
from .coordinator import (
FliprConfigEntry,
FliprData,
@@ -27,9 +23,6 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: FliprConfigEntry) -> bool:
"""Set up flipr from a config entry."""
- # Detect invalid old config entry and raise error if found
- detect_invalid_old_configuration(hass, entry)
-
config = entry.data
username = config[CONF_EMAIL]
@@ -64,47 +57,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
-
-def detect_invalid_old_configuration(hass: HomeAssistant, entry: ConfigEntry):
- """Detect invalid old configuration and raise error if found."""
-
- def find_duplicate_entries(entries):
- values = [e.data["email"] for e in entries]
- _LOGGER.debug("Detecting duplicates in values : %s", values)
- return any(count > 1 for count in Counter(values).values())
-
- entries = hass.config_entries.async_entries(DOMAIN)
-
- if find_duplicate_entries(entries):
- ir.async_create_issue(
- hass,
- DOMAIN,
- "duplicate_config",
- breaks_in_ha_version="2025.4.0",
- is_fixable=False,
- severity=ir.IssueSeverity.ERROR,
- translation_key="duplicate_config",
- )
-
- raise ConfigEntryError(
- "Duplicate entries found for flipr with the same user email. Please remove one of it manually. Multiple fliprs will be automatically detected after restart."
- )
-
-
-async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Migrate config entry."""
- _LOGGER.debug("Migration of flipr config from version %s", entry.version)
-
- if entry.version == 1:
- # In version 1, we have flipr device as config entry unique id
- # and one device per config entry.
- # We need to migrate to a new config entry that may contain multiple devices.
- # So we change the entry data to match config_flow evolution.
- login = entry.data[CONF_EMAIL]
-
- hass.config_entries.async_update_entry(entry, version=2, unique_id=login)
-
- _LOGGER.debug("Migration of flipr config to version 2 successful")
-
- return True
diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py
index 07357b81af0..899d045ad86 100644
--- a/homeassistant/components/flipr/binary_sensor.py
+++ b/homeassistant/components/flipr/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FliprConfigEntry
from .entity import FliprEntity
@@ -30,7 +30,7 @@ BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FliprConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Defer sensor setup of flipr binary sensors."""
diff --git a/homeassistant/components/flipr/select.py b/homeassistant/components/flipr/select.py
index 79515be6ed4..c10e9c6e91b 100644
--- a/homeassistant/components/flipr/select.py
+++ b/homeassistant/components/flipr/select.py
@@ -4,7 +4,7 @@ import logging
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FliprConfigEntry
from .entity import FliprEntity
@@ -23,7 +23,7 @@ SELECT_TYPES: tuple[SelectEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FliprConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up select for Flipr hub mode."""
coordinators = config_entry.runtime_data.hub_coordinators
diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py
index 2594186f24a..296bcaac68d 100644
--- a/homeassistant/components/flipr/sensor.py
+++ b/homeassistant/components/flipr/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FliprConfigEntry
from .entity import FliprEntity
@@ -57,7 +57,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FliprConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
coordinators = config_entry.runtime_data.flipr_coordinators
diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json
index 631b0ce5488..5c1a55e8b2a 100644
--- a/homeassistant/components/flipr/strings.json
+++ b/homeassistant/components/flipr/strings.json
@@ -14,7 +14,7 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
- "no_flipr_id_found": "No flipr or hub associated to your account for now. You should verify it is working with the Flipr's mobile app first."
+ "no_flipr_id_found": "No Flipr or hub associated to your account for now. You should verify it is working with the Flipr mobile app first."
}
},
"entity": {
@@ -44,17 +44,11 @@
"hub_mode": {
"name": "Mode",
"state": {
- "auto": "Automatic",
- "manual": "Manual",
+ "auto": "[%key:common::state::auto%]",
+ "manual": "[%key:common::state::manual%]",
"planning": "Planning"
}
}
}
- },
- "issues": {
- "duplicate_config": {
- "title": "Multiple flipr configurations with the same account",
- "description": "The Flipr integration has been updated to work account based rather than device based. This means that if you have 2 devices, you only need one configuration. For every account you have, please delete all but one configuration and restart Home Assistant for it to set up the devices linked to your account."
- }
}
}
diff --git a/homeassistant/components/flipr/switch.py b/homeassistant/components/flipr/switch.py
index 03df7f34d12..4db8b54af8a 100644
--- a/homeassistant/components/flipr/switch.py
+++ b/homeassistant/components/flipr/switch.py
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FliprConfigEntry
from .entity import FliprEntity
@@ -23,7 +23,7 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FliprConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch for Flipr hub."""
coordinators = config_entry.runtime_data.hub_coordinators
diff --git a/homeassistant/components/flo/__init__.py b/homeassistant/components/flo/__init__.py
index 6a497f5140d..88824b041e7 100644
--- a/homeassistant/components/flo/__init__.py
+++ b/homeassistant/components/flo/__init__.py
@@ -6,27 +6,23 @@ import logging
from aioflo import async_get_api
from aioflo.errors import RequestError
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import CLIENT, DOMAIN
-from .coordinator import FloDeviceDataUpdateCoordinator
+from .coordinator import FloConfigEntry, FloDeviceDataUpdateCoordinator, FloRuntimeData
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: FloConfigEntry) -> bool:
"""Set up flo from a config entry."""
session = async_get_clientsession(hass)
- hass.data.setdefault(DOMAIN, {})
- hass.data[DOMAIN][entry.entry_id] = {}
try:
- hass.data[DOMAIN][entry.entry_id][CLIENT] = client = await async_get_api(
+ client = await async_get_api(
entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session
)
except RequestError as err:
@@ -36,7 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug("Flo user information with locations: %s", user_info)
- hass.data[DOMAIN][entry.entry_id]["devices"] = devices = [
+ devices = [
FloDeviceDataUpdateCoordinator(
hass, entry, client, location["id"], device["id"]
)
@@ -47,14 +43,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
tasks = [device.async_refresh() for device in devices]
await asyncio.gather(*tasks)
+ entry.runtime_data = FloRuntimeData(client=client, devices=devices)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: FloConfigEntry) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/flo/binary_sensor.py b/homeassistant/components/flo/binary_sensor.py
index 20f5d7822d2..89f317fd3c6 100644
--- a/homeassistant/components/flo/binary_sensor.py
+++ b/homeassistant/components/flo/binary_sensor.py
@@ -6,24 +6,20 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN as FLO_DOMAIN
-from .coordinator import FloDeviceDataUpdateCoordinator
+from .coordinator import FloConfigEntry
from .entity import FloEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FloConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Flo sensors from config entry."""
- devices: list[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN][
- config_entry.entry_id
- ]["devices"]
+ devices = config_entry.runtime_data.devices
entities: list[BinarySensorEntity] = []
for device in devices:
if device.device_type == "puck_oem":
diff --git a/homeassistant/components/flo/const.py b/homeassistant/components/flo/const.py
index 9eb00ebfa62..5b1d926d9f4 100644
--- a/homeassistant/components/flo/const.py
+++ b/homeassistant/components/flo/const.py
@@ -4,7 +4,6 @@ import logging
LOGGER = logging.getLogger(__package__)
-CLIENT = "client"
DOMAIN = "flo"
FLO_HOME = "home"
FLO_AWAY = "away"
diff --git a/homeassistant/components/flo/coordinator.py b/homeassistant/components/flo/coordinator.py
index f5dc34a50cd..9f540b230f4 100644
--- a/homeassistant/components/flo/coordinator.py
+++ b/homeassistant/components/flo/coordinator.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
+from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
@@ -17,6 +18,16 @@ from homeassistant.util import dt as dt_util
from .const import DOMAIN as FLO_DOMAIN, LOGGER
+type FloConfigEntry = ConfigEntry[FloRuntimeData]
+
+
+@dataclass
+class FloRuntimeData:
+ """Flo runtime data."""
+
+ client: API
+ devices: list[FloDeviceDataUpdateCoordinator]
+
class FloDeviceDataUpdateCoordinator(DataUpdateCoordinator):
"""Flo device object."""
diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py
index b0cf8d04313..072afbae4f2 100644
--- a/homeassistant/components/flo/entity.py
+++ b/homeassistant/components/flo/entity.py
@@ -45,10 +45,10 @@ class FloEntity(Entity):
"""Return True if device is available."""
return self._device.available
- async def async_update(self):
+ async def async_update(self) -> None:
"""Update Flo entity."""
await self._device.async_request_refresh()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
self.async_on_remove(self._device.async_add_listener(self.async_write_ha_state))
diff --git a/homeassistant/components/flo/sensor.py b/homeassistant/components/flo/sensor.py
index 7419b0a1c3b..ca763839b87 100644
--- a/homeassistant/components/flo/sensor.py
+++ b/homeassistant/components/flo/sensor.py
@@ -7,7 +7,6 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
UnitOfPressure,
@@ -16,22 +15,19 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN as FLO_DOMAIN
-from .coordinator import FloDeviceDataUpdateCoordinator
+from .coordinator import FloConfigEntry
from .entity import FloEntity
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FloConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Flo sensors from config entry."""
- devices: list[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN][
- config_entry.entry_id
- ]["devices"]
+ devices = config_entry.runtime_data.devices
entities = []
for device in devices:
if device.device_type == "puck_oem":
diff --git a/homeassistant/components/flo/strings.json b/homeassistant/components/flo/strings.json
index 3444911fbd4..64e22bedec3 100644
--- a/homeassistant/components/flo/strings.json
+++ b/homeassistant/components/flo/strings.json
@@ -60,11 +60,11 @@
"fields": {
"sleep_minutes": {
"name": "Sleep minutes",
- "description": "The time to sleep in minutes."
+ "description": "The duration to sleep in minutes."
},
"revert_to_mode": {
"name": "Revert to mode",
- "description": "The mode to revert to after sleep_minutes has elapsed."
+ "description": "The mode to revert to after the 'Sleep minutes' duration has elapsed."
}
}
},
@@ -78,7 +78,7 @@
},
"run_health_test": {
"name": "Run health test",
- "description": "Have the Flo device run a health test."
+ "description": "Requests the Flo device to run a health test."
}
}
}
diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py
index f0460839837..12e242db5c8 100644
--- a/homeassistant/components/flo/switch.py
+++ b/homeassistant/components/flo/switch.py
@@ -8,13 +8,11 @@ from aioflo.location import SLEEP_MINUTE_OPTIONS, SYSTEM_MODE_HOME, SYSTEM_REVER
import voluptuous as vol
from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN as FLO_DOMAIN
-from .coordinator import FloDeviceDataUpdateCoordinator
+from .coordinator import FloConfigEntry, FloDeviceDataUpdateCoordinator
from .entity import FloEntity
ATTR_REVERT_TO_MODE = "revert_to_mode"
@@ -27,13 +25,11 @@ SERVICE_RUN_HEALTH_TEST = "run_health_test"
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: FloConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Flo switches from config entry."""
- devices: list[FloDeviceDataUpdateCoordinator] = hass.data[FLO_DOMAIN][
- config_entry.entry_id
- ]["devices"]
+ devices = config_entry.runtime_data.devices
async_add_entities(
[FloSwitch(device) for device in devices if device.device_type != "puck_oem"]
diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py
index cb0add90443..2c2dc285036 100644
--- a/homeassistant/components/flume/binary_sensor.py
+++ b/homeassistant/components/flume/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
FLUME_TYPE_BRIDGE,
@@ -69,7 +69,7 @@ FLUME_BINARY_NOTIFICATION_SENSORS: tuple[FlumeBinarySensorEntityDescription, ...
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FlumeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Flume binary sensor.."""
flume_domain_data = config_entry.runtime_data
diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py
index aea0aa60093..0f0213ec984 100644
--- a/homeassistant/components/flume/sensor.py
+++ b/homeassistant/components/flume/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfVolume
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
@@ -108,7 +108,7 @@ def make_flume_datas(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FlumeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Flume sensor."""
diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py
index 7597a7c9c9a..7515b6b8dfc 100644
--- a/homeassistant/components/flux_led/__init__.py
+++ b/homeassistant/components/flux_led/__init__.py
@@ -11,7 +11,6 @@ from flux_led.aio import AIOWifiLedBulb
from flux_led.const import ATTR_ID, WhiteChannelType
from flux_led.scanner import FluxLEDDiscovery
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
@@ -39,7 +38,7 @@ from .const import (
FLUX_LED_EXCEPTIONS,
SIGNAL_STATE_UPDATED,
)
-from .coordinator import FluxLedUpdateCoordinator
+from .coordinator import FluxLedConfigEntry, FluxLedUpdateCoordinator
from .discovery import (
async_build_cached_discovery,
async_clear_discovery_cache,
@@ -113,7 +112,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
-async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def _async_migrate_unique_ids(
+ hass: HomeAssistant, entry: FluxLedConfigEntry
+) -> None:
"""Migrate entities when the mac address gets discovered."""
@callback
@@ -146,14 +147,16 @@ async def _async_migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) ->
await er.async_migrate_entries(hass, entry.entry_id, _async_migrator)
-async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def _async_update_listener(
+ hass: HomeAssistant, entry: FluxLedConfigEntry
+) -> None:
"""Handle options update."""
- coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
if entry.title != coordinator.title:
await hass.config_entries.async_reload(entry.entry_id)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: FluxLedConfigEntry) -> bool:
"""Set up Flux LED/MagicLight from a config entry."""
host = entry.data[CONF_HOST]
discovery_cached = True
@@ -206,7 +209,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await _async_migrate_unique_ids(hass, entry)
coordinator = FluxLedUpdateCoordinator(hass, device, entry)
- hass.data[DOMAIN][entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
platforms = PLATFORMS_BY_TYPE[device.device_type]
await hass.config_entries.async_forward_entry_setups(entry, platforms)
@@ -239,13 +242,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: FluxLedConfigEntry) -> bool:
"""Unload a config entry."""
- device: AIOWifiLedBulb = hass.data[DOMAIN][entry.entry_id].device
+ device = entry.runtime_data.device
platforms = PLATFORMS_BY_TYPE[device.device_type]
if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms):
# Make sure we probe the device again in case something has changed externally
async_clear_discovery_cache(hass, entry.data[CONF_HOST])
- del hass.data[DOMAIN][entry.entry_id]
await device.async_stop()
return unload_ok
diff --git a/homeassistant/components/flux_led/button.py b/homeassistant/components/flux_led/button.py
index 90918a55bb2..c4a7ff6569c 100644
--- a/homeassistant/components/flux_led/button.py
+++ b/homeassistant/components/flux_led/button.py
@@ -5,7 +5,6 @@ from __future__ import annotations
from flux_led.aio import AIOWifiLedBulb
from flux_led.protocol import RemoteConfig
-from homeassistant import config_entries
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
@@ -13,10 +12,9 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import FluxLedUpdateCoordinator
+from .coordinator import FluxLedConfigEntry
from .entity import FluxBaseEntity
_RESTART_KEY = "restart"
@@ -34,11 +32,11 @@ UNPAIR_REMOTES_DESCRIPTION = ButtonEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
- entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: FluxLedConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Magic Home button based on a config entry."""
- coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
device = coordinator.device
entities: list[FluxButton] = [
FluxButton(coordinator.device, entry, RESTART_BUTTON_DESCRIPTION)
@@ -59,7 +57,7 @@ class FluxButton(FluxBaseEntity, ButtonEntity):
def __init__(
self,
device: AIOWifiLedBulb,
- entry: config_entries.ConfigEntry,
+ entry: FluxLedConfigEntry,
description: ButtonEntityDescription,
) -> None:
"""Initialize the button."""
diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py
index 035be5b115c..754ed0525b9 100644
--- a/homeassistant/components/flux_led/config_flow.py
+++ b/homeassistant/components/flux_led/config_flow.py
@@ -18,7 +18,6 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_IGNORE,
- ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
@@ -46,6 +45,7 @@ from .const import (
TRANSITION_JUMP,
TRANSITION_STROBE,
)
+from .coordinator import FluxLedConfigEntry
from .discovery import (
async_discover_device,
async_discover_devices,
@@ -72,7 +72,7 @@ class FluxLedConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: FluxLedConfigEntry,
) -> FluxLedOptionsFlow:
"""Get the options flow for the Flux LED component."""
return FluxLedOptionsFlow()
diff --git a/homeassistant/components/flux_led/coordinator.py b/homeassistant/components/flux_led/coordinator.py
index a879d894bcc..78d8bb947fd 100644
--- a/homeassistant/components/flux_led/coordinator.py
+++ b/homeassistant/components/flux_led/coordinator.py
@@ -20,14 +20,16 @@ _LOGGER = logging.getLogger(__name__)
REQUEST_REFRESH_DELAY: Final = 2.0
+type FluxLedConfigEntry = ConfigEntry[FluxLedUpdateCoordinator]
+
class FluxLedUpdateCoordinator(DataUpdateCoordinator[None]):
"""DataUpdateCoordinator to gather data for a specific flux_led device."""
- config_entry: ConfigEntry
+ config_entry: FluxLedConfigEntry
def __init__(
- self, hass: HomeAssistant, device: AIOWifiLedBulb, entry: ConfigEntry
+ self, hass: HomeAssistant, device: AIOWifiLedBulb, entry: FluxLedConfigEntry
) -> None:
"""Initialize DataUpdateCoordinator to gather data for specific device."""
self.device = device
diff --git a/homeassistant/components/flux_led/diagnostics.py b/homeassistant/components/flux_led/diagnostics.py
index e24c1aff9a4..683aa362377 100644
--- a/homeassistant/components/flux_led/diagnostics.py
+++ b/homeassistant/components/flux_led/diagnostics.py
@@ -4,22 +4,19 @@ from __future__ import annotations
from typing import Any
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import FluxLedUpdateCoordinator
+from .coordinator import FluxLedConfigEntry
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: FluxLedConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
return {
"entry": {
"title": entry.title,
"data": dict(entry.data),
},
- "data": coordinator.device.diagnostics,
+ "data": entry.runtime_data.device.diagnostics,
}
diff --git a/homeassistant/components/flux_led/discovery.py b/homeassistant/components/flux_led/discovery.py
index d55f560193f..c3a3c5df3a7 100644
--- a/homeassistant/components/flux_led/discovery.py
+++ b/homeassistant/components/flux_led/discovery.py
@@ -23,9 +23,8 @@ from flux_led.const import (
from flux_led.models_db import get_model_description
from flux_led.scanner import FluxLEDDiscovery
-from homeassistant import config_entries
from homeassistant.components import network
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, discovery_flow
@@ -44,6 +43,7 @@ from .const import (
DOMAIN,
FLUX_LED_DISCOVERY,
)
+from .coordinator import FluxLedConfigEntry
from .util import format_as_flux_mac, mac_matches_by_one
_LOGGER = logging.getLogger(__name__)
@@ -63,7 +63,7 @@ CONF_TO_DISCOVERY: Final = {
@callback
-def async_build_cached_discovery(entry: ConfigEntry) -> FluxLEDDiscovery:
+def async_build_cached_discovery(entry: FluxLedConfigEntry) -> FluxLEDDiscovery:
"""When discovery is unavailable, load it from the config entry."""
data = entry.data
return FluxLEDDiscovery(
@@ -116,7 +116,7 @@ def async_populate_data_from_discovery(
@callback
def async_update_entry_from_discovery(
hass: HomeAssistant,
- entry: config_entries.ConfigEntry,
+ entry: FluxLedConfigEntry,
device: FluxLEDDiscovery,
model_num: int | None,
allow_update_mac: bool,
@@ -230,6 +230,6 @@ def async_trigger_discovery(
discovery_flow.async_create_flow(
hass,
DOMAIN,
- context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY},
+ context={"source": SOURCE_INTEGRATION_DISCOVERY},
data={**device},
)
diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py
index 2a0b5795970..79dae33a2a5 100644
--- a/homeassistant/components/flux_led/light.py
+++ b/homeassistant/components/flux_led/light.py
@@ -11,7 +11,6 @@ from flux_led.protocol import MusicMode
from flux_led.utils import rgbcw_brightness, rgbcw_to_rgbwc, rgbw_brightness
import voluptuous as vol
-from homeassistant import config_entries
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
@@ -26,7 +25,7 @@ from homeassistant.components.light import (
from homeassistant.const import CONF_EFFECT
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -38,7 +37,6 @@ from .const import (
CONF_SPEED_PCT,
CONF_TRANSITION,
DEFAULT_EFFECT_SPEED,
- DOMAIN,
MIN_CCT_BRIGHTNESS,
MIN_RGB_BRIGHTNESS,
MULTI_BRIGHTNESS_COLOR_MODES,
@@ -46,7 +44,7 @@ from .const import (
TRANSITION_JUMP,
TRANSITION_STROBE,
)
-from .coordinator import FluxLedUpdateCoordinator
+from .coordinator import FluxLedConfigEntry, FluxLedUpdateCoordinator
from .entity import FluxOnOffEntity
from .util import (
_effect_brightness,
@@ -134,11 +132,11 @@ SET_ZONES_DICT: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
- entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: FluxLedConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Flux lights."""
- coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json
index fcb16c9742b..2c5e1b3839e 100644
--- a/homeassistant/components/flux_led/manifest.json
+++ b/homeassistant/components/flux_led/manifest.json
@@ -53,5 +53,5 @@
"documentation": "https://www.home-assistant.io/integrations/flux_led",
"iot_class": "local_push",
"loggers": ["flux_led"],
- "requirements": ["flux-led==1.1.3"]
+ "requirements": ["flux-led==1.2.0"]
}
diff --git a/homeassistant/components/flux_led/number.py b/homeassistant/components/flux_led/number.py
index 93687c0c579..edf6b8c9654 100644
--- a/homeassistant/components/flux_led/number.py
+++ b/homeassistant/components/flux_led/number.py
@@ -16,18 +16,16 @@ from flux_led.protocol import (
SEGMENTS_MAX,
)
-from homeassistant import config_entries
from homeassistant.components.light import EFFECT_RANDOM
from homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.debounce import Debouncer
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
-from .coordinator import FluxLedUpdateCoordinator
+from .coordinator import FluxLedConfigEntry, FluxLedUpdateCoordinator
from .entity import FluxEntity
from .util import _effect_brightness
@@ -38,11 +36,11 @@ DEBOUNCE_TIME = 1
async def async_setup_entry(
hass: HomeAssistant,
- entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: FluxLedConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Flux lights."""
- coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
device = coordinator.device
entities: list[
FluxSpeedNumber
diff --git a/homeassistant/components/flux_led/select.py b/homeassistant/components/flux_led/select.py
index 33329ebb3f3..bcb44c995b8 100644
--- a/homeassistant/components/flux_led/select.py
+++ b/homeassistant/components/flux_led/select.py
@@ -13,14 +13,13 @@ from flux_led.const import (
)
from flux_led.protocol import PowerRestoreState, RemoteConfig
-from homeassistant import config_entries
from homeassistant.components.select import SelectEntity
from homeassistant.const import CONF_NAME, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import CONF_WHITE_CHANNEL_TYPE, DOMAIN, FLUX_COLOR_MODE_RGBW
-from .coordinator import FluxLedUpdateCoordinator
+from .const import CONF_WHITE_CHANNEL_TYPE, FLUX_COLOR_MODE_RGBW
+from .coordinator import FluxLedConfigEntry, FluxLedUpdateCoordinator
from .entity import FluxBaseEntity, FluxEntity
from .util import _human_readable_option
@@ -29,9 +28,7 @@ NAME_TO_POWER_RESTORE_STATE = {
}
-async def _async_delayed_reload(
- hass: HomeAssistant, entry: config_entries.ConfigEntry
-) -> None:
+async def _async_delayed_reload(hass: HomeAssistant, entry: FluxLedConfigEntry) -> None:
"""Reload after making a change that will effect the operation of the device."""
await asyncio.sleep(STATE_CHANGE_LATENCY)
hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
@@ -39,11 +36,11 @@ async def _async_delayed_reload(
async def async_setup_entry(
hass: HomeAssistant,
- entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: FluxLedConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Flux selects."""
- coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
device = coordinator.device
entities: list[
FluxPowerStateSelect
@@ -97,7 +94,7 @@ class FluxPowerStateSelect(FluxConfigAtStartSelect, SelectEntity):
def __init__(
self,
device: AIOWifiLedBulb,
- entry: config_entries.ConfigEntry,
+ entry: FluxLedConfigEntry,
) -> None:
"""Initialize the power state select."""
super().__init__(device, entry)
@@ -228,7 +225,7 @@ class FluxWhiteChannelSelect(FluxConfigAtStartSelect):
def __init__(
self,
device: AIOWifiLedBulb,
- entry: config_entries.ConfigEntry,
+ entry: FluxLedConfigEntry,
) -> None:
"""Initialize the white channel select."""
super().__init__(device, entry)
diff --git a/homeassistant/components/flux_led/sensor.py b/homeassistant/components/flux_led/sensor.py
index 5a6633669ae..ad4b9bacbbe 100644
--- a/homeassistant/components/flux_led/sensor.py
+++ b/homeassistant/components/flux_led/sensor.py
@@ -2,24 +2,22 @@
from __future__ import annotations
-from homeassistant import config_entries
from homeassistant.components.sensor import SensorEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import FluxLedUpdateCoordinator
+from .coordinator import FluxLedConfigEntry
from .entity import FluxEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: FluxLedConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Magic Home sensors."""
- coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
if coordinator.device.paired_remotes is not None:
async_add_entities(
[
diff --git a/homeassistant/components/flux_led/switch.py b/homeassistant/components/flux_led/switch.py
index 3adcd9a9da9..5dea5408c84 100644
--- a/homeassistant/components/flux_led/switch.py
+++ b/homeassistant/components/flux_led/switch.py
@@ -8,31 +8,29 @@ from flux_led import DeviceType
from flux_led.aio import AIOWifiLedBulb
from flux_led.const import MODE_MUSIC
-from homeassistant import config_entries
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
CONF_REMOTE_ACCESS_ENABLED,
CONF_REMOTE_ACCESS_HOST,
CONF_REMOTE_ACCESS_PORT,
- DOMAIN,
)
-from .coordinator import FluxLedUpdateCoordinator
+from .coordinator import FluxLedConfigEntry, FluxLedUpdateCoordinator
from .discovery import async_clear_discovery_cache
from .entity import FluxBaseEntity, FluxEntity, FluxOnOffEntity
async def async_setup_entry(
hass: HomeAssistant,
- entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: FluxLedConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Flux lights."""
- coordinator: FluxLedUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
entities: list[FluxSwitch | FluxRemoteAccessSwitch | FluxMusicSwitch] = []
base_unique_id = entry.unique_id or entry.entry_id
@@ -70,7 +68,7 @@ class FluxRemoteAccessSwitch(FluxBaseEntity, SwitchEntity):
def __init__(
self,
device: AIOWifiLedBulb,
- entry: config_entries.ConfigEntry,
+ entry: FluxLedConfigEntry,
) -> None:
"""Initialize the light."""
super().__init__(device, entry)
diff --git a/homeassistant/components/folder_watcher/event.py b/homeassistant/components/folder_watcher/event.py
index 7158930e116..472599c4ead 100644
--- a/homeassistant/components/folder_watcher/event.py
+++ b/homeassistant/components/folder_watcher/event.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -25,7 +25,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Folder Watcher event."""
diff --git a/homeassistant/components/folder_watcher/strings.json b/homeassistant/components/folder_watcher/strings.json
index da1e3c1962a..5b1f72bf254 100644
--- a/homeassistant/components/folder_watcher/strings.json
+++ b/homeassistant/components/folder_watcher/strings.json
@@ -36,11 +36,11 @@
"issues": {
"import_failed_not_allowed_path": {
"title": "The Folder Watcher YAML configuration could not be imported",
- "description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in config.yaml and restart Home Assistant to import it and fix this issue."
+ "description": "Configuring Folder Watcher using YAML is being removed but your configuration could not be imported as the folder {path} is not in the configured allowlist.\n\nPlease add it to `{config_variable}` in configuration.yaml and restart Home Assistant to import it and fix this issue."
},
"setup_not_allowed_path": {
"title": "The Folder Watcher configuration for {path} could not start",
- "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in config.yaml and restart Home Assistant to fix this issue."
+ "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in configuration.yaml and restart Home Assistant to fix this issue."
}
},
"entity": {
diff --git a/homeassistant/components/forecast_solar/manifest.json b/homeassistant/components/forecast_solar/manifest.json
index 1eb9c98701d..769bda56adc 100644
--- a/homeassistant/components/forecast_solar/manifest.json
+++ b/homeassistant/components/forecast_solar/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/forecast_solar",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["forecast-solar==4.0.0"]
+ "requirements": ["forecast-solar==4.1.0"]
}
diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py
index c1fa971a89d..13a4d5c2d23 100644
--- a/homeassistant/components/forecast_solar/sensor.py
+++ b/homeassistant/components/forecast_solar/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -135,7 +135,7 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ForecastSolarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/forked_daapd/__init__.py b/homeassistant/components/forked_daapd/__init__.py
index 2172e60ba38..844a6a3eff9 100644
--- a/homeassistant/components/forked_daapd/__init__.py
+++ b/homeassistant/components/forked_daapd/__init__.py
@@ -1,29 +1,36 @@
"""The forked_daapd component."""
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
-from homeassistant.core import HomeAssistant
+from pyforked_daapd import ForkedDaapdAPI
-from .const import DOMAIN, HASS_DATA_UPDATER_KEY
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .coordinator import ForkedDaapdConfigEntry, ForkedDaapdUpdater
PLATFORMS = [Platform.MEDIA_PLAYER]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: ForkedDaapdConfigEntry) -> bool:
"""Set up forked-daapd from a config entry by forwarding to platform."""
+ host: str = entry.data[CONF_HOST]
+ port: int = entry.data[CONF_PORT]
+ password: str = entry.data[CONF_PASSWORD]
+ forked_daapd_api = ForkedDaapdAPI(
+ async_get_clientsession(hass), host, port, password
+ )
+ forked_daapd_updater = ForkedDaapdUpdater(hass, forked_daapd_api, entry.entry_id)
+ entry.runtime_data = forked_daapd_updater
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: ForkedDaapdConfigEntry
+) -> bool:
"""Remove forked-daapd component."""
status = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if status and hass.data.get(DOMAIN) and hass.data[DOMAIN].get(entry.entry_id):
- if websocket_handler := hass.data[DOMAIN][entry.entry_id][
- HASS_DATA_UPDATER_KEY
- ].websocket_handler:
+ if status:
+ if websocket_handler := entry.runtime_data.websocket_handler:
websocket_handler.cancel()
- del hass.data[DOMAIN][entry.entry_id]
- if not hass.data[DOMAIN]:
- del hass.data[DOMAIN]
return status
diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py
index b2b2d498f60..890976c7503 100644
--- a/homeassistant/components/forked_daapd/config_flow.py
+++ b/homeassistant/components/forked_daapd/config_flow.py
@@ -7,12 +7,7 @@ from typing import Any
from pyforked_daapd import ForkedDaapdAPI
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -28,6 +23,7 @@ from .const import (
DEFAULT_TTS_VOLUME,
DOMAIN,
)
+from .coordinator import ForkedDaapdConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -115,7 +111,7 @@ class ForkedDaapdFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: ForkedDaapdConfigEntry,
) -> ForkedDaapdOptionsFlowHandler:
"""Return options flow handler."""
return ForkedDaapdOptionsFlowHandler()
diff --git a/homeassistant/components/forked_daapd/const.py b/homeassistant/components/forked_daapd/const.py
index dd7ed1bdf16..effd4c9454c 100644
--- a/homeassistant/components/forked_daapd/const.py
+++ b/homeassistant/components/forked_daapd/const.py
@@ -30,9 +30,8 @@ DEFAULT_SERVER_NAME = "My Server"
DEFAULT_TTS_PAUSE_TIME = 1.2
DEFAULT_TTS_VOLUME = 0.8
DEFAULT_UNMUTE_VOLUME = 0.6
-DOMAIN = "forked_daapd" # key for hass.data
+DOMAIN = "forked_daapd"
FD_NAME = "OwnTone"
-HASS_DATA_UPDATER_KEY = "UPDATER"
KNOWN_PIPES = {"librespot-java"}
PIPE_FUNCTION_MAP = {
"librespot-java": {
diff --git a/homeassistant/components/forked_daapd/coordinator.py b/homeassistant/components/forked_daapd/coordinator.py
index 7a03a9075ed..0ba339be505 100644
--- a/homeassistant/components/forked_daapd/coordinator.py
+++ b/homeassistant/components/forked_daapd/coordinator.py
@@ -3,8 +3,14 @@
from __future__ import annotations
import asyncio
+from collections.abc import Sequence
import logging
+from typing import Any
+from pyforked_daapd import ForkedDaapdAPI
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -17,6 +23,8 @@ from .const import (
SIGNAL_UPDATE_QUEUE,
)
+type ForkedDaapdConfigEntry = ConfigEntry[ForkedDaapdUpdater]
+
_LOGGER = logging.getLogger(__name__)
WS_NOTIFY_EVENT_TYPES = ["player", "outputs", "volume", "options", "queue", "database"]
@@ -26,15 +34,20 @@ WEBSOCKET_RECONNECT_TIME = 30 # seconds
class ForkedDaapdUpdater:
"""Manage updates for the forked-daapd device."""
- def __init__(self, hass, api, entry_id):
+ def __init__(self, hass: HomeAssistant, api: ForkedDaapdAPI, entry_id: str) -> None:
"""Initialize."""
self.hass = hass
self._api = api
- self.websocket_handler = None
- self._all_output_ids = set()
+ self.websocket_handler: asyncio.Task[None] | None = None
+ self._all_output_ids: set[str] = set()
self._entry_id = entry_id
- async def async_init(self):
+ @property
+ def api(self) -> ForkedDaapdAPI:
+ """Return the API object."""
+ return self._api
+
+ async def async_init(self) -> None:
"""Perform async portion of class initialization."""
if not (server_config := await self._api.get_request("config")):
raise PlatformNotReady
@@ -51,7 +64,7 @@ class ForkedDaapdUpdater:
else:
_LOGGER.error("Invalid websocket port")
- async def _disconnected_callback(self):
+ async def _disconnected_callback(self) -> None:
"""Send update signals when the websocket gets disconnected."""
async_dispatcher_send(
self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), False
@@ -60,9 +73,9 @@ class ForkedDaapdUpdater:
self.hass, SIGNAL_UPDATE_OUTPUTS.format(self._entry_id), []
)
- async def _update(self, update_types):
+ async def _update(self, update_types_sequence: Sequence[str]) -> None:
"""Private update method."""
- update_types = set(update_types)
+ update_types = set(update_types_sequence)
update_events = {}
_LOGGER.debug("Updating %s", update_types)
if (
@@ -127,8 +140,8 @@ class ForkedDaapdUpdater:
self.hass, SIGNAL_UPDATE_MASTER.format(self._entry_id), True
)
- def _add_zones(self, outputs):
- outputs_to_add = []
+ def _add_zones(self, outputs: list[dict[str, Any]]) -> None:
+ outputs_to_add: list[dict[str, Any]] = []
for output in outputs:
if output["id"] not in self._all_output_ids:
self._all_output_ids.add(output["id"])
diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py
index 8e61df3de45..fd5390195a6 100644
--- a/homeassistant/components/forked_daapd/media_player.py
+++ b/homeassistant/components/forked_daapd/media_player.py
@@ -7,7 +7,6 @@ from collections import defaultdict
import logging
from typing import Any
-from pyforked_daapd import ForkedDaapdAPI
from pylibrespot_java import LibrespotJavaAPI
from homeassistant.components import media_source
@@ -28,15 +27,14 @@ from homeassistant.components.spotify import (
resolve_spotify_media_type,
spotify_uri_from_media_browser_url,
)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
+from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from .browse_media import (
@@ -55,9 +53,7 @@ from .const import (
DEFAULT_TTS_PAUSE_TIME,
DEFAULT_TTS_VOLUME,
DEFAULT_UNMUTE_VOLUME,
- DOMAIN,
FD_NAME,
- HASS_DATA_UPDATER_KEY,
KNOWN_PIPES,
PIPE_FUNCTION_MAP,
SIGNAL_ADD_ZONES,
@@ -74,29 +70,25 @@ from .const import (
SUPPORTED_FEATURES_ZONE,
TTS_TIMEOUT,
)
-from .coordinator import ForkedDaapdUpdater
+from .coordinator import ForkedDaapdConfigEntry
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: ForkedDaapdConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up forked-daapd from a config entry."""
- host = config_entry.data[CONF_HOST]
- port = config_entry.data[CONF_PORT]
- password = config_entry.data[CONF_PASSWORD]
- forked_daapd_api = ForkedDaapdAPI(
- async_get_clientsession(hass), host, port, password
- )
+ forked_daapd_updater = config_entry.runtime_data
+
+ host: str = config_entry.data[CONF_HOST]
+ forked_daapd_api = forked_daapd_updater.api
forked_daapd_master = ForkedDaapdMaster(
clientsession=async_get_clientsession(hass),
api=forked_daapd_api,
ip_address=host,
- api_port=port,
- api_password=password,
config_entry=config_entry,
)
@@ -113,20 +105,12 @@ async def async_setup_entry(
)
config_entry.async_on_unload(config_entry.add_update_listener(update_listener))
- if not hass.data.get(DOMAIN):
- hass.data[DOMAIN] = {config_entry.entry_id: {}}
-
async_add_entities([forked_daapd_master], False)
- forked_daapd_updater = ForkedDaapdUpdater(
- hass, forked_daapd_api, config_entry.entry_id
- )
- hass.data[DOMAIN][config_entry.entry_id][HASS_DATA_UPDATER_KEY] = (
- forked_daapd_updater
- )
+
await forked_daapd_updater.async_init()
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def update_listener(hass: HomeAssistant, entry: ForkedDaapdConfigEntry) -> None:
"""Handle options update."""
async_dispatcher_send(
hass, SIGNAL_CONFIG_OPTIONS_UPDATE.format(entry.entry_id), entry.options
@@ -240,9 +224,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
_attr_should_poll = False
- def __init__(
- self, clientsession, api, ip_address, api_port, api_password, config_entry
- ):
+ def __init__(self, clientsession, api, ip_address, config_entry):
"""Initialize the ForkedDaapd Master Device."""
# Leave the api public so the browse media helpers can use it
self.api = api
@@ -269,7 +251,7 @@ class ForkedDaapdMaster(MediaPlayerEntity):
self._on_remove = None
self._available = False
self._clientsession = clientsession
- self._config_entry = config_entry
+ self._entry_id = config_entry.entry_id
self.update_options(config_entry.options)
self._paused_event = asyncio.Event()
self._pause_requested = False
@@ -282,42 +264,42 @@ class ForkedDaapdMaster(MediaPlayerEntity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_PLAYER.format(self._config_entry.entry_id),
+ SIGNAL_UPDATE_PLAYER.format(self._entry_id),
self._update_player,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_QUEUE.format(self._config_entry.entry_id),
+ SIGNAL_UPDATE_QUEUE.format(self._entry_id),
self._update_queue,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_OUTPUTS.format(self._config_entry.entry_id),
+ SIGNAL_UPDATE_OUTPUTS.format(self._entry_id),
self._update_outputs,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_MASTER.format(self._config_entry.entry_id),
+ SIGNAL_UPDATE_MASTER.format(self._entry_id),
self._update_callback,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_CONFIG_OPTIONS_UPDATE.format(self._config_entry.entry_id),
+ SIGNAL_CONFIG_OPTIONS_UPDATE.format(self._entry_id),
self.update_options,
)
)
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- SIGNAL_UPDATE_DATABASE.format(self._config_entry.entry_id),
+ SIGNAL_UPDATE_DATABASE.format(self._entry_id),
self._update_database,
)
)
@@ -411,9 +393,9 @@ class ForkedDaapdMaster(MediaPlayerEntity):
self._track_info = defaultdict(str)
@property
- def unique_id(self):
+ def unique_id(self) -> str:
"""Return unique ID."""
- return self._config_entry.entry_id
+ return self._entry_id
@property
def available(self) -> bool:
diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py
index ed5ba1d4c21..353c7397d81 100644
--- a/homeassistant/components/foscam/camera.py
+++ b/homeassistant/components/foscam/camera.py
@@ -10,7 +10,7 @@ from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_RTSP_PORT, CONF_STREAM, LOGGER, SERVICE_PTZ, SERVICE_PTZ_PRESET
from .coordinator import FoscamConfigEntry, FoscamCoordinator
@@ -49,7 +49,7 @@ PTZ_GOTO_PRESET_COMMAND = "ptz_goto_preset"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FoscamConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a Foscam IP camera from a config entry."""
platform = entity_platform.async_get_current_platform()
diff --git a/homeassistant/components/foscam/strings.json b/homeassistant/components/foscam/strings.json
index 2784e541809..03351e3238f 100644
--- a/homeassistant/components/foscam/strings.json
+++ b/homeassistant/components/foscam/strings.json
@@ -35,7 +35,7 @@
"services": {
"ptz": {
"name": "PTZ",
- "description": "Pan/Tilt action for Foscam camera.",
+ "description": "Moves a Foscam camera to a specified direction.",
"fields": {
"movement": {
"name": "Movement",
@@ -49,7 +49,7 @@
},
"ptz_preset": {
"name": "PTZ preset",
- "description": "PTZ Preset action for Foscam camera.",
+ "description": "Moves a Foscam camera to a predefined position.",
"fields": {
"preset_name": {
"name": "Preset name",
diff --git a/homeassistant/components/foscam/switch.py b/homeassistant/components/foscam/switch.py
index 189271d2746..24b05b5aeaa 100644
--- a/homeassistant/components/foscam/switch.py
+++ b/homeassistant/components/foscam/switch.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER
from .coordinator import FoscamConfigEntry, FoscamCoordinator
@@ -17,7 +17,7 @@ from .entity import FoscamEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FoscamConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up foscam switch from a config entry."""
diff --git a/homeassistant/components/frankever/__init__.py b/homeassistant/components/frankever/__init__.py
new file mode 100644
index 00000000000..66eeecb1e59
--- /dev/null
+++ b/homeassistant/components/frankever/__init__.py
@@ -0,0 +1 @@
+"""FrankEver virtual integration."""
diff --git a/homeassistant/components/frankever/manifest.json b/homeassistant/components/frankever/manifest.json
new file mode 100644
index 00000000000..37d7be765ef
--- /dev/null
+++ b/homeassistant/components/frankever/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "frankever",
+ "name": "FrankEver",
+ "integration_type": "virtual",
+ "supported_by": "shelly"
+}
diff --git a/homeassistant/components/freebox/alarm_control_panel.py b/homeassistant/components/freebox/alarm_control_panel.py
index 9d8e85a14ca..89462b33a2f 100644
--- a/homeassistant/components/freebox/alarm_control_panel.py
+++ b/homeassistant/components/freebox/alarm_control_panel.py
@@ -9,7 +9,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, FreeboxHomeCategory
from .entity import FreeboxHomeEntity
@@ -28,7 +28,9 @@ FREEBOX_TO_STATUS = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up alarm panel."""
router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id]
diff --git a/homeassistant/components/freebox/binary_sensor.py b/homeassistant/components/freebox/binary_sensor.py
index 20c124efea6..9fc9929b869 100644
--- a/homeassistant/components/freebox/binary_sensor.py
+++ b/homeassistant/components/freebox/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, FreeboxHomeCategory
from .entity import FreeboxHomeEntity
@@ -34,7 +34,9 @@ RAID_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors."""
router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id]
diff --git a/homeassistant/components/freebox/button.py b/homeassistant/components/freebox/button.py
index 79e3c98b8b7..4f676fd46a1 100644
--- a/homeassistant/components/freebox/button.py
+++ b/homeassistant/components/freebox/button.py
@@ -13,7 +13,7 @@ from homeassistant.components.button import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .router import FreeboxRouter
@@ -44,7 +44,9 @@ BUTTON_DESCRIPTIONS: tuple[FreeboxButtonEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the buttons."""
router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id]
diff --git a/homeassistant/components/freebox/camera.py b/homeassistant/components/freebox/camera.py
index 33919df74f6..45bb5a34063 100644
--- a/homeassistant/components/freebox/camera.py
+++ b/homeassistant/components/freebox/camera.py
@@ -17,7 +17,7 @@ from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_DETECTION, DOMAIN, FreeboxHomeCategory
from .entity import FreeboxHomeEntity
@@ -27,7 +27,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up cameras."""
router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id]
@@ -49,7 +51,7 @@ async def async_setup_entry(
def add_entities(
hass: HomeAssistant,
router: FreeboxRouter,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
tracked: set[str],
) -> None:
"""Add new cameras from the router."""
diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py
index 1fa37ebc270..dcb6eb104b2 100644
--- a/homeassistant/components/freebox/device_tracker.py
+++ b/homeassistant/components/freebox/device_tracker.py
@@ -9,14 +9,16 @@ from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DEFAULT_DEVICE_NAME, DEVICE_ICONS, DOMAIN
from .router import FreeboxRouter
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for Freebox component."""
router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id]
@@ -36,7 +38,9 @@ async def async_setup_entry(
@callback
def add_entities(
- router: FreeboxRouter, async_add_entities: AddEntitiesCallback, tracked: set[str]
+ router: FreeboxRouter,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ tracked: set[str],
) -> None:
"""Add new tracker entities from the router."""
new_tracked = []
diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py
index 588992a7f21..cc62de9ae0d 100644
--- a/homeassistant/components/freebox/sensor.py
+++ b/homeassistant/components/freebox/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.const import PERCENTAGE, UnitOfDataRate, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -60,7 +60,9 @@ DISK_PARTITION_SENSORS: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors."""
router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id]
diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py
index 96c3bcc2496..c4618b014bf 100644
--- a/homeassistant/components/freebox/switch.py
+++ b/homeassistant/components/freebox/switch.py
@@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .router import FreeboxRouter
@@ -29,7 +29,9 @@ SWITCH_DESCRIPTIONS = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switch."""
router: FreeboxRouter = hass.data[DOMAIN][entry.unique_id]
diff --git a/homeassistant/components/freedompro/binary_sensor.py b/homeassistant/components/freedompro/binary_sensor.py
index 840150e807d..9ff62446176 100644
--- a/homeassistant/components/freedompro/binary_sensor.py
+++ b/homeassistant/components/freedompro/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -34,7 +34,7 @@ SUPPORTED_SENSORS = {"smokeSensor", "occupancySensor", "motionSensor", "contactS
async def async_setup_entry(
hass: HomeAssistant,
entry: FreedomproConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Freedompro binary_sensor."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/freedompro/climate.py b/homeassistant/components/freedompro/climate.py
index a0146dc70b3..0145dea27bb 100644
--- a/homeassistant/components/freedompro/climate.py
+++ b/homeassistant/components/freedompro/climate.py
@@ -19,7 +19,7 @@ from homeassistant.const import ATTR_TEMPERATURE, CONF_API_KEY, UnitOfTemperatur
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -45,7 +45,7 @@ SUPPORTED_HVAC_MODES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: FreedomproConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Freedompro climate."""
api_key: str = entry.data[CONF_API_KEY]
diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py
index ee61612428c..01e1b39d08f 100644
--- a/homeassistant/components/freedompro/cover.py
+++ b/homeassistant/components/freedompro/cover.py
@@ -15,7 +15,7 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -35,7 +35,7 @@ SUPPORTED_SENSORS = {"windowCovering", "gate", "garageDoor", "door", "window"}
async def async_setup_entry(
hass: HomeAssistant,
entry: FreedomproConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Freedompro cover."""
api_key: str = entry.data[CONF_API_KEY]
diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py
index ad520ac8eb8..c65afb3a0e2 100644
--- a/homeassistant/components/freedompro/fan.py
+++ b/homeassistant/components/freedompro/fan.py
@@ -12,7 +12,7 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -22,7 +22,7 @@ from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: FreedomproConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Freedompro fan."""
api_key: str = entry.data[CONF_API_KEY]
diff --git a/homeassistant/components/freedompro/light.py b/homeassistant/components/freedompro/light.py
index c1b2e0ea17b..f9d90420c5d 100644
--- a/homeassistant/components/freedompro/light.py
+++ b/homeassistant/components/freedompro/light.py
@@ -17,7 +17,7 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -27,7 +27,7 @@ from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: FreedomproConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Freedompro light."""
api_key: str = entry.data[CONF_API_KEY]
diff --git a/homeassistant/components/freedompro/lock.py b/homeassistant/components/freedompro/lock.py
index 70423bb9514..4aee252abbe 100644
--- a/homeassistant/components/freedompro/lock.py
+++ b/homeassistant/components/freedompro/lock.py
@@ -10,7 +10,7 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -20,7 +20,7 @@ from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: FreedomproConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Freedompro lock."""
api_key: str = entry.data[CONF_API_KEY]
diff --git a/homeassistant/components/freedompro/sensor.py b/homeassistant/components/freedompro/sensor.py
index eaa96ac9fed..dbe1449d6e5 100644
--- a/homeassistant/components/freedompro/sensor.py
+++ b/homeassistant/components/freedompro/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -42,7 +42,7 @@ SUPPORTED_SENSORS = {"temperatureSensor", "humiditySensor", "lightSensor"}
async def async_setup_entry(
hass: HomeAssistant,
entry: FreedomproConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Freedompro sensor."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/freedompro/switch.py b/homeassistant/components/freedompro/switch.py
index 12346825474..bda13b147b1 100644
--- a/homeassistant/components/freedompro/switch.py
+++ b/homeassistant/components/freedompro/switch.py
@@ -10,7 +10,7 @@ from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -20,7 +20,7 @@ from .coordinator import FreedomproConfigEntry, FreedomproDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: FreedomproConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Freedompro switch."""
api_key: str = entry.data[CONF_API_KEY]
diff --git a/homeassistant/components/fritz/binary_sensor.py b/homeassistant/components/fritz/binary_sensor.py
index 7553328a64c..2a4eb8c82b5 100644
--- a/homeassistant/components/fritz/binary_sensor.py
+++ b/homeassistant/components/fritz/binary_sensor.py
@@ -13,13 +13,16 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ConnectionInfo, FritzConfigEntry
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class FritzBinarySensorEntityDescription(
@@ -51,7 +54,7 @@ SENSOR_TYPES: tuple[FritzBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
_LOGGER.debug("Setting up FRITZ!Box binary sensors")
diff --git a/homeassistant/components/fritz/button.py b/homeassistant/components/fritz/button.py
index f3ffbe42099..926e233d159 100644
--- a/homeassistant/components/fritz/button.py
+++ b/homeassistant/components/fritz/button.py
@@ -16,9 +16,9 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, DOMAIN, MeshRoles
+from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles
from .coordinator import (
FRITZ_DATA_KEY,
AvmWrapper,
@@ -31,6 +31,9 @@ from .entity import FritzDeviceBase
_LOGGER = logging.getLogger(__name__)
+# Set a sane value to avoid too many updates
+PARALLEL_UPDATES = 5
+
@dataclass(frozen=True, kw_only=True)
class FritzButtonDescription(ButtonEntityDescription):
@@ -72,7 +75,7 @@ BUTTONS: Final = [
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set buttons for device."""
_LOGGER.debug("Setting up buttons")
@@ -175,16 +178,6 @@ class FritzBoxWOLButton(FritzDeviceBase, ButtonEntity):
self._name = f"{self.hostname} Wake on LAN"
self._attr_unique_id = f"{self._mac}_wake_on_lan"
self._is_available = True
- self._attr_device_info = DeviceInfo(
- connections={(CONNECTION_NETWORK_MAC, self._mac)},
- default_manufacturer="AVM",
- default_model="FRITZ!Box Tracked device",
- default_name=device.hostname,
- via_device=(
- DOMAIN,
- avm_wrapper.unique_id,
- ),
- )
async def async_press(self) -> None:
"""Press the button."""
diff --git a/homeassistant/components/fritz/coordinator.py b/homeassistant/components/fritz/coordinator.py
index 38d76c92871..c0121ed9aa1 100644
--- a/homeassistant/components/fritz/coordinator.py
+++ b/homeassistant/components/fritz/coordinator.py
@@ -196,6 +196,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
self.hass = hass
self.host = host
self.mesh_role = MeshRoles.NONE
+ self.mesh_wifi_uplink = False
self.device_conn_type: str | None = None
self.device_is_router: bool = False
self.password = password
@@ -525,7 +526,7 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
def manage_device_info(
self, dev_info: Device, dev_mac: str, consider_home: bool
) -> bool:
- """Update device lists."""
+ """Update device lists and return if device is new."""
_LOGGER.debug("Client dev_info: %s", dev_info)
if dev_mac in self._devices:
@@ -535,6 +536,16 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
device = FritzDevice(dev_mac, dev_info.name)
device.update(dev_info, consider_home)
self._devices[dev_mac] = device
+
+ # manually register device entry for new connected device
+ dr.async_get(self.hass).async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ connections={(CONNECTION_NETWORK_MAC, dev_mac)},
+ default_manufacturer="AVM",
+ default_model="FRITZ!Box Tracked device",
+ default_name=device.hostname,
+ via_device=(DOMAIN, self.unique_id),
+ )
return True
async def async_send_signal_device_update(self, new_device: bool) -> None:
@@ -610,6 +621,12 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
ssid=interf.get("ssid", ""),
type=interf["type"],
)
+
+ if interf["type"].lower() == "wlan" and interf[
+ "name"
+ ].lower().startswith("uplink"):
+ self.mesh_wifi_uplink = True
+
if dr.format_mac(int_mac) == self.mac:
self.mesh_role = MeshRoles(node["mesh_role"])
diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py
index ba3c9a5aab6..618214a1c55 100644
--- a/homeassistant/components/fritz/device_tracker.py
+++ b/homeassistant/components/fritz/device_tracker.py
@@ -8,7 +8,7 @@ import logging
from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
FRITZ_DATA_KEY,
@@ -22,11 +22,14 @@ from .entity import FritzDeviceBase
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for FRITZ!Box component."""
_LOGGER.debug("Starting FRITZ!Box device tracker")
@@ -48,7 +51,7 @@ async def async_setup_entry(
@callback
def _async_add_entities(
avm_wrapper: AvmWrapper,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
data_fritz: FritzData,
) -> None:
"""Add new tracker entities from the AVM device."""
diff --git a/homeassistant/components/fritz/entity.py b/homeassistant/components/fritz/entity.py
index 33eb60d72cf..e8b5c49fd43 100644
--- a/homeassistant/components/fritz/entity.py
+++ b/homeassistant/components/fritz/entity.py
@@ -26,6 +26,9 @@ class FritzDeviceBase(CoordinatorEntity[AvmWrapper]):
self._avm_wrapper = avm_wrapper
self._mac: str = device.mac_address
self._name: str = device.hostname or DEFAULT_DEVICE_NAME
+ self._attr_device_info = DeviceInfo(
+ connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}
+ )
@property
def name(self) -> str:
diff --git a/homeassistant/components/fritz/image.py b/homeassistant/components/fritz/image.py
index d305551b097..1fc70dedc6c 100644
--- a/homeassistant/components/fritz/image.py
+++ b/homeassistant/components/fritz/image.py
@@ -10,7 +10,7 @@ from requests.exceptions import RequestException
from homeassistant.components.image import ImageEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
from .coordinator import AvmWrapper, FritzConfigEntry
@@ -18,11 +18,14 @@ from .entity import FritzBoxBaseEntity
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up guest WiFi QR code for device."""
avm_wrapper = entry.runtime_data
diff --git a/homeassistant/components/fritz/quality_scale.yaml b/homeassistant/components/fritz/quality_scale.yaml
index 805705eb4b4..c2d18a0be84 100644
--- a/homeassistant/components/fritz/quality_scale.yaml
+++ b/homeassistant/components/fritz/quality_scale.yaml
@@ -4,19 +4,13 @@ rules:
appropriate-polling: done
brands: done
common-modules: done
- config-flow-test-coverage:
- status: todo
- comment: one coverage miss in line 110
- config-flow:
- status: todo
- comment: data_description are missing
+ config-flow-test-coverage: done
+ config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
- docs-removal-instructions:
- status: todo
- comment: include the proper docs snippet
+ docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name:
@@ -31,15 +25,11 @@ rules:
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
- docs-installation-parameters:
- status: todo
- comment: add the proper configuration_basic block
+ docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
- parallel-updates:
- status: todo
- comment: not set at the moment, we use a coordinator
+ parallel-updates: done
reauthentication-flow: done
test-coverage:
status: todo
@@ -50,7 +40,7 @@ rules:
diagnostics: done
discovery-update-info: todo
discovery: done
- docs-data-update: todo
+ docs-data-update: done
docs-examples: done
docs-known-limitations:
status: exempt
diff --git a/homeassistant/components/fritz/sensor.py b/homeassistant/components/fritz/sensor.py
index 81b50bd21ac..65a776b9ad5 100644
--- a/homeassistant/components/fritz/sensor.py
+++ b/homeassistant/components/fritz/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfInformation,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
@@ -32,6 +32,9 @@ from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
_LOGGER = logging.getLogger(__name__)
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
def _uptime_calculation(seconds_uptime: float, last_value: datetime | None) -> datetime:
"""Calculate uptime with deviation."""
@@ -193,7 +196,6 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
translation_key="max_kb_s_sent",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
- entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_max_kb_s_sent_state,
),
FritzSensorEntityDescription(
@@ -201,7 +203,6 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
translation_key="max_kb_s_received",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
- entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_max_kb_s_received_state,
),
FritzSensorEntityDescription(
@@ -225,6 +226,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
translation_key="link_kb_s_sent",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
+ entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_link_kb_s_sent_state,
),
FritzSensorEntityDescription(
@@ -232,12 +234,15 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
translation_key="link_kb_s_received",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
+ entity_category=EntityCategory.DIAGNOSTIC,
value_fn=_retrieve_link_kb_s_received_state,
),
FritzSensorEntityDescription(
key="link_noise_margin_sent",
translation_key="link_noise_margin_sent",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
value_fn=_retrieve_link_noise_margin_sent_state,
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
),
@@ -245,6 +250,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
key="link_noise_margin_received",
translation_key="link_noise_margin_received",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
value_fn=_retrieve_link_noise_margin_received_state,
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
),
@@ -252,6 +259,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
key="link_attenuation_sent",
translation_key="link_attenuation_sent",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
value_fn=_retrieve_link_attenuation_sent_state,
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
),
@@ -259,6 +268,8 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
key="link_attenuation_received",
translation_key="link_attenuation_received",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
value_fn=_retrieve_link_attenuation_received_state,
is_suitable=lambda info: info.wan_enabled and info.connection == DSL_CONNECTION,
),
@@ -268,7 +279,7 @@ SENSOR_TYPES: tuple[FritzSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
_LOGGER.debug("Setting up FRITZ!Box sensors")
diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json
index 06a07cba79e..6191fc524dd 100644
--- a/homeassistant/components/fritz/strings.json
+++ b/homeassistant/components/fritz/strings.json
@@ -1,4 +1,11 @@
{
+ "common": {
+ "data_description_host": "The hostname or IP address of your FRITZ!Box router.",
+ "data_description_port": "Leave empty to use the default port.",
+ "data_description_username": "Username for the FRITZ!Box.",
+ "data_description_password": "Password for the FRITZ!Box.",
+ "data_description_ssl": "Use SSL to connect to the FRITZ!Box."
+ },
"config": {
"flow_title": "{name}",
"step": {
@@ -9,6 +16,11 @@
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
"ssl": "[%key:common::config_flow::data::ssl%]"
+ },
+ "data_description": {
+ "username": "[%key:component::fritz::common::data_description_username%]",
+ "password": "[%key:component::fritz::common::data_description_password%]",
+ "ssl": "[%key:component::fritz::common::data_description_ssl%]"
}
},
"reauth_confirm": {
@@ -17,6 +29,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::fritz::common::data_description_username%]",
+ "password": "[%key:component::fritz::common::data_description_password%]"
}
},
"reconfigure": {
@@ -28,8 +44,9 @@
"ssl": "[%key:common::config_flow::data::ssl%]"
},
"data_description": {
- "host": "The hostname or IP address of your FRITZ!Box router.",
- "port": "Leave it empty to use the default port."
+ "host": "[%key:component::fritz::common::data_description_host%]",
+ "port": "[%key:component::fritz::common::data_description_port%]",
+ "ssl": "[%key:component::fritz::common::data_description_ssl%]"
}
},
"user": {
@@ -43,8 +60,11 @@
"ssl": "[%key:common::config_flow::data::ssl%]"
},
"data_description": {
- "host": "The hostname or IP address of your FRITZ!Box router.",
- "port": "Leave it empty to use the default port."
+ "host": "[%key:component::fritz::common::data_description_host%]",
+ "port": "[%key:component::fritz::common::data_description_port%]",
+ "username": "[%key:component::fritz::common::data_description_username%]",
+ "password": "[%key:component::fritz::common::data_description_password%]",
+ "ssl": "[%key:component::fritz::common::data_description_ssl%]"
}
}
},
@@ -70,6 +90,10 @@
"data": {
"consider_home": "Seconds to consider a device at 'home'",
"old_discovery": "Enable old discovery method"
+ },
+ "data_description": {
+ "consider_home": "Time in seconds to consider a device at home. Default is 180 seconds.",
+ "old_discovery": "Enable old discovery method. This is needed for some scenarios."
}
}
}
@@ -169,8 +193,12 @@
"config_entry_not_found": {
"message": "Failed to perform action \"{service}\". Config entry for target not found"
},
- "service_parameter_unknown": { "message": "Action or parameter unknown" },
- "service_not_supported": { "message": "Action not supported" },
+ "service_parameter_unknown": {
+ "message": "Action or parameter unknown"
+ },
+ "service_not_supported": {
+ "message": "Action not supported"
+ },
"error_refresh_hosts_info": {
"message": "Error refreshing hosts info"
},
diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py
index 9c12fe0cecc..a033e45fcec 100644
--- a/homeassistant/components/fritz/switch.py
+++ b/homeassistant/components/fritz/switch.py
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
@@ -38,6 +38,9 @@ from .entity import FritzBoxBaseEntity, FritzDeviceBase
_LOGGER = logging.getLogger(__name__)
+# Set a sane value to avoid too many updates
+PARALLEL_UPDATES = 5
+
async def _async_deflection_entities_list(
avm_wrapper: AvmWrapper, device_friendly_name: str
@@ -207,8 +210,9 @@ async def async_all_entities_list(
local_ip: str,
) -> list[Entity]:
"""Get a list of all entities."""
-
if avm_wrapper.mesh_role == MeshRoles.SLAVE:
+ if not avm_wrapper.mesh_wifi_uplink:
+ return [*await _async_wifi_entities_list(avm_wrapper, device_friendly_name)]
return []
return [
@@ -222,7 +226,7 @@ async def async_all_entities_list(
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
_LOGGER.debug("Setting up switches")
@@ -510,16 +514,6 @@ class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
self._name = f"{device.hostname} Internet Access"
self._attr_unique_id = f"{self._mac}_internet_access"
self._attr_entity_category = EntityCategory.CONFIG
- self._attr_device_info = DeviceInfo(
- connections={(CONNECTION_NETWORK_MAC, self._mac)},
- default_manufacturer="AVM",
- default_model="FRITZ!Box Tracked device",
- default_name=device.hostname,
- via_device=(
- DOMAIN,
- avm_wrapper.unique_id,
- ),
- )
@property
def is_on(self) -> bool | None:
@@ -565,6 +559,9 @@ class FritzBoxWifiSwitch(FritzBoxBaseSwitch):
self._attributes = {}
self._attr_entity_category = EntityCategory.CONFIG
+ self._attr_entity_registry_enabled_default = (
+ avm_wrapper.mesh_role is not MeshRoles.SLAVE
+ )
self._network_num = network_num
switch_info = SwitchInfo(
diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py
index ad23a076ca6..4e54f4c28d3 100644
--- a/homeassistant/components/fritz/update.py
+++ b/homeassistant/components/fritz/update.py
@@ -13,13 +13,16 @@ from homeassistant.components.update import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AvmWrapper, FritzConfigEntry
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
_LOGGER = logging.getLogger(__name__)
+# Set a sane value to avoid too many updates
+PARALLEL_UPDATES = 5
+
@dataclass(frozen=True, kw_only=True)
class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription):
@@ -29,7 +32,7 @@ class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescripti
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AVM FRITZ!Box update entities."""
_LOGGER.debug("Setting up AVM FRITZ!Box update entities")
diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py
index 3c9cb6ada5c..75683017cb7 100644
--- a/homeassistant/components/fritzbox/binary_sensor.py
+++ b/homeassistant/components/fritzbox/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FritzboxConfigEntry
from .entity import FritzBoxDeviceEntity
@@ -66,7 +66,7 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzboxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FRITZ!SmartHome binary sensor from ConfigEntry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py
index 44a6697e1c0..54baa97b11a 100644
--- a/homeassistant/components/fritzbox/button.py
+++ b/homeassistant/components/fritzbox/button.py
@@ -5,7 +5,7 @@ from pyfritzhome.devicetypes import FritzhomeTemplate
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import FritzboxConfigEntry
@@ -15,7 +15,7 @@ from .entity import FritzBoxEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzboxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FRITZ!SmartHome template from ConfigEntry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py
index 87a87ac691f..57c7e2a696f 100644
--- a/homeassistant/components/fritzbox/climate.py
+++ b/homeassistant/components/fritzbox/climate.py
@@ -6,6 +6,7 @@ from typing import Any
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
+ PRESET_BOOST,
PRESET_COMFORT,
PRESET_ECO,
ClimateEntity,
@@ -20,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_STATE_BATTERY_LOW,
@@ -38,7 +39,7 @@ from .sensor import value_scheduled_preset
HVAC_MODES = [HVACMode.HEAT, HVACMode.OFF]
PRESET_HOLIDAY = "holiday"
PRESET_SUMMER = "summer"
-PRESET_MODES = [PRESET_ECO, PRESET_COMFORT]
+PRESET_MODES = [PRESET_ECO, PRESET_COMFORT, PRESET_BOOST]
SUPPORTED_FEATURES = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.PRESET_MODE
@@ -59,7 +60,7 @@ OFF_REPORT_SET_TEMPERATURE = 0.0
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzboxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FRITZ!SmartHome thermostat from ConfigEntry."""
coordinator = entry.runtime_data
@@ -85,6 +86,8 @@ async def async_setup_entry(
class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
"""The thermostat class for FRITZ!SmartHome thermostats."""
+ _attr_max_temp = MAX_TEMPERATURE
+ _attr_min_temp = MIN_TEMPERATURE
_attr_precision = PRECISION_HALVES
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = "thermostat"
@@ -135,11 +138,13 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
- target_temp = kwargs.get(ATTR_TEMPERATURE)
- hvac_mode = kwargs.get(ATTR_HVAC_MODE)
- if hvac_mode == HVACMode.OFF:
+ if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is HVACMode.OFF:
await self.async_set_hvac_mode(hvac_mode)
- elif target_temp is not None:
+ elif (target_temp := kwargs.get(ATTR_TEMPERATURE)) is not None:
+ if target_temp == OFF_API_TEMPERATURE:
+ target_temp = OFF_REPORT_SET_TEMPERATURE
+ elif target_temp == ON_API_TEMPERATURE:
+ target_temp = ON_REPORT_SET_TEMPERATURE
await self.hass.async_add_executor_job(
self.data.set_target_temperature, target_temp, True
)
@@ -169,12 +174,12 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
translation_domain=DOMAIN,
translation_key="change_hvac_while_active_mode",
)
- if self.hvac_mode == hvac_mode:
+ if self.hvac_mode is hvac_mode:
LOGGER.debug(
"%s is already in requested hvac mode %s", self.name, hvac_mode
)
return
- if hvac_mode == HVACMode.OFF:
+ if hvac_mode is HVACMode.OFF:
await self.async_set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE)
else:
if value_scheduled_preset(self.data) == PRESET_ECO:
@@ -190,6 +195,8 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
return PRESET_HOLIDAY
if self.data.summer_active:
return PRESET_SUMMER
+ if self.data.target_temperature == ON_API_TEMPERATURE:
+ return PRESET_BOOST
if self.data.target_temperature == self.data.comfort_temperature:
return PRESET_COMFORT
if self.data.target_temperature == self.data.eco_temperature:
@@ -207,16 +214,8 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
await self.async_set_temperature(temperature=self.data.comfort_temperature)
elif preset_mode == PRESET_ECO:
await self.async_set_temperature(temperature=self.data.eco_temperature)
-
- @property
- def min_temp(self) -> int:
- """Return the minimum temperature."""
- return MIN_TEMPERATURE
-
- @property
- def max_temp(self) -> int:
- """Return the maximum temperature."""
- return MAX_TEMPERATURE
+ elif preset_mode == PRESET_BOOST:
+ await self.async_set_temperature(temperature=ON_REPORT_SET_TEMPERATURE)
@property
def extra_state_attributes(self) -> ClimateExtraAttributes:
diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py
index 34df3885deb..adc63dd2c2e 100644
--- a/homeassistant/components/fritzbox/coordinator.py
+++ b/homeassistant/components/fritzbox/coordinator.py
@@ -77,12 +77,11 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
self.configuration_url = self.fritz.get_prefixed_host()
await self.async_config_entry_first_refresh()
- self.cleanup_removed_devices(
- list(self.data.devices) + list(self.data.templates)
- )
+ self.cleanup_removed_devices(self.data)
- def cleanup_removed_devices(self, available_ains: list[str]) -> None:
+ def cleanup_removed_devices(self, data: FritzboxCoordinatorData) -> None:
"""Cleanup entity and device registry from removed devices."""
+ available_ains = list(data.devices) + list(data.templates)
entity_reg = er.async_get(self.hass)
for entity in er.async_entries_for_config_entry(
entity_reg, self.config_entry.entry_id
@@ -91,8 +90,13 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
LOGGER.debug("Removing obsolete entity entry %s", entity.entity_id)
entity_reg.async_remove(entity.entity_id)
+ available_main_ains = [
+ ain
+ for ain, dev in data.devices.items()
+ if dev.device_and_unit_id[1] is None
+ ]
device_reg = dr.async_get(self.hass)
- identifiers = {(DOMAIN, ain) for ain in available_ains}
+ identifiers = {(DOMAIN, ain) for ain in available_main_ains}
for device in dr.async_entries_for_config_entry(
device_reg, self.config_entry.entry_id
):
@@ -165,12 +169,26 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
"""Fetch all device data."""
new_data = await self.hass.async_add_executor_job(self._update_fritz_devices)
+ for device in new_data.devices.values():
+ # create device registry entry for new main devices
+ if (
+ device.ain not in self.data.devices
+ and device.device_and_unit_id[1] is None
+ ):
+ dr.async_get(self.hass).async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ name=device.name,
+ identifiers={(DOMAIN, device.ain)},
+ manufacturer=device.manufacturer,
+ model=device.productname,
+ sw_version=device.fw_version,
+ configuration_url=self.configuration_url,
+ )
+
if (
self.data.devices.keys() - new_data.devices.keys()
or self.data.templates.keys() - new_data.templates.keys()
):
- self.cleanup_removed_devices(
- list(new_data.devices) + list(new_data.templates)
- )
+ self.cleanup_removed_devices(new_data)
return new_data
diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py
index 070bb868298..c7ecfef6a32 100644
--- a/homeassistant/components/fritzbox/cover.py
+++ b/homeassistant/components/fritzbox/cover.py
@@ -11,7 +11,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FritzboxConfigEntry
from .entity import FritzBoxDeviceEntity
@@ -20,7 +20,7 @@ from .entity import FritzBoxDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzboxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FRITZ!SmartHome cover from ConfigEntry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fritzbox/entity.py b/homeassistant/components/fritzbox/entity.py
index cd619588bc1..bbc7d9fe276 100644
--- a/homeassistant/components/fritzbox/entity.py
+++ b/homeassistant/components/fritzbox/entity.py
@@ -58,11 +58,4 @@ class FritzBoxDeviceEntity(FritzBoxEntity):
@property
def device_info(self) -> DeviceInfo:
"""Return device specific attributes."""
- return DeviceInfo(
- name=self.data.name,
- identifiers={(DOMAIN, self.ain)},
- manufacturer=self.data.manufacturer,
- model=self.data.productname,
- sw_version=self.data.fw_version,
- configuration_url=self.coordinator.configuration_url,
- )
+ return DeviceInfo(identifiers={(DOMAIN, self.data.device_and_unit_id[0])})
diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py
index 94d7d320704..8603840630c 100644
--- a/homeassistant/components/fritzbox/light.py
+++ b/homeassistant/components/fritzbox/light.py
@@ -12,7 +12,7 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import COLOR_MODE, LOGGER
from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator
@@ -22,7 +22,7 @@ from .entity import FritzBoxDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzboxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FRITZ!SmartHome light from ConfigEntry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json
index 2fbb75443b2..f6155024cbf 100644
--- a/homeassistant/components/fritzbox/manifest.json
+++ b/homeassistant/components/fritzbox/manifest.json
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyfritzhome"],
- "requirements": ["pyfritzhome==0.6.14"],
+ "requirements": ["pyfritzhome==0.6.17"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:fritzbox:1"
diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py
index e610fd80f3e..801a3a67a6e 100644
--- a/homeassistant/components/fritzbox/sensor.py
+++ b/homeassistant/components/fritzbox/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utc_from_timestamp
@@ -137,6 +137,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = (
key="battery",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
+ state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suitable=lambda device: device.battery_level is not None,
native_value=lambda device: device.battery_level,
@@ -229,7 +230,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzboxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FRITZ!SmartHome sensor from ConfigEntry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fritzbox/strings.json b/homeassistant/components/fritzbox/strings.json
index c7c2439b566..e0df30875bc 100644
--- a/homeassistant/components/fritzbox/strings.json
+++ b/homeassistant/components/fritzbox/strings.json
@@ -89,7 +89,7 @@
"message": "Can't change preset while holiday or summer mode is active on the device."
},
"change_hvac_while_active_mode": {
- "message": "Can't change hvac mode while holiday or summer mode is active on the device."
+ "message": "Can't change HVAC mode while holiday or summer mode is active on the device."
}
}
}
diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py
index d83793c77dc..c2679ef5243 100644
--- a/homeassistant/components/fritzbox/switch.py
+++ b/homeassistant/components/fritzbox/switch.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import FritzboxConfigEntry
@@ -17,7 +17,7 @@ from .entity import FritzBoxDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FritzboxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FRITZ!SmartHome switch from ConfigEntry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py
index df18ae5702a..574ae9ef7f2 100644
--- a/homeassistant/components/fritzbox_callmonitor/sensor.py
+++ b/homeassistant/components/fritzbox_callmonitor/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FritzBoxCallMonitorConfigEntry
from .base import Contact, FritzBoxPhonebook
@@ -48,7 +48,7 @@ class CallState(StrEnum):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FritzBoxCallMonitorConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the fritzbox_callmonitor sensor from config_entry."""
fritzbox_phonebook = config_entry.runtime_data
diff --git a/homeassistant/components/fronius/config_flow.py b/homeassistant/components/fronius/config_flow.py
index f35c9ce5bc1..b8aa2da81c6 100644
--- a/homeassistant/components/fronius/config_flow.py
+++ b/homeassistant/components/fronius/config_flow.py
@@ -149,7 +149,7 @@ class FroniusConfigFlow(ConfigFlow, domain=DOMAIN):
unique_id, info = await validate_host(self.hass, user_input[CONF_HOST])
except CannotConnect:
errors["base"] = "cannot_connect"
- except Exception: # pylint: disable=broad-except
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json
index 94d0f90b0bd..661d808ad23 100644
--- a/homeassistant/components/fronius/manifest.json
+++ b/homeassistant/components/fronius/manifest.json
@@ -11,6 +11,6 @@
"documentation": "https://www.home-assistant.io/integrations/fronius",
"iot_class": "local_polling",
"loggers": ["pyfronius"],
- "quality_scale": "gold",
- "requirements": ["PyFronius==0.7.3"]
+ "quality_scale": "platinum",
+ "requirements": ["PyFronius==0.7.7"]
}
diff --git a/homeassistant/components/fronius/quality_scale.yaml b/homeassistant/components/fronius/quality_scale.yaml
index 2c4b892475b..522b8ab571f 100644
--- a/homeassistant/components/fronius/quality_scale.yaml
+++ b/homeassistant/components/fronius/quality_scale.yaml
@@ -83,7 +83,4 @@ rules:
# Platinum
async-dependency: done
inject-websession: done
- strict-typing:
- status: todo
- comment: |
- The pyfronius library isn't strictly typed and doesn't export type information.
+ strict-typing: done
diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py
index c6c3ff4b602..c65f6072ba6 100644
--- a/homeassistant/components/fronius/sensor.py
+++ b/homeassistant/components/fronius/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -64,7 +64,7 @@ ENERGY_VOLT_AMPERE_REACTIVE_HOUR: Final = "varh"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FroniusConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Fronius sensor entities based on a config entry."""
solar_net = config_entry.runtime_data
diff --git a/homeassistant/components/fronius/strings.json b/homeassistant/components/fronius/strings.json
index b77f6fec83c..36778f2ca5f 100644
--- a/homeassistant/components/fronius/strings.json
+++ b/homeassistant/components/fronius/strings.json
@@ -182,10 +182,10 @@
"state": {
"startup": "Startup",
"running": "Running",
- "standby": "Standby",
+ "standby": "[%key:common::state::standby%]",
"bootloading": "Bootloading",
"error": "Error",
- "idle": "Idle",
+ "idle": "[%key:common::state::idle%]",
"ready": "Ready",
"sleeping": "Sleeping"
}
diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py
index 6184d888004..9a0627f9f42 100644
--- a/homeassistant/components/frontend/__init__.py
+++ b/homeassistant/components/frontend/__init__.py
@@ -52,10 +52,9 @@ CONF_JS_VERSION = "javascript_version"
DEFAULT_THEME_COLOR = "#03A9F4"
-DATA_PANELS = "frontend_panels"
-DATA_JS_VERSION = "frontend_js_version"
-DATA_EXTRA_MODULE_URL = "frontend_extra_module_url"
-DATA_EXTRA_JS_URL_ES5 = "frontend_extra_js_url_es5"
+DATA_PANELS: HassKey[dict[str, Panel]] = HassKey("frontend_panels")
+DATA_EXTRA_MODULE_URL: HassKey[UrlManager] = HassKey("frontend_extra_module_url")
+DATA_EXTRA_JS_URL_ES5: HassKey[UrlManager] = HassKey("frontend_extra_js_url_es5")
DATA_WS_SUBSCRIBERS: HassKey[set[tuple[websocket_api.ActiveConnection, int]]] = HassKey(
"frontend_ws_subscribers"
@@ -64,8 +63,8 @@ DATA_WS_SUBSCRIBERS: HassKey[set[tuple[websocket_api.ActiveConnection, int]]] =
THEMES_STORAGE_KEY = f"{DOMAIN}_theme"
THEMES_STORAGE_VERSION = 1
THEMES_SAVE_DELAY = 60
-DATA_THEMES_STORE = "frontend_themes_store"
-DATA_THEMES = "frontend_themes"
+DATA_THEMES_STORE: HassKey[Store] = HassKey("frontend_themes_store")
+DATA_THEMES: HassKey[dict[str, Any]] = HassKey("frontend_themes")
DATA_DEFAULT_THEME = "frontend_default_theme"
DATA_DEFAULT_DARK_THEME = "frontend_default_dark_theme"
DEFAULT_THEME = "default"
@@ -242,7 +241,7 @@ class Panel:
sidebar_title: str | None = None
# Url to show the panel in the frontend
- frontend_url_path: str | None = None
+ frontend_url_path: str
# Config to pass to the webcomponent
config: dict[str, Any] | None = None
@@ -273,7 +272,7 @@ class Panel:
self.config_panel_domain = config_panel_domain
@callback
- def to_response(self) -> PanelRespons:
+ def to_response(self) -> PanelResponse:
"""Panel as dictionary."""
return {
"component_name": self.component_name,
@@ -631,7 +630,8 @@ class IndexView(web_urldispatcher.AbstractResource):
def get_info(self) -> dict[str, list[str]]: # type: ignore[override]
"""Return a dict with additional info useful for introspection."""
- return {"panels": list(self.hass.data[DATA_PANELS])}
+ panels = self.hass.data[DATA_PANELS]
+ return {"panels": list(panels)}
def raw_match(self, path: str) -> bool:
"""Perform a raw match against path."""
@@ -841,13 +841,13 @@ def websocket_subscribe_extra_js(
connection.send_message(websocket_api.result_message(msg["id"]))
-class PanelRespons(TypedDict):
+class PanelResponse(TypedDict):
"""Represent the panel response type."""
component_name: str
icon: str | None
title: str | None
config: dict[str, Any] | None
- url_path: str | None
+ url_path: str
require_admin: bool
config_panel_domain: str | None
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
index d27785dcea5..64b49588ba1 100644
--- a/homeassistant/components/frontend/manifest.json
+++ b/homeassistant/components/frontend/manifest.json
@@ -1,7 +1,6 @@
{
"domain": "frontend",
"name": "Home Assistant Frontend",
- "after_dependencies": ["backup"],
"codeowners": ["@home-assistant/frontend"],
"dependencies": [
"api",
@@ -21,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["home-assistant-frontend==20250205.0"]
+ "requirements": ["home-assistant-frontend==20250411.0"]
}
diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py
index cbcc3024aa7..a33a9de7ac5 100644
--- a/homeassistant/components/frontend/storage.py
+++ b/homeassistant/components/frontend/storage.py
@@ -12,8 +12,11 @@ from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.storage import Store
+from homeassistant.util.hass_dict import HassKey
-DATA_STORAGE = "frontend_storage"
+DATA_STORAGE: HassKey[tuple[dict[str, Store], dict[str, dict]]] = HassKey(
+ "frontend_storage"
+)
STORAGE_VERSION_USER_DATA = 1
diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py
index f6514da28ff..dc4f6bea989 100644
--- a/homeassistant/components/frontier_silicon/config_flow.py
+++ b/homeassistant/components/frontier_silicon/config_flow.py
@@ -108,8 +108,8 @@ class FrontierSiliconConfigFlow(ConfigFlow, domain=DOMAIN):
self._webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url)
except FSConnectionError:
return self.async_abort(reason="cannot_connect")
- except Exception as exception: # noqa: BLE001
- _LOGGER.debug(exception)
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
# try to login with default pin
diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py
index 6b0f987baa2..4f5e55d1536 100644
--- a/homeassistant/components/frontier_silicon/media_player.py
+++ b/homeassistant/components/frontier_silicon/media_player.py
@@ -22,7 +22,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FrontierSiliconConfigEntry
from .browse_media import browse_node, browse_top_level
@@ -34,7 +34,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FrontierSiliconConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Frontier Silicon entity."""
diff --git a/homeassistant/components/fujitsu_fglair/climate.py b/homeassistant/components/fujitsu_fglair/climate.py
index 5df6573e638..bf1df07823c 100644
--- a/homeassistant/components/fujitsu_fglair/climate.py
+++ b/homeassistant/components/fujitsu_fglair/climate.py
@@ -25,7 +25,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FGLairConfigEntry, FGLairCoordinator
from .entity import FGLairEntity
@@ -60,7 +60,7 @@ FUJI_TO_HA_SWING = {value: key for key, value in HA_TO_FUJI_SWING.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: FGLairConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up one Fujitsu HVAC device."""
async_add_entities(
diff --git a/homeassistant/components/fujitsu_fglair/config_flow.py b/homeassistant/components/fujitsu_fglair/config_flow.py
index c4b097ff0de..9369fd7b7cd 100644
--- a/homeassistant/components/fujitsu_fglair/config_flow.py
+++ b/homeassistant/components/fujitsu_fglair/config_flow.py
@@ -62,7 +62,7 @@ class FGLairConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except AylaAuthError:
errors["base"] = "invalid_auth"
- except Exception: # pylint: disable=broad-except
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
diff --git a/homeassistant/components/fujitsu_fglair/manifest.json b/homeassistant/components/fujitsu_fglair/manifest.json
index 330685f89fc..c8fed9b45c9 100644
--- a/homeassistant/components/fujitsu_fglair/manifest.json
+++ b/homeassistant/components/fujitsu_fglair/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/fujitsu_fglair",
"iot_class": "cloud_polling",
- "requirements": ["ayla-iot-unofficial==1.4.5"]
+ "requirements": ["ayla-iot-unofficial==1.4.7"]
}
diff --git a/homeassistant/components/fujitsu_fglair/sensor.py b/homeassistant/components/fujitsu_fglair/sensor.py
index e095a566dcb..3bb693e1068 100644
--- a/homeassistant/components/fujitsu_fglair/sensor.py
+++ b/homeassistant/components/fujitsu_fglair/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FGLairConfigEntry, FGLairCoordinator
from .entity import FGLairEntity
@@ -18,12 +18,13 @@ from .entity import FGLairEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FGLairConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up one Fujitsu HVAC device."""
async_add_entities(
FGLairOutsideTemperature(entry.runtime_data, device)
for device in entry.runtime_data.data.values()
+ if device.outdoor_temperature is not None
)
diff --git a/homeassistant/components/fully_kiosk/binary_sensor.py b/homeassistant/components/fully_kiosk/binary_sensor.py
index c039baa0397..8a25376f635 100644
--- a/homeassistant/components/fully_kiosk/binary_sensor.py
+++ b/homeassistant/components/fully_kiosk/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
@@ -38,7 +38,7 @@ SENSORS: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FullyKioskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser sensor."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/fully_kiosk/button.py b/homeassistant/components/fully_kiosk/button.py
index 4b172d45ae2..112ead983b9 100644
--- a/homeassistant/components/fully_kiosk/button.py
+++ b/homeassistant/components/fully_kiosk/button.py
@@ -15,7 +15,7 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
@@ -68,7 +68,7 @@ BUTTONS: tuple[FullyButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FullyKioskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser button entities."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/fully_kiosk/camera.py b/homeassistant/components/fully_kiosk/camera.py
index 7dfbe9e9257..6357660f8e8 100644
--- a/homeassistant/components/fully_kiosk/camera.py
+++ b/homeassistant/components/fully_kiosk/camera.py
@@ -7,7 +7,7 @@ from fullykiosk import FullyKioskError
from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
@@ -17,7 +17,7 @@ from .entity import FullyKioskEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: FullyKioskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the cameras."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fully_kiosk/image.py b/homeassistant/components/fully_kiosk/image.py
index e1a4240c9e9..158eae8671c 100644
--- a/homeassistant/components/fully_kiosk/image.py
+++ b/homeassistant/components/fully_kiosk/image.py
@@ -11,7 +11,7 @@ from fullykiosk import FullyKiosk, FullyKioskError
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import FullyKioskConfigEntry
@@ -38,7 +38,7 @@ IMAGES: tuple[FullyImageEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: FullyKioskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser image entities."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fully_kiosk/media_player.py b/homeassistant/components/fully_kiosk/media_player.py
index 24f002a7544..f6333a2941d 100644
--- a/homeassistant/components/fully_kiosk/media_player.py
+++ b/homeassistant/components/fully_kiosk/media_player.py
@@ -14,7 +14,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FullyKioskConfigEntry
from .const import AUDIOMANAGER_STREAM_MUSIC, MEDIA_SUPPORT_FULLYKIOSK
@@ -25,7 +25,7 @@ from .entity import FullyKioskEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FullyKioskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser media player entity."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/fully_kiosk/notify.py b/homeassistant/components/fully_kiosk/notify.py
index bddc07439b3..0a0c24c60e2 100644
--- a/homeassistant/components/fully_kiosk/notify.py
+++ b/homeassistant/components/fully_kiosk/notify.py
@@ -9,7 +9,7 @@ from fullykiosk import FullyKioskError
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
@@ -40,7 +40,7 @@ NOTIFIERS: tuple[FullyNotifyEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: FullyKioskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser notify entities."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fully_kiosk/number.py b/homeassistant/components/fully_kiosk/number.py
index ef25a69f1ee..8c386e85418 100644
--- a/homeassistant/components/fully_kiosk/number.py
+++ b/homeassistant/components/fully_kiosk/number.py
@@ -7,7 +7,7 @@ from contextlib import suppress
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
@@ -54,7 +54,7 @@ ENTITY_TYPES: tuple[NumberEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FullyKioskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser number entities."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py
index d92c5c17341..6094a6c4c23 100644
--- a/homeassistant/components/fully_kiosk/sensor.py
+++ b/homeassistant/components/fully_kiosk/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import FullyKioskConfigEntry
@@ -114,7 +114,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FullyKioskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser sensor."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/fully_kiosk/strings.json b/homeassistant/components/fully_kiosk/strings.json
index a4b466926f0..fdfdf7910ae 100644
--- a/homeassistant/components/fully_kiosk/strings.json
+++ b/homeassistant/components/fully_kiosk/strings.json
@@ -1,8 +1,8 @@
{
"common": {
- "data_description_password": "The Remote Admin Password from the Fully Kiosk Browser app settings.",
+ "data_description_password": "The Remote Admin password from the Fully Kiosk Browser app settings.",
"data_description_ssl": "Is the Fully Kiosk app configured to require SSL for the connection?",
- "data_description_verify_ssl": "Should SSL certificartes be verified? This should be off for self-signed certificates."
+ "data_description_verify_ssl": "Should SSL certificates be verified? This should be off for self-signed certificates."
},
"config": {
"step": {
@@ -151,7 +151,7 @@
}
},
"set_config": {
- "name": "Set Configuration",
+ "name": "Set configuration",
"description": "Sets a configuration parameter on Fully Kiosk Browser.",
"fields": {
"key": {
@@ -165,7 +165,7 @@
}
},
"start_application": {
- "name": "Start Application",
+ "name": "Start application",
"description": "Starts an application on the device running Fully Kiosk Browser.",
"fields": {
"application": {
diff --git a/homeassistant/components/fully_kiosk/switch.py b/homeassistant/components/fully_kiosk/switch.py
index 4adf8e8c924..804233dcc9e 100644
--- a/homeassistant/components/fully_kiosk/switch.py
+++ b/homeassistant/components/fully_kiosk/switch.py
@@ -11,7 +11,7 @@ from fullykiosk import FullyKiosk
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FullyKioskConfigEntry
from .coordinator import FullyKioskDataUpdateCoordinator
@@ -84,7 +84,7 @@ SWITCHES: tuple[FullySwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FullyKioskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fully Kiosk Browser switch."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/fyta/binary_sensor.py b/homeassistant/components/fyta/binary_sensor.py
index 66e5b2feeca..ac092f1d9cb 100644
--- a/homeassistant/components/fyta/binary_sensor.py
+++ b/homeassistant/components/fyta/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FytaConfigEntry
from .entity import FytaPlantEntity
@@ -83,7 +83,9 @@ BINARY_SENSORS: Final[list[FytaBinarySensorEntityDescription]] = [
async def async_setup_entry(
- hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FytaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FYTA binary sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fyta/config_flow.py b/homeassistant/components/fyta/config_flow.py
index 78cb7647785..9c5ab1de405 100644
--- a/homeassistant/components/fyta/config_flow.py
+++ b/homeassistant/components/fyta/config_flow.py
@@ -65,8 +65,8 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN):
return {"base": "invalid_auth"}
except FytaPasswordError:
return {"base": "invalid_auth", CONF_PASSWORD: "password_error"}
- except Exception as e: # noqa: BLE001
- _LOGGER.error(e)
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
return {"base": "unknown"}
finally:
await fyta.client.close()
diff --git a/homeassistant/components/fyta/image.py b/homeassistant/components/fyta/image.py
index 4a0b32f605b..326f2ddf570 100644
--- a/homeassistant/components/fyta/image.py
+++ b/homeassistant/components/fyta/image.py
@@ -7,14 +7,16 @@ from datetime import datetime
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import FytaConfigEntry, FytaCoordinator
from .entity import FytaPlantEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FytaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FYTA plant images."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json
index ea628f55c6c..615197203a8 100644
--- a/homeassistant/components/fyta/manifest.json
+++ b/homeassistant/components/fyta/manifest.json
@@ -9,5 +9,5 @@
"iot_class": "cloud_polling",
"loggers": ["fyta_cli"],
"quality_scale": "platinum",
- "requirements": ["fyta_cli==0.7.0"]
+ "requirements": ["fyta_cli==0.7.2"]
}
diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py
index 66c96ab697b..622945ae102 100644
--- a/homeassistant/components/fyta/sensor.py
+++ b/homeassistant/components/fyta/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import FytaConfigEntry, FytaCoordinator
@@ -154,7 +154,9 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [
async def async_setup_entry(
- hass: HomeAssistant, entry: FytaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: FytaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the FYTA sensors."""
coordinator: FytaCoordinator = entry.runtime_data
diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json
index 1a25f654e19..a10fa5bfc47 100644
--- a/homeassistant/components/fyta/strings.json
+++ b/homeassistant/components/fyta/strings.json
@@ -9,8 +9,8 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "username": "The email address to login to your FYTA account.",
- "password": "The password to login to your FYTA account."
+ "username": "The email address to log in to your FYTA account.",
+ "password": "The password to log in to your FYTA account."
}
},
"reauth_confirm": {
@@ -79,9 +79,9 @@
"state": {
"no_data": "No data",
"too_low": "Too low",
- "low": "Low",
+ "low": "[%key:common::state::low%]",
"perfect": "Perfect",
- "high": "High",
+ "high": "[%key:common::state::high%]",
"too_high": "Too high"
}
},
@@ -90,9 +90,9 @@
"state": {
"no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]",
"too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]",
- "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]",
+ "low": "[%key:common::state::low%]",
"perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]",
- "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]",
+ "high": "[%key:common::state::high%]",
"too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]"
}
},
@@ -101,9 +101,9 @@
"state": {
"no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]",
"too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]",
- "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]",
+ "low": "[%key:common::state::low%]",
"perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]",
- "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]",
+ "high": "[%key:common::state::high%]",
"too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]"
}
},
@@ -112,9 +112,9 @@
"state": {
"no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]",
"too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]",
- "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]",
+ "low": "[%key:common::state::low%]",
"perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]",
- "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]",
+ "high": "[%key:common::state::high%]",
"too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]"
}
},
@@ -123,9 +123,9 @@
"state": {
"no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]",
"too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]",
- "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]",
+ "low": "[%key:common::state::low%]",
"perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]",
- "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]",
+ "high": "[%key:common::state::high%]",
"too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]"
}
},
diff --git a/homeassistant/components/gaggenau/__init__.py b/homeassistant/components/gaggenau/__init__.py
new file mode 100644
index 00000000000..2c03410c35d
--- /dev/null
+++ b/homeassistant/components/gaggenau/__init__.py
@@ -0,0 +1 @@
+"""Gaggenau virtual integration."""
diff --git a/homeassistant/components/gaggenau/manifest.json b/homeassistant/components/gaggenau/manifest.json
new file mode 100644
index 00000000000..9dc38b2e4b3
--- /dev/null
+++ b/homeassistant/components/gaggenau/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "gaggenau",
+ "name": "Gaggenau",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+}
diff --git a/homeassistant/components/garages_amsterdam/binary_sensor.py b/homeassistant/components/garages_amsterdam/binary_sensor.py
index cf4b29f0af8..6cfd68c8a00 100644
--- a/homeassistant/components/garages_amsterdam/binary_sensor.py
+++ b/homeassistant/components/garages_amsterdam/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
GaragesAmsterdamConfigEntry,
@@ -42,7 +42,7 @@ BINARY_SENSORS: tuple[GaragesAmsterdamBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: GaragesAmsterdamConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/garages_amsterdam/sensor.py b/homeassistant/components/garages_amsterdam/sensor.py
index 8c16260c58b..5467ae73b1e 100644
--- a/homeassistant/components/garages_amsterdam/sensor.py
+++ b/homeassistant/components/garages_amsterdam/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import (
@@ -59,7 +59,7 @@ SENSORS: tuple[GaragesAmsterdamSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: GaragesAmsterdamConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/gardena_bluetooth/binary_sensor.py b/homeassistant/components/gardena_bluetooth/binary_sensor.py
index 4ee3dd511e9..b41988afd8c 100644
--- a/homeassistant/components/gardena_bluetooth/binary_sensor.py
+++ b/homeassistant/components/gardena_bluetooth/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import GardenaBluetoothConfigEntry
from .entity import GardenaBluetoothDescriptorEntity
@@ -53,7 +53,7 @@ DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: GardenaBluetoothConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensor based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/gardena_bluetooth/button.py b/homeassistant/components/gardena_bluetooth/button.py
index 8390baa5943..6a4f0395fe0 100644
--- a/homeassistant/components/gardena_bluetooth/button.py
+++ b/homeassistant/components/gardena_bluetooth/button.py
@@ -10,7 +10,7 @@ from gardena_bluetooth.parse import CharacteristicBool
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import GardenaBluetoothConfigEntry
from .entity import GardenaBluetoothDescriptorEntity
@@ -42,7 +42,7 @@ DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: GardenaBluetoothConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/gardena_bluetooth/config_flow.py b/homeassistant/components/gardena_bluetooth/config_flow.py
index c7631b62f47..613d0cf21db 100644
--- a/homeassistant/components/gardena_bluetooth/config_flow.py
+++ b/homeassistant/components/gardena_bluetooth/config_flow.py
@@ -41,6 +41,8 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
ProductType.PUMP,
ProductType.VALVE,
ProductType.WATER_COMPUTER,
+ ProductType.AUTOMATS,
+ ProductType.PRESSURE_TANKS,
):
_LOGGER.debug("Unsupported device: %s", manufacturer_data)
return False
diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json
index 28bba1015f5..8c9cda7d998 100644
--- a/homeassistant/components/gardena_bluetooth/manifest.json
+++ b/homeassistant/components/gardena_bluetooth/manifest.json
@@ -14,5 +14,5 @@
"documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
- "requirements": ["gardena-bluetooth==1.5.0"]
+ "requirements": ["gardena-bluetooth==1.6.0"]
}
diff --git a/homeassistant/components/gardena_bluetooth/number.py b/homeassistant/components/gardena_bluetooth/number.py
index eb95d9ff814..41b4f1e79ba 100644
--- a/homeassistant/components/gardena_bluetooth/number.py
+++ b/homeassistant/components/gardena_bluetooth/number.py
@@ -19,7 +19,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator
from .entity import GardenaBluetoothDescriptorEntity, GardenaBluetoothEntity
@@ -105,7 +105,7 @@ DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: GardenaBluetoothConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entity based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/gardena_bluetooth/sensor.py b/homeassistant/components/gardena_bluetooth/sensor.py
index 29d1a3155de..602f5bdfd6e 100644
--- a/homeassistant/components/gardena_bluetooth/sensor.py
+++ b/homeassistant/components/gardena_bluetooth/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator
@@ -95,7 +95,7 @@ DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: GardenaBluetoothConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Gardena Bluetooth sensor based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/gardena_bluetooth/switch.py b/homeassistant/components/gardena_bluetooth/switch.py
index 73c4867d040..de1fbe22470 100644
--- a/homeassistant/components/gardena_bluetooth/switch.py
+++ b/homeassistant/components/gardena_bluetooth/switch.py
@@ -9,7 +9,7 @@ from gardena_bluetooth.const import Valve
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator
from .entity import GardenaBluetoothEntity
@@ -18,7 +18,7 @@ from .entity import GardenaBluetoothEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: GardenaBluetoothConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/gardena_bluetooth/valve.py b/homeassistant/components/gardena_bluetooth/valve.py
index e51e5aa22ca..4138c7c4472 100644
--- a/homeassistant/components/gardena_bluetooth/valve.py
+++ b/homeassistant/components/gardena_bluetooth/valve.py
@@ -8,7 +8,7 @@ from gardena_bluetooth.const import Valve
from homeassistant.components.valve import ValveEntity, ValveEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import GardenaBluetoothConfigEntry, GardenaBluetoothCoordinator
from .entity import GardenaBluetoothEntity
@@ -19,7 +19,7 @@ FALLBACK_WATERING_TIME_IN_SECONDS = 60 * 60
async def async_setup_entry(
hass: HomeAssistant,
entry: GardenaBluetoothConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py
index 3f693241b24..d277ee54f6b 100644
--- a/homeassistant/components/gdacs/geo_location.py
+++ b/homeassistant/components/gdacs/geo_location.py
@@ -15,7 +15,7 @@ from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import DistanceConverter
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
@@ -52,7 +52,9 @@ SOURCE = "gdacs"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the GDACS Feed platform."""
manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id]
diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py
index c8205730da4..a204addd414 100644
--- a/homeassistant/components/gdacs/sensor.py
+++ b/homeassistant/components/gdacs/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import GdacsFeedEntityManager
@@ -37,7 +37,9 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the GDACS Feed platform."""
manager: GdacsFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id]
diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py
index edefbc55ca6..6821300fadf 100644
--- a/homeassistant/components/generic/camera.py
+++ b/homeassistant/components/generic/camera.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.template import Template
@@ -47,7 +47,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a generic IP Camera."""
diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json
index 35c5ae93b72..b5e25c08851 100644
--- a/homeassistant/components/generic/manifest.json
+++ b/homeassistant/components/generic/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["av==13.1.0", "Pillow==11.1.0"]
+ "requirements": ["av==13.1.0", "Pillow==11.2.1"]
}
diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py
index 69c4fb3cdf4..6e699745279 100644
--- a/homeassistant/components/generic_hygrostat/humidifier.py
+++ b/homeassistant/components/generic_hygrostat/humidifier.py
@@ -43,7 +43,10 @@ from homeassistant.core import (
)
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers.device import async_device_info_to_link_from_entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_state_report_event,
@@ -94,7 +97,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
@@ -116,7 +119,7 @@ async def _async_setup_config(
hass: HomeAssistant,
config: Mapping[str, Any],
unique_id: str | None,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
) -> None:
name: str = config[CONF_NAME]
switch_entity_id: str = config[CONF_HUMIDIFIER]
diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py
index fe6f0253f48..185040f02c9 100644
--- a/homeassistant/components/generic_thermostat/climate.py
+++ b/homeassistant/components/generic_thermostat/climate.py
@@ -49,7 +49,10 @@ from homeassistant.core import (
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers.device import async_device_info_to_link_from_entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import (
async_track_state_change_event,
async_track_time_interval,
@@ -123,7 +126,7 @@ PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend(PLATFORM_SCHEMA_COMMON.schema)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
await _async_setup_config(
@@ -152,7 +155,7 @@ async def _async_setup_config(
hass: HomeAssistant,
config: Mapping[str, Any],
unique_id: str | None,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the generic thermostat platform."""
@@ -536,10 +539,14 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
return
assert self._cur_temp is not None and self._target_temp is not None
- too_cold = self._target_temp >= self._cur_temp + self._cold_tolerance
- too_hot = self._cur_temp >= self._target_temp + self._hot_tolerance
+
+ min_temp = self._target_temp - self._cold_tolerance
+ max_temp = self._target_temp + self._hot_tolerance
+
if self._is_device_active:
- if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot):
+ if (self.ac_mode and self._cur_temp <= min_temp) or (
+ not self.ac_mode and self._cur_temp >= max_temp
+ ):
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
await self._async_heater_turn_off()
elif time is not None:
@@ -549,7 +556,9 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
self.heater_entity_id,
)
await self._async_heater_turn_on()
- elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold):
+ elif (self.ac_mode and self._cur_temp > max_temp) or (
+ not self.ac_mode and self._cur_temp < min_temp
+ ):
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
await self._async_heater_turn_on()
elif time is not None:
diff --git a/homeassistant/components/generic_thermostat/strings.json b/homeassistant/components/generic_thermostat/strings.json
index 58280e99543..735e0b0f9e6 100644
--- a/homeassistant/components/generic_thermostat/strings.json
+++ b/homeassistant/components/generic_thermostat/strings.json
@@ -21,17 +21,17 @@
"heater": "Switch entity used to cool or heat depending on A/C mode.",
"target_sensor": "Temperature sensor that reflects the current temperature.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
- "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor equals or goes below 24.5.",
+ "cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5."
}
},
"presets": {
"title": "Temperature presets",
"data": {
- "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
+ "home_temp": "[%key:common::state::home%]",
+ "away_temp": "[%key:common::state::not_home%]",
"comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
"eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
- "home_temp": "[%key:common::state::home%]",
"sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]",
"activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]"
}
@@ -63,10 +63,10 @@
"presets": {
"title": "[%key:component::generic_thermostat::config::step::presets::title%]",
"data": {
- "away_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
+ "home_temp": "[%key:common::state::home%]",
+ "away_temp": "[%key:common::state::not_home%]",
"comfort_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
"eco_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
- "home_temp": "[%key:common::state::home%]",
"sleep_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::sleep%]",
"activity_temp": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::activity%]"
}
diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py
index 01ccc950fd6..c2f25532453 100644
--- a/homeassistant/components/geniushub/binary_sensor.py
+++ b/homeassistant/components/geniushub/binary_sensor.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import GeniusHubConfigEntry
from .entity import GeniusDevice
@@ -16,7 +16,7 @@ GH_TYPE = "Receiver"
async def async_setup_entry(
hass: HomeAssistant,
entry: GeniusHubConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Genius Hub binary sensor entities."""
diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py
index e20d649541e..3c5cc4d4ad9 100644
--- a/homeassistant/components/geniushub/climate.py
+++ b/homeassistant/components/geniushub/climate.py
@@ -11,7 +11,7 @@ from homeassistant.components.climate import (
HVACMode,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import GeniusHubConfigEntry
from .entity import GeniusHeatingZone
@@ -29,7 +29,7 @@ GH_ZONES = ["radiator", "wet underfloor"]
async def async_setup_entry(
hass: HomeAssistant,
entry: GeniusHubConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Genius Hub climate entities."""
diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py
index a558ad18672..de7c047e934 100644
--- a/homeassistant/components/geniushub/sensor.py
+++ b/homeassistant/components/geniushub/sensor.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import GeniusHubConfigEntry
@@ -26,7 +26,7 @@ GH_LEVEL_MAPPING = {
async def async_setup_entry(
hass: HomeAssistant,
entry: GeniusHubConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Genius Hub sensor entities."""
diff --git a/homeassistant/components/geniushub/strings.json b/homeassistant/components/geniushub/strings.json
index 42d53c7fa00..79eee2c9a1b 100644
--- a/homeassistant/components/geniushub/strings.json
+++ b/homeassistant/components/geniushub/strings.json
@@ -45,7 +45,7 @@
},
"mode": {
"name": "[%key:common::config_flow::data::mode%]",
- "description": "One of: off, timer or footprint."
+ "description": "The zone's operating mode."
}
}
},
diff --git a/homeassistant/components/geniushub/switch.py b/homeassistant/components/geniushub/switch.py
index 3af82eb4e92..890ca1578be 100644
--- a/homeassistant/components/geniushub/switch.py
+++ b/homeassistant/components/geniushub/switch.py
@@ -10,7 +10,7 @@ import voluptuous as vol
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import ATTR_DURATION, GeniusHubConfigEntry
@@ -31,7 +31,7 @@ SET_SWITCH_OVERRIDE_SCHEMA: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
entry: GeniusHubConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Genius Hub switch entities."""
diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py
index 2807bd60611..60acf8f2cca 100644
--- a/homeassistant/components/geniushub/water_heater.py
+++ b/homeassistant/components/geniushub/water_heater.py
@@ -8,7 +8,7 @@ from homeassistant.components.water_heater import (
)
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import GeniusHubConfigEntry
from .entity import GeniusHeatingZone
@@ -36,7 +36,7 @@ GH_HEATERS = ["hot water temperature"]
async def async_setup_entry(
hass: HomeAssistant,
entry: GeniusHubConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Genius Hub water heater entities."""
diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py
index d55fe6e3ee6..e38c17008a5 100644
--- a/homeassistant/components/geo_json_events/__init__.py
+++ b/homeassistant/components/geo_json_events/__init__.py
@@ -4,25 +4,27 @@ from __future__ import annotations
import logging
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from .const import DOMAIN, PLATFORMS
-from .manager import GeoJsonFeedEntityManager
+from .const import PLATFORMS
+from .manager import GeoJsonConfigEntry, GeoJsonFeedEntityManager
_LOGGER = logging.getLogger(__name__)
-async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: GeoJsonConfigEntry
+) -> bool:
"""Set up the GeoJSON events component as config entry."""
- feeds = hass.data.setdefault(DOMAIN, {})
# Create feed entity manager for all platforms.
manager = GeoJsonFeedEntityManager(hass, config_entry)
- feeds[config_entry.entry_id] = manager
_LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id)
await remove_orphaned_entities(hass, config_entry.entry_id)
+
+ config_entry.runtime_data = manager
+ config_entry.async_on_unload(manager.async_stop)
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
await manager.async_init()
return True
@@ -46,10 +48,6 @@ async def remove_orphaned_entities(hass: HomeAssistant, entry_id: str) -> None:
entity_registry.async_remove(entry.entity_id)
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: GeoJsonConfigEntry) -> bool:
"""Unload the GeoJSON events config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- manager: GeoJsonFeedEntityManager = hass.data[DOMAIN].pop(entry.entry_id)
- await manager.async_stop()
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py
index e0067bcfdc9..a119571a0ca 100644
--- a/homeassistant/components/geo_json_events/geo_location.py
+++ b/homeassistant/components/geo_json_events/geo_location.py
@@ -9,29 +9,24 @@ from typing import Any
from aio_geojson_generic_client.feed_entry import GenericFeedEntry
from homeassistant.components.geo_location import GeolocationEvent
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import GeoJsonFeedEntityManager
-from .const import (
- ATTR_EXTERNAL_ID,
- DOMAIN,
- SIGNAL_DELETE_ENTITY,
- SIGNAL_UPDATE_ENTITY,
- SOURCE,
-)
+from .const import ATTR_EXTERNAL_ID, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY, SOURCE
+from .manager import GeoJsonConfigEntry, GeoJsonFeedEntityManager
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: GeoJsonConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the GeoJSON Events platform."""
- manager: GeoJsonFeedEntityManager = hass.data[DOMAIN][entry.entry_id]
+ manager = entry.runtime_data
@callback
def async_add_geolocation(
diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py
index deff15436a6..223d3bf571f 100644
--- a/homeassistant/components/geo_json_events/manager.py
+++ b/homeassistant/components/geo_json_events/manager.py
@@ -25,6 +25,8 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
+type GeoJsonConfigEntry = ConfigEntry[GeoJsonFeedEntityManager]
+
class GeoJsonFeedEntityManager:
"""Feed Entity Manager for GeoJSON feeds."""
diff --git a/homeassistant/components/geocaching/manifest.json b/homeassistant/components/geocaching/manifest.json
index 127519ca5d0..4617bd1c57b 100644
--- a/homeassistant/components/geocaching/manifest.json
+++ b/homeassistant/components/geocaching/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/geocaching",
"iot_class": "cloud_polling",
- "requirements": ["geocachingapi==0.2.1"]
+ "requirements": ["geocachingapi==0.3.0"]
}
diff --git a/homeassistant/components/geocaching/sensor.py b/homeassistant/components/geocaching/sensor.py
index c082e5308a1..c7894afc5ac 100644
--- a/homeassistant/components/geocaching/sensor.py
+++ b/homeassistant/components/geocaching/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -64,7 +64,9 @@ SENSORS: tuple[GeocachingSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Geocaching sensor entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py
index 2ad3c1772de..c74dad1cebb 100644
--- a/homeassistant/components/geofency/device_tracker.py
+++ b/homeassistant/components/geofency/device_tracker.py
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE
@@ -16,7 +16,7 @@ from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Geofency config entry."""
diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py
index 78313e102e0..96a1c3c09b2 100644
--- a/homeassistant/components/geonetnz_quakes/geo_location.py
+++ b/homeassistant/components/geonetnz_quakes/geo_location.py
@@ -14,7 +14,7 @@ from homeassistant.const import ATTR_TIME, UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import DistanceConverter
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
@@ -38,7 +38,9 @@ SOURCE = "geonetnz_quakes"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the GeoNet NZ Quakes Feed platform."""
manager: GeonetnzQuakesFeedEntityManager = hass.data[DOMAIN][FEED][entry.entry_id]
diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py
index 2fce3e93d12..b8a1e2dd4db 100644
--- a/homeassistant/components/geonetnz_quakes/sensor.py
+++ b/homeassistant/components/geonetnz_quakes/sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN, FEED
@@ -31,7 +31,9 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the GeoNet NZ Quakes Feed platform."""
manager = hass.data[DOMAIN][FEED][entry.entry_id]
diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py
index 980679cc64f..bde04acb895 100644
--- a/homeassistant/components/geonetnz_volcano/sensor.py
+++ b/homeassistant/components/geonetnz_volcano/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import DistanceConverter
@@ -31,7 +31,9 @@ ATTR_LAST_UPDATE_SUCCESSFUL = "feed_last_update_successful"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the GeoNet NZ Volcano Feed platform."""
manager = hass.data[DOMAIN][FEED][entry.entry_id]
diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py
index c76efbcf361..31f704fcacc 100644
--- a/homeassistant/components/gios/__init__.py
+++ b/homeassistant/components/gios/__init__.py
@@ -4,9 +4,14 @@ from __future__ import annotations
import logging
+from aiohttp.client_exceptions import ClientConnectorError
+from gios import Gios
+from gios.exceptions import GiosError
+
from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -36,8 +41,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool
device_registry.async_update_device(device_entry.id, new_identifiers={new_ids})
websession = async_get_clientsession(hass)
+ try:
+ gios = await Gios.create(websession, station_id)
+ except (GiosError, ConnectionError, ClientConnectorError) as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={
+ "entry": entry.title,
+ "error": repr(err),
+ },
+ ) from err
- coordinator = GiosDataUpdateCoordinator(hass, entry, websession, station_id)
+ coordinator = GiosDataUpdateCoordinator(hass, entry, gios)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = GiosData(coordinator)
diff --git a/homeassistant/components/gios/config_flow.py b/homeassistant/components/gios/config_flow.py
index a089aeab820..9b242a8cc99 100644
--- a/homeassistant/components/gios/config_flow.py
+++ b/homeassistant/components/gios/config_flow.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
-from typing import Any
+from typing import TYPE_CHECKING, Any
from aiohttp.client_exceptions import ClientConnectorError
from gios import ApiError, Gios, InvalidSensorsDataError, NoStationError
@@ -12,6 +12,12 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_NAME
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
from .const import API_TIMEOUT, CONF_STATION_ID, DOMAIN
@@ -27,40 +33,59 @@ class GiosFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by the user."""
errors = {}
+ websession = async_get_clientsession(self.hass)
+
if user_input is not None:
+ station_id = user_input[CONF_STATION_ID]
+
try:
- await self.async_set_unique_id(
- str(user_input[CONF_STATION_ID]), raise_on_progress=False
- )
+ await self.async_set_unique_id(station_id, raise_on_progress=False)
self._abort_if_unique_id_configured()
- websession = async_get_clientsession(self.hass)
-
async with asyncio.timeout(API_TIMEOUT):
- gios = Gios(user_input[CONF_STATION_ID], websession)
+ gios = await Gios.create(websession, int(station_id))
await gios.async_update()
- assert gios.station_name is not None
+ # GIOS treats station ID as int
+ user_input[CONF_STATION_ID] = int(station_id)
+
+ if TYPE_CHECKING:
+ assert gios.station_name is not None
+
return self.async_create_entry(
title=gios.station_name,
data=user_input,
)
except (ApiError, ClientConnectorError, TimeoutError):
errors["base"] = "cannot_connect"
- except NoStationError:
- errors[CONF_STATION_ID] = "wrong_station_id"
except InvalidSensorsDataError:
errors[CONF_STATION_ID] = "invalid_sensors_data"
+ try:
+ gios = await Gios.create(websession)
+ except (ApiError, ClientConnectorError, NoStationError):
+ return self.async_abort(reason="cannot_connect")
+
+ options: list[SelectOptionDict] = [
+ SelectOptionDict(value=str(station.id), label=station.name)
+ for station in gios.measurement_stations.values()
+ ]
+
+ schema: vol.Schema = vol.Schema(
+ {
+ vol.Required(CONF_STATION_ID): SelectSelector(
+ SelectSelectorConfig(
+ options=options,
+ sort=True,
+ mode=SelectSelectorMode.DROPDOWN,
+ ),
+ ),
+ vol.Optional(CONF_NAME, default=self.hass.config.location_name): str,
+ }
+ )
+
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_STATION_ID): int,
- vol.Optional(
- CONF_NAME, default=self.hass.config.location_name
- ): str,
- }
- ),
+ data_schema=schema,
errors=errors,
)
diff --git a/homeassistant/components/gios/const.py b/homeassistant/components/gios/const.py
index a8490511ab8..2294e89c961 100644
--- a/homeassistant/components/gios/const.py
+++ b/homeassistant/components/gios/const.py
@@ -13,7 +13,7 @@ SCAN_INTERVAL: Final = timedelta(minutes=30)
DOMAIN: Final = "gios"
MANUFACTURER: Final = "Główny Inspektorat Ochrony Środowiska"
-URL = "http://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id}"
+URL = "https://powietrze.gios.gov.pl/pjp/current/station_details/info/{station_id}"
API_TIMEOUT: Final = 30
diff --git a/homeassistant/components/gios/coordinator.py b/homeassistant/components/gios/coordinator.py
index be4b41ca6ee..eb0dd82eb67 100644
--- a/homeassistant/components/gios/coordinator.py
+++ b/homeassistant/components/gios/coordinator.py
@@ -6,7 +6,6 @@ import asyncio
from dataclasses import dataclass
import logging
-from aiohttp import ClientSession
from aiohttp.client_exceptions import ClientConnectorError
from gios import Gios
from gios.exceptions import GiosError
@@ -39,11 +38,10 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]):
self,
hass: HomeAssistant,
config_entry: GiosConfigEntry,
- session: ClientSession,
- station_id: int,
+ gios: Gios,
) -> None:
"""Class to manage fetching GIOS data API."""
- self.gios = Gios(station_id, session)
+ self.gios = gios
super().__init__(
hass,
@@ -59,4 +57,11 @@ class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]):
async with asyncio.timeout(API_TIMEOUT):
return await self.gios.async_update()
except (GiosError, ClientConnectorError) as error:
- raise UpdateFailed(error) from error
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ translation_placeholders={
+ "entry": self.config_entry.title,
+ "error": repr(error),
+ },
+ ) from error
diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json
index 3d2e719fab6..8deb2eee414 100644
--- a/homeassistant/components/gios/manifest.json
+++ b/homeassistant/components/gios/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["dacite", "gios"],
- "requirements": ["gios==5.0.0"]
+ "requirements": ["gios==6.0.0"]
}
diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py
index 096ea838a41..67997a01dc6 100644
--- a/homeassistant/components/gios/sensor.py
+++ b/homeassistant/components/gios/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_N
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -158,7 +158,9 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: GiosConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: GiosConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a GIOS entities from a config_entry."""
name = entry.data[CONF_NAME]
diff --git a/homeassistant/components/gios/strings.json b/homeassistant/components/gios/strings.json
index fc82f1c843d..eca23159a13 100644
--- a/homeassistant/components/gios/strings.json
+++ b/homeassistant/components/gios/strings.json
@@ -5,17 +5,17 @@
"title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)",
"data": {
"name": "[%key:common::config_flow::data::name%]",
- "station_id": "ID of the measuring station"
+ "station_id": "Measuring station"
}
}
},
"error": {
- "wrong_station_id": "ID of the measuring station is not correct.",
"invalid_sensors_data": "Invalid sensors' data for this measuring station.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
},
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_location%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_location%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}
},
"system_health": {
@@ -170,5 +170,13 @@
}
}
}
+ },
+ "exceptions": {
+ "cannot_connect": {
+ "message": "An error occurred while connecting to the GIOS API for {entry}: {error}"
+ },
+ "update_error": {
+ "message": "An error occurred while retrieving data from the GIOS API for {entry}: {error}"
+ }
}
}
diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py
index a7ecb4ec8da..35985ed50d5 100644
--- a/homeassistant/components/github/sensor.py
+++ b/homeassistant/components/github/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -139,7 +139,7 @@ SENSOR_DESCRIPTIONS: tuple[GitHubSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: GithubConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up GitHub sensor based on a config entry."""
repositories = entry.runtime_data
diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py
index 61d88b744bf..67f57ee0fbf 100644
--- a/homeassistant/components/glances/sensor.py
+++ b/homeassistant/components/glances/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CPU_ICON, DOMAIN
@@ -288,7 +288,7 @@ SENSOR_TYPES = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: GlancesConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Glances sensors."""
diff --git a/homeassistant/components/go2rtc/const.py b/homeassistant/components/go2rtc/const.py
index 234411936cb..491b2269043 100644
--- a/homeassistant/components/go2rtc/const.py
+++ b/homeassistant/components/go2rtc/const.py
@@ -6,4 +6,4 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
-RECOMMENDED_VERSION = "1.9.8"
+RECOMMENDED_VERSION = "1.9.9"
diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py
index 6bd061879eb..86287dc35eb 100644
--- a/homeassistant/components/goalzero/binary_sensor.py
+++ b/homeassistant/components/goalzero/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import GoalZeroConfigEntry
from .entity import GoalZeroEntity
@@ -44,7 +44,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: GoalZeroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Goal Zero Yeti sensor."""
async_add_entities(
diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py
index f565c216745..7b5f8955947 100644
--- a/homeassistant/components/goalzero/sensor.py
+++ b/homeassistant/components/goalzero/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import GoalZeroConfigEntry
@@ -131,7 +131,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: GoalZeroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Goal Zero Yeti sensor."""
async_add_entities(
diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py
index daff4ee5fec..00a1ad936d8 100644
--- a/homeassistant/components/goalzero/switch.py
+++ b/homeassistant/components/goalzero/switch.py
@@ -6,7 +6,7 @@ from typing import Any, cast
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import GoalZeroConfigEntry
from .entity import GoalZeroEntity
@@ -30,7 +30,7 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: GoalZeroConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Goal Zero Yeti switch."""
async_add_entities(
diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py
index 0348d0b428c..cebff656d5d 100644
--- a/homeassistant/components/gogogate2/config_flow.py
+++ b/homeassistant/components/gogogate2/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import dataclasses
+import logging
import re
from typing import Any, Self
@@ -27,6 +28,8 @@ from homeassistant.helpers.service_info.zeroconf import (
from .common import get_api
from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
DEVICE_NAMES = {
DEVICE_TYPE_GOGOGATE2: "Gogogate2",
DEVICE_TYPE_ISMARTGATE: "ismartgate",
@@ -115,7 +118,8 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN):
else:
errors["base"] = "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "cannot_connect"
if self._ip_address and self._device_type:
diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py
index 6bd38a0bc01..9492108d4b2 100644
--- a/homeassistant/components/gogogate2/cover.py
+++ b/homeassistant/components/gogogate2/cover.py
@@ -18,7 +18,7 @@ from homeassistant.components.cover import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import cover_unique_id, get_data_update_coordinator
from .coordinator import DeviceDataUpdateCoordinator
@@ -28,7 +28,7 @@ from .entity import GoGoGate2Entity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the config entry."""
data_update_coordinator = get_data_update_coordinator(hass, config_entry)
diff --git a/homeassistant/components/gogogate2/sensor.py b/homeassistant/components/gogogate2/sensor.py
index c7740e24825..ce86ca9ac43 100644
--- a/homeassistant/components/gogogate2/sensor.py
+++ b/homeassistant/components/gogogate2/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import get_data_update_coordinator, sensor_unique_id
from .coordinator import DeviceDataUpdateCoordinator
@@ -26,7 +26,7 @@ SENSOR_ID_WIRED = "WIRE"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the config entry."""
data_update_coordinator = get_data_update_coordinator(hass, config_entry)
diff --git a/homeassistant/components/goodwe/button.py b/homeassistant/components/goodwe/button.py
index d3d96a19a76..e93b23570db 100644
--- a/homeassistant/components/goodwe/button.py
+++ b/homeassistant/components/goodwe/button.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER
@@ -37,7 +37,7 @@ SYNCHRONIZE_CLOCK = GoodweButtonEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the inverter button entities from a config entry."""
inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER]
diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py
index ce36bb35bf9..0a61ac19d64 100644
--- a/homeassistant/components/goodwe/number.py
+++ b/homeassistant/components/goodwe/number.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER
@@ -87,7 +87,7 @@ NUMBERS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the inverter select entities from a config entry."""
inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER]
diff --git a/homeassistant/components/goodwe/select.py b/homeassistant/components/goodwe/select.py
index 4fa84c8401f..340e10bfa0f 100644
--- a/homeassistant/components/goodwe/select.py
+++ b/homeassistant/components/goodwe/select.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, KEY_DEVICE_INFO, KEY_INVERTER
@@ -40,7 +40,7 @@ OPERATION_MODE = SelectEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the inverter select entities from a config entry."""
inverter = hass.data[DOMAIN][config_entry.entry_id][KEY_INVERTER]
diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py
index 5a88ac612da..d2dce2770e4 100644
--- a/homeassistant/components/goodwe/sensor.py
+++ b/homeassistant/components/goodwe/sensor.py
@@ -33,7 +33,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_time
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -166,7 +166,7 @@ TEXT_SENSOR = GoodweSensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the GoodWe inverter from a config entry."""
entities: list[InverterSensor] = []
diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py
index 82208420b8c..a62d2bf1d6b 100644
--- a/homeassistant/components/google/calendar.py
+++ b/homeassistant/components/google/calendar.py
@@ -43,7 +43,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import entity_platform, entity_registry as er
from homeassistant.helpers.entity import generate_entity_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -89,6 +89,7 @@ OPAQUE = "opaque"
RRULE_PREFIX = "RRULE:"
SERVICE_CREATE_EVENT = "create_event"
+FILTERED_EVENT_TYPES = [EventTypeEnum.BIRTHDAY, EventTypeEnum.WORKING_LOCATION]
@dataclasses.dataclass(frozen=True, kw_only=True)
@@ -103,7 +104,7 @@ class GoogleCalendarEntityDescription(CalendarEntityDescription):
search: str | None
local_sync: bool
device_id: str
- working_location: bool = False
+ event_type: EventTypeEnum | None = None
def _get_entity_descriptions(
@@ -173,14 +174,24 @@ def _get_entity_descriptions(
local_sync,
)
if calendar_item.primary and local_sync:
- _LOGGER.debug("work location entity")
+ # Create a separate calendar for birthdays
+ entity_descriptions.append(
+ dataclasses.replace(
+ entity_description,
+ key=f"{key}-birthdays",
+ translation_key="birthdays",
+ event_type=EventTypeEnum.BIRTHDAY,
+ name=None,
+ entity_id=None,
+ )
+ )
# Create an optional disabled by default entity for Work Location
entity_descriptions.append(
dataclasses.replace(
entity_description,
key=f"{key}-work-location",
translation_key="working_location",
- working_location=True,
+ event_type=EventTypeEnum.WORKING_LOCATION,
name=None,
entity_id=None,
entity_registry_enabled_default=False,
@@ -192,7 +203,7 @@ def _get_entity_descriptions(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the google calendar platform."""
calendar_service = hass.data[DOMAIN][config_entry.entry_id][DATA_SERVICE]
@@ -383,9 +394,18 @@ class GoogleCalendarEntity(
for attendee in event.attendees
):
return False
-
- if event.event_type == EventTypeEnum.WORKING_LOCATION:
- return self.entity_description.working_location
+ # Calendar enttiy may be limited to a specific event type
+ if (
+ self.entity_description.event_type is not None
+ and self.entity_description.event_type != event.event_type
+ ):
+ return False
+ # Default calendar entity omits the special types but includes all the others
+ if (
+ self.entity_description.event_type is None
+ and event.event_type in FILTERED_EVENT_TYPES
+ ):
+ return False
if self._ignore_availability:
return True
return event.transparency == OPAQUE
diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py
index 8ae09b58957..add75f5e95b 100644
--- a/homeassistant/components/google/config_flow.py
+++ b/homeassistant/components/google/config_flow.py
@@ -197,7 +197,12 @@ class OAuth2FlowHandler(
"Error reading primary calendar, make sure Google Calendar API is enabled: %s",
err,
)
- return self.async_abort(reason="api_disabled")
+ return self.async_abort(
+ reason="calendar_api_disabled",
+ description_placeholders={
+ "calendar_api_url": "https://console.cloud.google.com/apis/library/calendar-json.googleapis.com"
+ },
+ )
except ApiException as err:
_LOGGER.error("Error reading primary calendar: %s", err)
return self.async_abort(reason="cannot_connect")
diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json
index bd04597e513..2bedc7a3163 100644
--- a/homeassistant/components/google/manifest.json
+++ b/homeassistant/components/google/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
- "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==8.3.0"]
+ "requirements": ["gcal-sync==7.0.0", "oauth2client==4.1.3", "ical==9.1.0"]
}
diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json
index 5ee0cdd9c14..4f3e27af27e 100644
--- a/homeassistant/components/google/strings.json
+++ b/homeassistant/components/google/strings.json
@@ -28,7 +28,7 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
"code_expired": "Authentication code expired or credential setup is invalid, please try again.",
- "api_disabled": "You must enable the Google Calendar API in the Google Cloud Console"
+ "calendar_api_disabled": "You must [enable the Google Calendar API]({calendar_api_url}) in the Google Cloud Console"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
@@ -131,6 +131,9 @@
"calendar": {
"working_location": {
"name": "Working location"
+ },
+ "birthdays": {
+ "name": "Birthdays"
}
}
}
diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py
index cf3a42e251b..58560d7b8d1 100644
--- a/homeassistant/components/google_assistant/button.py
+++ b/homeassistant/components/google_assistant/button.py
@@ -8,7 +8,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .const import CONF_PROJECT_ID, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN
@@ -18,7 +18,7 @@ from .http import GoogleConfig
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform."""
yaml_config: ConfigType = hass.data[DOMAIN][DATA_CONFIG]
diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py
index 8132ecaae2c..71738c9d13e 100644
--- a/homeassistant/components/google_assistant/const.py
+++ b/homeassistant/components/google_assistant/const.py
@@ -14,6 +14,7 @@ from homeassistant.components import (
input_boolean,
input_button,
input_select,
+ lawn_mower,
light,
lock,
media_player,
@@ -58,6 +59,7 @@ DEFAULT_EXPOSED_DOMAINS = [
"humidifier",
"input_boolean",
"input_select",
+ "lawn_mower",
"light",
"lock",
"media_player",
@@ -88,6 +90,7 @@ TYPE_GATE = f"{PREFIX_TYPES}GATE"
TYPE_HUMIDIFIER = f"{PREFIX_TYPES}HUMIDIFIER"
TYPE_LIGHT = f"{PREFIX_TYPES}LIGHT"
TYPE_LOCK = f"{PREFIX_TYPES}LOCK"
+TYPE_MOWER = f"{PREFIX_TYPES}MOWER"
TYPE_OUTLET = f"{PREFIX_TYPES}OUTLET"
TYPE_RECEIVER = f"{PREFIX_TYPES}AUDIO_VIDEO_RECEIVER"
TYPE_SCENE = f"{PREFIX_TYPES}SCENE"
@@ -149,6 +152,7 @@ DOMAIN_TO_GOOGLE_TYPES = {
input_boolean.DOMAIN: TYPE_SWITCH,
input_button.DOMAIN: TYPE_SCENE,
input_select.DOMAIN: TYPE_SENSOR,
+ lawn_mower.DOMAIN: TYPE_MOWER,
light.DOMAIN: TYPE_LIGHT,
lock.DOMAIN: TYPE_LOCK,
media_player.DOMAIN: TYPE_SETTOP,
diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py
index 44251a3be04..9edd340d7d9 100644
--- a/homeassistant/components/google_assistant/trait.py
+++ b/homeassistant/components/google_assistant/trait.py
@@ -21,6 +21,7 @@ from homeassistant.components import (
input_boolean,
input_button,
input_select,
+ lawn_mower,
light,
lock,
media_player,
@@ -42,6 +43,7 @@ from homeassistant.components.climate import ClimateEntityFeature
from homeassistant.components.cover import CoverEntityFeature
from homeassistant.components.fan import FanEntityFeature
from homeassistant.components.humidifier import HumidifierEntityFeature
+from homeassistant.components.lawn_mower import LawnMowerEntityFeature
from homeassistant.components.light import LightEntityFeature
from homeassistant.components.lock import LockState
from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType
@@ -714,7 +716,7 @@ class DockTrait(_Trait):
@staticmethod
def supported(domain, features, device_class, _):
"""Test if state is supported."""
- return domain == vacuum.DOMAIN
+ return domain in (vacuum.DOMAIN, lawn_mower.DOMAIN)
def sync_attributes(self) -> dict[str, Any]:
"""Return dock attributes for a sync request."""
@@ -722,17 +724,32 @@ class DockTrait(_Trait):
def query_attributes(self) -> dict[str, Any]:
"""Return dock query attributes."""
- return {"isDocked": self.state.state == vacuum.VacuumActivity.DOCKED}
+ domain = self.state.domain
+ state = self.state.state
+ if domain == vacuum.DOMAIN:
+ return {"isDocked": state == vacuum.VacuumActivity.DOCKED}
+ if domain == lawn_mower.DOMAIN:
+ return {"isDocked": state == lawn_mower.LawnMowerActivity.DOCKED}
+ raise NotImplementedError(f"Unsupported domain {domain}")
async def execute(self, command, data, params, challenge):
"""Execute a dock command."""
- await self.hass.services.async_call(
- self.state.domain,
- vacuum.SERVICE_RETURN_TO_BASE,
- {ATTR_ENTITY_ID: self.state.entity_id},
- blocking=not self.config.should_report_state,
- context=data.context,
- )
+ domain = self.state.domain
+ service: str | None = None
+
+ if domain == vacuum.DOMAIN:
+ service = vacuum.SERVICE_RETURN_TO_BASE
+ elif domain == lawn_mower.DOMAIN:
+ service = lawn_mower.SERVICE_DOCK
+
+ if service:
+ await self.hass.services.async_call(
+ self.state.domain,
+ service,
+ {ATTR_ENTITY_ID: self.state.entity_id},
+ blocking=not self.config.should_report_state,
+ context=data.context,
+ )
@register_trait
@@ -843,7 +860,7 @@ class StartStopTrait(_Trait):
@staticmethod
def supported(domain, features, device_class, _):
"""Test if state is supported."""
- if domain == vacuum.DOMAIN:
+ if domain in (vacuum.DOMAIN, lawn_mower.DOMAIN):
return True
if (
@@ -863,6 +880,12 @@ class StartStopTrait(_Trait):
& VacuumEntityFeature.PAUSE
!= 0
}
+ if domain == lawn_mower.DOMAIN:
+ return {
+ "pausable": self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ & LawnMowerEntityFeature.PAUSE
+ != 0
+ }
if domain in COVER_VALVE_DOMAINS:
return {}
@@ -878,6 +901,11 @@ class StartStopTrait(_Trait):
"isRunning": state == vacuum.VacuumActivity.CLEANING,
"isPaused": state == vacuum.VacuumActivity.PAUSED,
}
+ if domain == lawn_mower.DOMAIN:
+ return {
+ "isRunning": state == lawn_mower.LawnMowerActivity.MOWING,
+ "isPaused": state == lawn_mower.LawnMowerActivity.PAUSED,
+ }
if domain in COVER_VALVE_DOMAINS:
return {
@@ -896,46 +924,52 @@ class StartStopTrait(_Trait):
if domain == vacuum.DOMAIN:
await self._execute_vacuum(command, data, params, challenge)
return
+ if domain == lawn_mower.DOMAIN:
+ await self._execute_lawn_mower(command, data, params, challenge)
+ return
if domain in COVER_VALVE_DOMAINS:
await self._execute_cover_or_valve(command, data, params, challenge)
return
async def _execute_vacuum(self, command, data, params, challenge):
"""Execute a StartStop command."""
+ service: str | None = None
if command == COMMAND_START_STOP:
- if params["start"]:
- await self.hass.services.async_call(
- self.state.domain,
- vacuum.SERVICE_START,
- {ATTR_ENTITY_ID: self.state.entity_id},
- blocking=not self.config.should_report_state,
- context=data.context,
- )
- else:
- await self.hass.services.async_call(
- self.state.domain,
- vacuum.SERVICE_STOP,
- {ATTR_ENTITY_ID: self.state.entity_id},
- blocking=not self.config.should_report_state,
- context=data.context,
- )
+ service = vacuum.SERVICE_START if params["start"] else vacuum.SERVICE_STOP
elif command == COMMAND_PAUSE_UNPAUSE:
- if params["pause"]:
- await self.hass.services.async_call(
- self.state.domain,
- vacuum.SERVICE_PAUSE,
- {ATTR_ENTITY_ID: self.state.entity_id},
- blocking=not self.config.should_report_state,
- context=data.context,
- )
- else:
- await self.hass.services.async_call(
- self.state.domain,
- vacuum.SERVICE_START,
- {ATTR_ENTITY_ID: self.state.entity_id},
- blocking=not self.config.should_report_state,
- context=data.context,
- )
+ service = vacuum.SERVICE_PAUSE if params["pause"] else vacuum.SERVICE_START
+ if service:
+ await self.hass.services.async_call(
+ self.state.domain,
+ service,
+ {ATTR_ENTITY_ID: self.state.entity_id},
+ blocking=not self.config.should_report_state,
+ context=data.context,
+ )
+
+ async def _execute_lawn_mower(self, command, data, params, challenge):
+ """Execute a StartStop command."""
+ service: str | None = None
+ if command == COMMAND_START_STOP:
+ service = (
+ lawn_mower.SERVICE_START_MOWING
+ if params["start"]
+ else lawn_mower.SERVICE_DOCK
+ )
+ elif command == COMMAND_PAUSE_UNPAUSE:
+ service = (
+ lawn_mower.SERVICE_PAUSE
+ if params["pause"]
+ else lawn_mower.SERVICE_START_MOWING
+ )
+ if service:
+ await self.hass.services.async_call(
+ self.state.domain,
+ service,
+ {ATTR_ENTITY_ID: self.state.entity_id},
+ blocking=not self.config.should_report_state,
+ context=data.context,
+ )
async def _execute_cover_or_valve(self, command, data, params, challenge):
"""Execute a StartStop command."""
diff --git a/homeassistant/components/google_assistant_sdk/__init__.py b/homeassistant/components/google_assistant_sdk/__init__.py
index 4ea496f2824..a08d7554516 100644
--- a/homeassistant/components/google_assistant_sdk/__init__.py
+++ b/homeassistant/components/google_assistant_sdk/__init__.py
@@ -10,7 +10,7 @@ from google.oauth2.credentials import Credentials
import voluptuous as vol
from homeassistant.components import conversation
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, Platform
from homeassistant.core import (
HomeAssistant,
@@ -99,12 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
hass.data[DOMAIN].pop(entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
for service_name in hass.services.async_services_for_domain(DOMAIN):
hass.services.async_remove(DOMAIN, service_name)
diff --git a/homeassistant/components/google_assistant_sdk/manifest.json b/homeassistant/components/google_assistant_sdk/manifest.json
index 85469a464b3..70e93f39f42 100644
--- a/homeassistant/components/google_assistant_sdk/manifest.json
+++ b/homeassistant/components/google_assistant_sdk/manifest.json
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/google_assistant_sdk",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["gassist-text==0.0.11"],
+ "requirements": ["gassist-text==0.0.12"],
"single_config_entry": true
}
diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json
index 3e08b6254db..3e6371cbe23 100644
--- a/homeassistant/components/google_cloud/manifest.json
+++ b/homeassistant/components/google_cloud/manifest.json
@@ -8,7 +8,7 @@
"integration_type": "service",
"iot_class": "cloud_push",
"requirements": [
- "google-cloud-texttospeech==2.17.2",
- "google-cloud-speech==2.27.0"
+ "google-cloud-texttospeech==2.25.1",
+ "google-cloud-speech==2.31.1"
]
}
diff --git a/homeassistant/components/google_cloud/stt.py b/homeassistant/components/google_cloud/stt.py
index ebca586d1a3..cd5055383ea 100644
--- a/homeassistant/components/google_cloud/stt.py
+++ b/homeassistant/components/google_cloud/stt.py
@@ -6,6 +6,7 @@ from collections.abc import AsyncGenerator, AsyncIterable
import logging
from google.api_core.exceptions import GoogleAPIError, Unauthenticated
+from google.api_core.retry import AsyncRetry
from google.cloud import speech_v1
from homeassistant.components.stt import (
@@ -22,7 +23,7 @@ from homeassistant.components.stt import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_SERVICE_ACCOUNT_INFO,
@@ -38,7 +39,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Google Cloud speech platform via config entry."""
service_account_info = config_entry.data[CONF_SERVICE_ACCOUNT_INFO]
@@ -127,6 +128,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
responses = await self._client.streaming_recognize(
requests=request_generator(),
timeout=10,
+ retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
)
transcript = ""
diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py
index 7f22dda4faf..16519645dee 100644
--- a/homeassistant/components/google_cloud/tts.py
+++ b/homeassistant/components/google_cloud/tts.py
@@ -7,6 +7,7 @@ from pathlib import Path
from typing import Any, cast
from google.api_core.exceptions import GoogleAPIError, Unauthenticated
+from google.api_core.retry import AsyncRetry
from google.cloud import texttospeech
import voluptuous as vol
@@ -21,7 +22,7 @@ from homeassistant.components.tts import (
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
@@ -88,7 +89,7 @@ async def async_get_engine(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Google Cloud text-to-speech."""
service_account_info = config_entry.data[CONF_SERVICE_ACCOUNT_INFO]
@@ -215,7 +216,11 @@ class BaseGoogleCloudProvider:
),
)
- response = await self._client.synthesize_speech(request, timeout=10)
+ response = await self._client.synthesize_speech(
+ request,
+ timeout=10,
+ retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
+ )
if encoding == texttospeech.AudioEncoding.MP3:
extension = "mp3"
diff --git a/homeassistant/components/google_drive/__init__.py b/homeassistant/components/google_drive/__init__.py
index b30bc2ae1f6..d5252bd01ea 100644
--- a/homeassistant/components/google_drive/__init__.py
+++ b/homeassistant/components/google_drive/__init__.py
@@ -7,7 +7,7 @@ from collections.abc import Callable
from google_drive_api.exceptions import GoogleDriveApiError
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import instance_id
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -49,7 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry)
except GoogleDriveApiError as err:
raise ConfigEntryNotReady from err
- _async_notify_backup_listeners_soon(hass)
+ def async_notify_backup_listeners() -> None:
+ for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
+ listener()
+
+ entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
return True
@@ -58,15 +62,4 @@ async def async_unload_entry(
hass: HomeAssistant, entry: GoogleDriveConfigEntry
) -> bool:
"""Unload a config entry."""
- _async_notify_backup_listeners_soon(hass)
return True
-
-
-def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
- for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
- listener()
-
-
-@callback
-def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
- hass.loop.call_soon(_async_notify_backup_listeners, hass)
diff --git a/homeassistant/components/google_drive/backup.py b/homeassistant/components/google_drive/backup.py
index 73e5902f8f5..a4b7fc956ce 100644
--- a/homeassistant/components/google_drive/backup.py
+++ b/homeassistant/components/google_drive/backup.py
@@ -8,7 +8,12 @@ from typing import Any
from google_drive_api.exceptions import GoogleDriveApiError
-from homeassistant.components.backup import AgentBackup, BackupAgent, BackupAgentError
+from homeassistant.components.backup import (
+ AgentBackup,
+ BackupAgent,
+ BackupAgentError,
+ BackupNotFound,
+)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
@@ -93,13 +98,13 @@ class GoogleDriveBackupAgent(BackupAgent):
self,
backup_id: str,
**kwargs: Any,
- ) -> AgentBackup | None:
+ ) -> AgentBackup:
"""Return a backup."""
backups = await self.async_list_backups()
for backup in backups:
if backup.backup_id == backup_id:
return backup
- return None
+ raise BackupNotFound(f"Backup {backup_id} not found")
async def async_download_backup(
self,
@@ -120,7 +125,7 @@ class GoogleDriveBackupAgent(BackupAgent):
return ChunkAsyncStreamIterator(stream)
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
raise BackupAgentError(f"Failed to download backup: {err}") from err
- raise BackupAgentError("Backup not found")
+ raise BackupNotFound(f"Backup {backup_id} not found")
async def async_delete_backup(
self,
@@ -138,5 +143,7 @@ class GoogleDriveBackupAgent(BackupAgent):
_LOGGER.debug("Deleting file_id: %s", file_id)
await self._client.async_delete(file_id)
_LOGGER.debug("Deleted backup_id: %s", backup_id)
+ return
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
raise BackupAgentError(f"Failed to delete backup: {err}") from err
+ raise BackupNotFound(f"Backup {backup_id} not found")
diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py
index a5c55c2099d..88a51446cda 100644
--- a/homeassistant/components/google_generative_ai_conversation/__init__.py
+++ b/homeassistant/components/google_generative_ai_conversation/__init__.py
@@ -5,11 +5,9 @@ from __future__ import annotations
import mimetypes
from pathlib import Path
-from google.ai import generativelanguage_v1beta
-from google.api_core.client_options import ClientOptions
-from google.api_core.exceptions import ClientError, DeadlineExceeded, GoogleAPIError
-import google.generativeai as genai
-import google.generativeai.types as genai_types
+from google.genai import Client
+from google.genai.errors import APIError, ClientError
+from requests.exceptions import Timeout
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -27,59 +25,91 @@ from homeassistant.exceptions import (
HomeAssistantError,
)
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType
-from .const import CONF_CHAT_MODEL, CONF_PROMPT, DOMAIN, RECOMMENDED_CHAT_MODEL
+from .const import (
+ CONF_CHAT_MODEL,
+ CONF_PROMPT,
+ DOMAIN,
+ RECOMMENDED_CHAT_MODEL,
+ TIMEOUT_MILLIS,
+)
SERVICE_GENERATE_CONTENT = "generate_content"
CONF_IMAGE_FILENAME = "image_filename"
+CONF_FILENAMES = "filenames"
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = (Platform.CONVERSATION,)
+type GoogleGenerativeAIConfigEntry = ConfigEntry[Client]
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Google Generative AI Conversation."""
async def generate_content(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images."""
- prompt_parts = [call.data[CONF_PROMPT]]
- image_filenames = call.data[CONF_IMAGE_FILENAME]
- for image_filename in image_filenames:
- if not hass.config.is_allowed_path(image_filename):
- raise HomeAssistantError(
- f"Cannot read `{image_filename}`, no access to path; "
- "`allowlist_external_dirs` may need to be adjusted in "
- "`configuration.yaml`"
- )
- if not Path(image_filename).exists():
- raise HomeAssistantError(f"`{image_filename}` does not exist")
- mime_type, _ = mimetypes.guess_type(image_filename)
- if mime_type is None or not mime_type.startswith("image"):
- raise HomeAssistantError(f"`{image_filename}` is not an image")
- prompt_parts.append(
- {
- "mime_type": mime_type,
- "data": await hass.async_add_executor_job(
- Path(image_filename).read_bytes
- ),
- }
+
+ if call.data[CONF_IMAGE_FILENAME]:
+ # Deprecated in 2025.3, to remove in 2025.9
+ async_create_issue(
+ hass,
+ DOMAIN,
+ "deprecated_image_filename_parameter",
+ breaks_in_ha_version="2025.9.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_image_filename_parameter",
)
- model = genai.GenerativeModel(model_name=RECOMMENDED_CHAT_MODEL)
+ prompt_parts = [call.data[CONF_PROMPT]]
+
+ config_entry: GoogleGenerativeAIConfigEntry = (
+ hass.config_entries.async_loaded_entries(DOMAIN)[0]
+ )
+
+ client = config_entry.runtime_data
+
+ def append_files_to_prompt():
+ image_filenames = call.data[CONF_IMAGE_FILENAME]
+ filenames = call.data[CONF_FILENAMES]
+ for filename in set(image_filenames + filenames):
+ if not hass.config.is_allowed_path(filename):
+ raise HomeAssistantError(
+ f"Cannot read `{filename}`, no access to path; "
+ "`allowlist_external_dirs` may need to be adjusted in "
+ "`configuration.yaml`"
+ )
+ if not Path(filename).exists():
+ raise HomeAssistantError(f"`{filename}` does not exist")
+ mimetype = mimetypes.guess_type(filename)[0]
+ with open(filename, "rb") as file:
+ uploaded_file = client.files.upload(
+ file=file, config={"mime_type": mimetype}
+ )
+ prompt_parts.append(uploaded_file)
+
+ await hass.async_add_executor_job(append_files_to_prompt)
try:
- response = await model.generate_content_async(prompt_parts)
+ response = await client.aio.models.generate_content(
+ model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts
+ )
except (
- GoogleAPIError,
+ APIError,
ValueError,
- genai_types.BlockedPromptException,
- genai_types.StopCandidateException,
) as err:
raise HomeAssistantError(f"Error generating content: {err}") from err
- if not response.parts:
- raise HomeAssistantError("Error generating content")
+ if response.prompt_feedback:
+ raise HomeAssistantError(
+ f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}"
+ )
+
+ if not response.candidates[0].content.parts:
+ raise HomeAssistantError("Unknown error generating content")
return {"text": response.text}
@@ -93,6 +123,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.Optional(CONF_IMAGE_FILENAME, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
+ vol.Optional(CONF_FILENAMES, default=[]): vol.All(
+ cv.ensure_list, [cv.string]
+ ),
}
),
supports_response=SupportsResponse.ONLY,
@@ -100,30 +133,38 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
+) -> bool:
"""Set up Google Generative AI Conversation from a config entry."""
- genai.configure(api_key=entry.data[CONF_API_KEY])
try:
- client = generativelanguage_v1beta.ModelServiceAsyncClient(
- client_options=ClientOptions(api_key=entry.data[CONF_API_KEY])
+
+ def _init_client() -> Client:
+ return Client(api_key=entry.data[CONF_API_KEY])
+
+ client = await hass.async_add_executor_job(_init_client)
+ await client.aio.models.get(
+ model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
+ config={"http_options": {"timeout": TIMEOUT_MILLIS}},
)
- await client.get_model(
- name=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), timeout=5.0
- )
- except (GoogleAPIError, ValueError) as err:
- if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID":
- raise ConfigEntryAuthFailed(err) from err
- if isinstance(err, DeadlineExceeded):
+ except (APIError, Timeout) as err:
+ if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err):
+ raise ConfigEntryAuthFailed(err.message) from err
+ if isinstance(err, Timeout):
raise ConfigEntryNotReady(err) from err
raise ConfigEntryError(err) from err
+ else:
+ entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
+) -> bool:
"""Unload GoogleGenerativeAI."""
if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
return False
diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py
index 83eec25ed15..ec476d940d1 100644
--- a/homeassistant/components/google_generative_ai_conversation/config_flow.py
+++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py
@@ -3,15 +3,13 @@
from __future__ import annotations
from collections.abc import Mapping
-from functools import partial
import logging
from types import MappingProxyType
from typing import Any
-from google.ai import generativelanguage_v1beta
-from google.api_core.client_options import ClientOptions
-from google.api_core.exceptions import ClientError, GoogleAPIError
-import google.generativeai as genai
+from google import genai
+from google.genai.errors import APIError, ClientError
+from requests.exceptions import Timeout
import voluptuous as vol
from homeassistant.config_entries import (
@@ -46,6 +44,7 @@ from .const import (
CONF_TEMPERATURE,
CONF_TOP_K,
CONF_TOP_P,
+ CONF_USE_GOOGLE_SEARCH_TOOL,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_HARM_BLOCK_THRESHOLD,
@@ -53,6 +52,8 @@ from .const import (
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P,
+ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
+ TIMEOUT_MILLIS,
)
_LOGGER = logging.getLogger(__name__)
@@ -70,15 +71,20 @@ RECOMMENDED_OPTIONS = {
}
-async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
+async def validate_input(data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
- client = generativelanguage_v1beta.ModelServiceAsyncClient(
- client_options=ClientOptions(api_key=data[CONF_API_KEY])
+ client = genai.Client(api_key=data[CONF_API_KEY])
+ await client.aio.models.list(
+ config={
+ "http_options": {
+ "timeout": TIMEOUT_MILLIS,
+ },
+ "query_base": True,
+ }
)
- await client.list_models(timeout=5.0)
class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -93,9 +99,9 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
try:
- await validate_input(self.hass, user_input)
- except GoogleAPIError as err:
- if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID":
+ await validate_input(user_input)
+ except (APIError, Timeout) as err:
+ if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err):
errors["base"] = "invalid_auth"
else:
errors["base"] = "cannot_connect"
@@ -166,53 +172,57 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
self.last_rendered_recommended = config_entry.options.get(
CONF_RECOMMENDED, False
)
+ self._genai_client = config_entry.runtime_data
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
+ errors: dict[str, str] = {}
if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
- if user_input[CONF_LLM_HASS_API] == "none":
- user_input.pop(CONF_LLM_HASS_API)
- return self.async_create_entry(title="", data=user_input)
+ if not user_input.get(CONF_LLM_HASS_API):
+ user_input.pop(CONF_LLM_HASS_API, None)
+ if not (
+ user_input.get(CONF_LLM_HASS_API)
+ and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True
+ ):
+ # Don't allow to save options that enable the Google Seearch tool with an Assist API
+ return self.async_create_entry(title="", data=user_input)
+ errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option"
# Re-render the options again, now with the recommended options shown/hidden
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
- options = {
- CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
- CONF_PROMPT: user_input[CONF_PROMPT],
- CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
- }
+ options = user_input
- schema = await google_generative_ai_config_option_schema(self.hass, options)
+ schema = await google_generative_ai_config_option_schema(
+ self.hass, options, self._genai_client
+ )
return self.async_show_form(
- step_id="init",
- data_schema=vol.Schema(schema),
+ step_id="init", data_schema=vol.Schema(schema), errors=errors
)
async def google_generative_ai_config_option_schema(
hass: HomeAssistant,
options: dict[str, Any] | MappingProxyType[str, Any],
+ genai_client: genai.Client,
) -> dict:
"""Return a schema for Google Generative AI completion options."""
hass_apis: list[SelectOptionDict] = [
- SelectOptionDict(
- label="No control",
- value="none",
- )
- ]
- hass_apis.extend(
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
- )
+ ]
+ if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance(
+ suggested_llm_apis, str
+ ):
+ suggested_llm_apis = [suggested_llm_apis]
schema = {
vol.Optional(
@@ -225,9 +235,8 @@ async def google_generative_ai_config_option_schema(
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
- description={"suggested_value": options.get(CONF_LLM_HASS_API)},
- default="none",
- ): SelectSelector(SelectSelectorConfig(options=hass_apis)),
+ description={"suggested_value": suggested_llm_apis},
+ ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,
@@ -236,18 +245,21 @@ async def google_generative_ai_config_option_schema(
if options.get(CONF_RECOMMENDED):
return schema
- api_models = await hass.async_add_executor_job(partial(genai.list_models))
-
+ api_models_pager = await genai_client.aio.models.list(config={"query_base": True})
+ api_models = [api_model async for api_model in api_models_pager]
models = [
SelectOptionDict(
label=api_model.display_name,
value=api_model.name,
)
- for api_model in sorted(api_models, key=lambda x: x.display_name)
+ for api_model in sorted(api_models, key=lambda x: x.display_name or "")
if (
api_model.name != "models/gemini-1.0-pro" # duplicate of gemini-pro
+ and api_model.display_name
+ and api_model.name
+ and api_model.supported_actions
and "vision" not in api_model.name
- and "generateContent" in api_model.supported_generation_methods
+ and "generateContent" in api_model.supported_actions
)
]
@@ -288,7 +300,7 @@ async def google_generative_ai_config_option_schema(
CONF_TEMPERATURE,
description={"suggested_value": options.get(CONF_TEMPERATURE)},
default=RECOMMENDED_TEMPERATURE,
- ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)),
+ ): NumberSelector(NumberSelectorConfig(min=0, max=2, step=0.05)),
vol.Optional(
CONF_TOP_P,
description={"suggested_value": options.get(CONF_TOP_P)},
@@ -330,6 +342,13 @@ async def google_generative_ai_config_option_schema(
},
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
): harm_block_thresholds_selector,
+ vol.Optional(
+ CONF_USE_GOOGLE_SEARCH_TOOL,
+ description={
+ "suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
+ },
+ default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
+ ): bool,
}
)
return schema
diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py
index 4d83b935528..108ffe1891d 100644
--- a/homeassistant/components/google_generative_ai_conversation/const.py
+++ b/homeassistant/components/google_generative_ai_conversation/const.py
@@ -22,3 +22,7 @@ CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"
CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold"
RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE"
+CONF_USE_GOOGLE_SEARCH_TOOL = "enable_google_search_tool"
+RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
+
+TIMEOUT_MILLIS = 10000
diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py
index 0f26c93da25..73a82b98664 100644
--- a/homeassistant/components/google_generative_ai_conversation/conversation.py
+++ b/homeassistant/components/google_generative_ai_conversation/conversation.py
@@ -4,13 +4,22 @@ from __future__ import annotations
import codecs
from collections.abc import Callable
+from dataclasses import replace
from typing import Any, Literal, cast
-from google.api_core.exceptions import GoogleAPIError
-import google.generativeai as genai
-from google.generativeai import protos
-import google.generativeai.types as genai_types
-from google.protobuf.json_format import MessageToDict
+from google.genai.errors import APIError
+from google.genai.types import (
+ AutomaticFunctionCallingConfig,
+ Content,
+ FunctionDeclaration,
+ GenerateContentConfig,
+ GoogleSearch,
+ HarmCategory,
+ Part,
+ SafetySetting,
+ Schema,
+ Tool,
+)
from voluptuous_openapi import convert
from homeassistant.components import assist_pipeline, conversation
@@ -18,8 +27,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import chat_session, device_registry as dr, intent, llm
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers import device_registry as dr, intent, llm
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_CHAT_MODEL,
@@ -32,6 +41,7 @@ from .const import (
CONF_TEMPERATURE,
CONF_TOP_K,
CONF_TOP_P,
+ CONF_USE_GOOGLE_SEARCH_TOOL,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
@@ -45,11 +55,15 @@ from .const import (
# Max number of back and forth with the LLM to generate a response
MAX_TOOL_ITERATIONS = 10
+ERROR_GETTING_RESPONSE = (
+ "Sorry, I had a problem getting a response from Google Generative AI."
+)
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up conversation entities."""
agent = GoogleGenerativeAIConversationEntity(config_entry)
@@ -57,21 +71,30 @@ async def async_setup_entry(
SUPPORTED_SCHEMA_KEYS = {
+ # Gemini API does not support all of the OpenAPI schema
+ # SoT: https://ai.google.dev/api/caching#Schema
"type",
"format",
"description",
"nullable",
"enum",
- "items",
+ "max_items",
+ "min_items",
"properties",
"required",
+ "items",
}
-def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
- """Format the schema to protobuf."""
- if (subschemas := schema.get("anyOf")) or (subschemas := schema.get("allOf")):
- for subschema in subschemas: # Gemini API does not support anyOf and allOf keys
+def _camel_to_snake(name: str) -> str:
+ """Convert camel case to snake case."""
+ return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
+
+
+def _format_schema(schema: dict[str, Any]) -> Schema:
+ """Format the schema to be compatible with Gemini API."""
+ if subschemas := schema.get("allOf"):
+ for subschema in subschemas: # Gemini API does not support allOf keys
if "type" in subschema: # Fallback to first subschema with 'type' field
return _format_schema(subschema)
return _format_schema(
@@ -80,42 +103,47 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
result = {}
for key, val in schema.items():
+ key = _camel_to_snake(key)
if key not in SUPPORTED_SCHEMA_KEYS:
continue
if key == "type":
- key = "type_"
val = val.upper()
elif key == "format":
- if schema.get("type") == "string" and val != "enum":
+ # Gemini API does not support all formats, see: https://ai.google.dev/api/caching#Schema
+ # formats that are not supported are ignored
+ if schema.get("type") == "string" and val not in ("enum", "date-time"):
continue
- if schema.get("type") not in ("number", "integer", "string"):
+ if schema.get("type") == "number" and val not in ("float", "double"):
+ continue
+ if schema.get("type") == "integer" and val not in ("int32", "int64"):
+ continue
+ if schema.get("type") not in ("string", "number", "integer"):
continue
- key = "format_"
elif key == "items":
val = _format_schema(val)
elif key == "properties":
val = {k: _format_schema(v) for k, v in val.items()}
result[key] = val
- if result.get("enum") and result.get("type_") != "STRING":
+ if result.get("enum") and result.get("type") != "STRING":
# enum is only allowed for STRING type. This is safe as long as the schema
# contains vol.Coerce for the respective type, for example:
# vol.All(vol.Coerce(int), vol.In([1, 2, 3]))
- result["type_"] = "STRING"
+ result["type"] = "STRING"
result["enum"] = [str(item) for item in result["enum"]]
- if result.get("type_") == "OBJECT" and not result.get("properties"):
+ if result.get("type") == "OBJECT" and not result.get("properties"):
# An object with undefined properties is not supported by Gemini API.
# Fallback to JSON string. This will probably fail for most tools that want it,
# but we don't have a better fallback strategy so far.
- result["properties"] = {"json": {"type_": "STRING"}}
+ result["properties"] = {"json": {"type": "STRING"}}
result["required"] = []
- return result
+ return cast(Schema, result)
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
-) -> dict[str, Any]:
+) -> Tool:
"""Format tool specification."""
if tool.parameters.schema:
@@ -125,16 +153,14 @@ def _format_tool(
else:
parameters = None
- return protos.Tool(
- {
- "function_declarations": [
- {
- "name": tool.name,
- "description": tool.description,
- "parameters": parameters,
- }
- ]
- }
+ return Tool(
+ function_declarations=[
+ FunctionDeclaration(
+ name=tool.name,
+ description=tool.description,
+ parameters=parameters,
+ )
+ ]
)
@@ -149,19 +175,25 @@ def _escape_decode(value: Any) -> Any:
return value
+def _create_google_tool_response_parts(
+ parts: list[conversation.ToolResultContent],
+) -> list[Part]:
+ """Create Google tool response parts."""
+ return [
+ Part.from_function_response(
+ name=tool_result.tool_name, response=tool_result.tool_result
+ )
+ for tool_result in parts
+ ]
+
+
def _create_google_tool_response_content(
content: list[conversation.ToolResultContent],
-) -> protos.Content:
+) -> Content:
"""Create a Google tool response content."""
- return protos.Content(
- parts=[
- protos.Part(
- function_response=protos.FunctionResponse(
- name=tool_result.tool_name, response=tool_result.tool_result
- )
- )
- for tool_result in content
- ]
+ return Content(
+ role="user",
+ parts=_create_google_tool_response_parts(content),
)
@@ -169,33 +201,36 @@ def _convert_content(
content: conversation.UserContent
| conversation.AssistantContent
| conversation.SystemContent,
-) -> genai_types.ContentDict:
+) -> Content:
"""Convert HA content to Google content."""
- if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr]
+ if content.role != "assistant" or not content.tool_calls:
role = "model" if content.role == "assistant" else content.role
- return {"role": role, "parts": content.content}
+ return Content(
+ role=role,
+ parts=[
+ Part.from_text(text=content.content if content.content else ""),
+ ],
+ )
# Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent
- parts = []
+ parts: list[Part] = []
if content.content:
- parts.append(protos.Part(text=content.content))
+ parts.append(Part.from_text(text=content.content))
if content.tool_calls:
parts.extend(
[
- protos.Part(
- function_call=protos.FunctionCall(
- name=tool_call.tool_name,
- args=_escape_decode(tool_call.tool_args),
- )
+ Part.from_function_call(
+ name=tool_call.tool_name,
+ args=_escape_decode(tool_call.tool_args),
)
for tool_call in content.tool_calls
]
)
- return protos.Content({"role": "model", "parts": parts})
+ return Content(role="model", parts=parts)
class GoogleGenerativeAIConversationEntity(
@@ -209,6 +244,7 @@ class GoogleGenerativeAIConversationEntity(
def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the agent."""
self.entry = entry
+ self._genai_client = entry.runtime_data
self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
@@ -243,17 +279,12 @@ class GoogleGenerativeAIConversationEntity(
conversation.async_unset_agent(self.hass, self.entry)
await super().async_will_remove_from_hass()
- async def async_process(
- self, user_input: conversation.ConversationInput
- ) -> conversation.ConversationResult:
- """Process a sentence."""
- with (
- chat_session.async_get_chat_session(
- self.hass, user_input.conversation_id
- ) as session,
- conversation.async_get_chat_log(self.hass, session, user_input) as chat_log,
- ):
- return await self._async_handle_message(user_input, chat_log)
+ def _fix_tool_name(self, tool_name: str) -> str:
+ """Fix tool name if needed."""
+ # The Gemini 2.0+ tokenizer seemingly has a issue with the HassListAddItem tool
+ # name. This makes sure when it incorrectly changes the name, that we change it
+ # back for HA to call.
+ return tool_name if tool_name != "HasListAddItem" else "HassListAddItem"
async def _async_handle_message(
self,
@@ -273,13 +304,20 @@ class GoogleGenerativeAIConversationEntity(
except conversation.ConverseError as err:
return err.as_conversation_result()
- tools: list[dict[str, Any]] | None = None
+ tools: list[Tool | Callable[..., Any]] | None = None
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
+ # Using search grounding allows the model to retrieve information from the web,
+ # however, it may interfere with how the model decides to use some tools, or entities
+ # for example weather entity may be disregarded if the model chooses to Google it.
+ if options.get(CONF_USE_GOOGLE_SEARCH_TOOL) is True:
+ tools = tools or []
+ tools.append(Tool(google_search=GoogleSearch()))
+
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
# Gemini 1.0 doesn't support system_instruction while 1.5 does.
# Assume future versions will support it (if not, the request fails with a
@@ -288,119 +326,162 @@ class GoogleGenerativeAIConversationEntity(
"gemini-1.0" not in model_name and "gemini-pro" not in model_name
)
- prompt = chat_log.content[0].content # type: ignore[union-attr]
- messages: list[genai_types.ContentDict] = []
+ prompt_content = cast(
+ conversation.SystemContent,
+ chat_log.content[0],
+ )
+
+ if prompt_content.content:
+ prompt = prompt_content.content
+ else:
+ raise HomeAssistantError("Invalid prompt content")
+
+ messages: list[Content] = []
# Google groups tool results, we do not. Group them before sending.
tool_results: list[conversation.ToolResultContent] = []
- for chat_content in chat_log.content[1:]:
+ for chat_content in chat_log.content[1:-1]:
if chat_content.role == "tool_result":
- # mypy doesn't like picking a type based on checking shared property 'role'
- tool_results.append(cast(conversation.ToolResultContent, chat_content))
+ tool_results.append(chat_content)
continue
+ if (
+ not isinstance(chat_content, conversation.ToolResultContent)
+ and chat_content.content == ""
+ ):
+ # Skipping is not possible since the number of function calls need to match the number of function responses
+ # and skipping one would mean removing the other and hence this would prevent a proper chat log
+ chat_content = replace(chat_content, content=" ")
+
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
tool_results.clear()
- messages.append(
- _convert_content(
- cast(
- conversation.UserContent
- | conversation.SystemContent
- | conversation.AssistantContent,
- chat_content,
- )
- )
+ messages.append(_convert_content(chat_content))
+
+ # The SDK requires the first message to be a user message
+ # This is not the case if user used `start_conversation`
+ # Workaround from https://github.com/googleapis/python-genai/issues/529#issuecomment-2740964537
+ if messages and messages[0].role != "user":
+ messages.insert(
+ 0,
+ Content(role="user", parts=[Part.from_text(text=" ")]),
)
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
-
- model = genai.GenerativeModel(
- model_name=model_name,
- generation_config={
- "temperature": self.entry.options.get(
- CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
+ generateContentConfig = GenerateContentConfig(
+ temperature=self.entry.options.get(
+ CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
+ ),
+ top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
+ top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
+ max_output_tokens=self.entry.options.get(
+ CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
+ ),
+ safety_settings=[
+ SafetySetting(
+ category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
+ threshold=self.entry.options.get(
+ CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
+ ),
),
- "top_p": self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
- "top_k": self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
- "max_output_tokens": self.entry.options.get(
- CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
+ SafetySetting(
+ category=HarmCategory.HARM_CATEGORY_HARASSMENT,
+ threshold=self.entry.options.get(
+ CONF_HARASSMENT_BLOCK_THRESHOLD,
+ RECOMMENDED_HARM_BLOCK_THRESHOLD,
+ ),
),
- },
- safety_settings={
- "HARASSMENT": self.entry.options.get(
- CONF_HARASSMENT_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
+ SafetySetting(
+ category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
+ threshold=self.entry.options.get(
+ CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
+ ),
),
- "HATE": self.entry.options.get(
- CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
+ SafetySetting(
+ category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
+ threshold=self.entry.options.get(
+ CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
+ ),
),
- "SEXUAL": self.entry.options.get(
- CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
- ),
- "DANGEROUS": self.entry.options.get(
- CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
- ),
- },
+ ],
tools=tools or None,
system_instruction=prompt if supports_system_instruction else None,
+ automatic_function_calling=AutomaticFunctionCallingConfig(
+ disable=True, maximum_remote_calls=None
+ ),
)
if not supports_system_instruction:
messages = [
- {"role": "user", "parts": prompt},
- {"role": "model", "parts": "Ok"},
+ Content(role="user", parts=[Part.from_text(text=prompt)]),
+ Content(role="model", parts=[Part.from_text(text="Ok")]),
*messages,
]
-
- chat = model.start_chat(history=messages)
- chat_request = user_input.text
+ chat = self._genai_client.aio.chats.create(
+ model=model_name, history=messages, config=generateContentConfig
+ )
+ chat_request: str | list[Part] = user_input.text
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
- chat_response = await chat.send_message_async(chat_request)
+ chat_response = await chat.send_message(message=chat_request)
+
+ if chat_response.prompt_feedback:
+ raise HomeAssistantError(
+ f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}"
+ )
+ if not chat_response.candidates:
+ LOGGER.error(
+ "No candidates found in the response: %s",
+ chat_response,
+ )
+ raise HomeAssistantError(ERROR_GETTING_RESPONSE)
+
except (
- GoogleAPIError,
+ APIError,
ValueError,
- genai_types.BlockedPromptException,
- genai_types.StopCandidateException,
) as err:
LOGGER.error("Error sending message: %s %s", type(err), err)
-
- if isinstance(
- err, genai_types.StopCandidateException
- ) and "finish_reason: SAFETY\n" in str(err):
- error = "The message got blocked by your safety settings"
- else:
- error = (
- f"Sorry, I had a problem talking to Google Generative AI: {err}"
- )
-
+ error = f"Sorry, I had a problem talking to Google Generative AI: {err}"
raise HomeAssistantError(error) from err
- LOGGER.debug("Response: %s", chat_response.parts)
- if not chat_response.parts:
- raise HomeAssistantError(
- "Sorry, I had a problem getting a response from Google Generative AI."
+ if (usage_metadata := chat_response.usage_metadata) is not None:
+ chat_log.async_trace(
+ {
+ "stats": {
+ "input_tokens": usage_metadata.prompt_token_count,
+ "cached_input_tokens": usage_metadata.cached_content_token_count
+ or 0,
+ "output_tokens": usage_metadata.candidates_token_count,
+ }
+ }
)
+
+ response_parts = chat_response.candidates[0].content.parts
+ if not response_parts:
+ raise HomeAssistantError(ERROR_GETTING_RESPONSE)
content = " ".join(
- [part.text.strip() for part in chat_response.parts if part.text]
+ [part.text.strip() for part in response_parts if part.text]
)
tool_calls = []
- for part in chat_response.parts:
+ for part in response_parts:
if not part.function_call:
continue
- tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001
- tool_name = tool_call["name"]
- tool_args = _escape_decode(tool_call["args"])
+ tool_call = part.function_call
+ tool_name = tool_call.name
+ tool_args = _escape_decode(tool_call.args)
tool_calls.append(
- llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
+ llm.ToolInput(
+ tool_name=self._fix_tool_name(tool_name),
+ tool_args=tool_args,
+ )
)
- chat_request = _create_google_tool_response_content(
+ chat_request = _create_google_tool_response_parts(
[
tool_response
async for tool_response in chat_log.async_add_assistant_content(
@@ -418,10 +499,12 @@ class GoogleGenerativeAIConversationEntity(
response = intent.IntentResponse(language=user_input.language)
response.async_set_speech(
- " ".join([part.text.strip() for part in chat_response.parts if part.text])
+ " ".join([part.text.strip() for part in response_parts if part.text])
)
return conversation.ConversationResult(
- response=response, conversation_id=chat_log.conversation_id
+ response=response,
+ conversation_id=chat_log.conversation_id,
+ continue_conversation=chat_log.continue_conversation,
)
async def _async_entry_update_listener(
diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json
index 7b687b7da6f..25e44964a6d 100644
--- a/homeassistant/components/google_generative_ai_conversation/manifest.json
+++ b/homeassistant/components/google_generative_ai_conversation/manifest.json
@@ -2,11 +2,11 @@
"domain": "google_generative_ai_conversation",
"name": "Google Generative AI",
"after_dependencies": ["assist_pipeline", "intent"],
- "codeowners": ["@tronikos"],
+ "codeowners": ["@tronikos", "@ivanlh"],
"config_flow": true,
"dependencies": ["conversation"],
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["google-generativeai==0.8.2"]
+ "requirements": ["google-genai==1.7.0"]
}
diff --git a/homeassistant/components/google_generative_ai_conversation/services.yaml b/homeassistant/components/google_generative_ai_conversation/services.yaml
index f35697b89f8..82190d64540 100644
--- a/homeassistant/components/google_generative_ai_conversation/services.yaml
+++ b/homeassistant/components/google_generative_ai_conversation/services.yaml
@@ -9,3 +9,8 @@ generate_content:
required: false
selector:
object:
+ filenames:
+ required: false
+ selector:
+ text:
+ multiple: true
diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json
index 9fea4805d38..2697f30eda0 100644
--- a/homeassistant/components/google_generative_ai_conversation/strings.json
+++ b/homeassistant/components/google_generative_ai_conversation/strings.json
@@ -36,12 +36,17 @@
"harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes",
"hate_block_threshold": "Content that is rude, disrespectful, or profane",
"sexual_block_threshold": "Contains references to sexual acts or other lewd content",
- "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts"
+ "dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts",
+ "enable_google_search_tool": "Enable Google Search tool"
},
"data_description": {
- "prompt": "Instruct how the LLM should respond. This can be a template."
+ "prompt": "Instruct how the LLM should respond. This can be a template.",
+ "enable_google_search_tool": "Only works with \"No control\" in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"."
}
}
+ },
+ "error": {
+ "invalid_google_search_option": "Google Search cannot be enabled alongside any Assist capability, this can only be used when Assist is set to \"No control\"."
}
},
"services": {
@@ -56,10 +61,21 @@
},
"image_filename": {
"name": "Image filename",
- "description": "Images",
+ "description": "Deprecated. Use filenames instead.",
+ "example": "/config/www/image.jpg"
+ },
+ "filenames": {
+ "name": "Attachment filenames",
+ "description": "Attachments to add to the prompt (images, PDFs, etc)",
"example": "/config/www/image.jpg"
}
}
}
+ },
+ "issues": {
+ "deprecated_image_filename_parameter": {
+ "title": "Deprecated 'image_filename' parameter",
+ "description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' instead."
+ }
}
}
diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py
index 7fae5f18da5..8ef978568dc 100644
--- a/homeassistant/components/google_mail/__init__.py
+++ b/homeassistant/components/google_mail/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, discovery
@@ -59,12 +59,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -
async def async_unload_entry(hass: HomeAssistant, entry: GoogleMailConfigEntry) -> bool:
"""Unload a config entry."""
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
for service_name in hass.services.async_services_for_domain(DOMAIN):
hass.services.async_remove(DOMAIN, service_name)
diff --git a/homeassistant/components/google_mail/sensor.py b/homeassistant/components/google_mail/sensor.py
index c832104d719..781ea9192f0 100644
--- a/homeassistant/components/google_mail/sensor.py
+++ b/homeassistant/components/google_mail/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import GoogleMailConfigEntry
from .entity import GoogleMailEntity
@@ -29,7 +29,7 @@ SENSOR_TYPE = SensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: GoogleMailConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Google Mail sensor."""
async_add_entities([GoogleMailSensor(entry.runtime_data, SENSOR_TYPE)], True)
diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json
index 9ea747898b2..b96f4e9ebc0 100644
--- a/homeassistant/components/google_pubsub/manifest.json
+++ b/homeassistant/components/google_pubsub/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_pubsub",
"iot_class": "cloud_push",
"quality_scale": "legacy",
- "requirements": ["google-cloud-pubsub==2.23.0"]
+ "requirements": ["google-cloud-pubsub==2.29.0"]
}
diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py
index faf1ff1ee0b..afafce816a9 100644
--- a/homeassistant/components/google_sheets/__init__.py
+++ b/homeassistant/components/google_sheets/__init__.py
@@ -12,7 +12,7 @@ from gspread.exceptions import APIError
from gspread.utils import ValueInputOption
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import (
@@ -81,12 +81,7 @@ async def async_unload_entry(
hass: HomeAssistant, entry: GoogleSheetsConfigEntry
) -> bool:
"""Unload a config entry."""
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
for service_name in hass.services.async_services_for_domain(DOMAIN):
hass.services.async_remove(DOMAIN, service_name)
diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py
index 6d1969d9a8a..d8e3c64bad8 100644
--- a/homeassistant/components/google_tasks/todo.py
+++ b/homeassistant/components/google_tasks/todo.py
@@ -12,7 +12,7 @@ from homeassistant.components.todo import (
TodoListEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -68,7 +68,7 @@ def _convert_api_item(item: dict[str, str]) -> TodoItem:
async def async_setup_entry(
hass: HomeAssistant,
entry: GoogleTasksConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Google Tasks todo platform."""
async_add_entities(
diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py
index 13e0ca4c273..201300d95b4 100644
--- a/homeassistant/components/google_translate/tts.py
+++ b/homeassistant/components/google_translate/tts.py
@@ -19,7 +19,7 @@ from homeassistant.components.tts import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
@@ -55,7 +55,7 @@ async def async_get_engine(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Google Translate speech platform via config entry."""
default_language = config_entry.data[CONF_LANG]
diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py
index a3f9c236136..cac792dca53 100644
--- a/homeassistant/components/google_travel_time/sensor.py
+++ b/homeassistant/components/google_travel_time/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.location import find_coordinates
from homeassistant.util import dt as dt_util
@@ -55,7 +55,7 @@ def convert_time_to_utc(timestr):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Google travel time sensor entry."""
api_key = config_entry.data[CONF_API_KEY]
diff --git a/homeassistant/components/govee_ble/binary_sensor.py b/homeassistant/components/govee_ble/binary_sensor.py
index 7b7a1fb5a50..c3c71714e90 100644
--- a/homeassistant/components/govee_ble/binary_sensor.py
+++ b/homeassistant/components/govee_ble/binary_sensor.py
@@ -20,7 +20,7 @@ from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .coordinator import GoveeBLEPassiveBluetoothDataProcessor
@@ -76,7 +76,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the govee-ble BLE sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/govee_ble/event.py b/homeassistant/components/govee_ble/event.py
index 5e5aa6354be..03f74f37f6a 100644
--- a/homeassistant/components/govee_ble/event.py
+++ b/homeassistant/components/govee_ble/event.py
@@ -16,7 +16,7 @@ from homeassistant.components.event import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import GoveeBLEConfigEntry, format_event_dispatcher_name
@@ -90,7 +90,7 @@ class GoveeBluetoothEventEntity(EventEntity):
async def async_setup_entry(
hass: HomeAssistant,
entry: GoveeBLEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a govee ble event."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json
index 1c61ae31010..b06dab243af 100644
--- a/homeassistant/components/govee_ble/manifest.json
+++ b/homeassistant/components/govee_ble/manifest.json
@@ -135,5 +135,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
"iot_class": "local_push",
- "requirements": ["govee-ble==0.43.0"]
+ "requirements": ["govee-ble==0.43.1"]
}
diff --git a/homeassistant/components/govee_ble/sensor.py b/homeassistant/components/govee_ble/sensor.py
index 383f50e5c46..fa0b828176c 100644
--- a/homeassistant/components/govee_ble/sensor.py
+++ b/homeassistant/components/govee_ble/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .coordinator import GoveeBLEConfigEntry, GoveeBLEPassiveBluetoothDataProcessor
@@ -105,7 +105,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: GoveeBLEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Govee BLE sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/govee_light_local/coordinator.py b/homeassistant/components/govee_light_local/coordinator.py
index ecbed0c4f65..530ade1f743 100644
--- a/homeassistant/components/govee_light_local/coordinator.py
+++ b/homeassistant/components/govee_light_local/coordinator.py
@@ -89,6 +89,10 @@ class GoveeLocalApiCoordinator(DataUpdateCoordinator[list[GoveeDevice]]):
"""Set light color in kelvin."""
await device.set_temperature(temperature)
+ async def set_scene(self, device: GoveeController, scene: str) -> None:
+ """Set light scene."""
+ await device.set_scene(scene)
+
@property
def devices(self) -> list[GoveeDevice]:
"""Return a list of discovered Govee devices."""
diff --git a/homeassistant/components/govee_light_local/light.py b/homeassistant/components/govee_light_local/light.py
index c7799a7ffc4..c5c8ed42ad5 100644
--- a/homeassistant/components/govee_light_local/light.py
+++ b/homeassistant/components/govee_light_local/light.py
@@ -10,14 +10,16 @@ from govee_local_api import GoveeDevice, GoveeLightFeatures
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_COLOR_TEMP_KELVIN,
+ ATTR_EFFECT,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
+ LightEntityFeature,
filter_supported_color_modes,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
@@ -25,11 +27,13 @@ from .coordinator import GoveeLocalApiCoordinator, GoveeLocalConfigEntry
_LOGGER = logging.getLogger(__name__)
+_NONE_SCENE = "none"
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: GoveeLocalConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Govee light setup."""
@@ -50,10 +54,22 @@ async def async_setup_entry(
class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
"""Govee Light."""
+ _attr_translation_key = "govee_light"
_attr_has_entity_name = True
_attr_name = None
_attr_supported_color_modes: set[ColorMode]
_fixed_color_mode: ColorMode | None = None
+ _attr_effect_list: list[str] | None = None
+ _attr_effect: str | None = None
+ _attr_supported_features: LightEntityFeature = LightEntityFeature(0)
+ _last_color_state: (
+ tuple[
+ ColorMode | str | None,
+ int | None,
+ tuple[int, int, int] | tuple[int | None] | None,
+ ]
+ | None
+ ) = None
def __init__(
self,
@@ -80,6 +96,13 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
if GoveeLightFeatures.BRIGHTNESS & capabilities.features:
color_modes.add(ColorMode.BRIGHTNESS)
+ if (
+ GoveeLightFeatures.SCENES & capabilities.features
+ and capabilities.scenes
+ ):
+ self._attr_supported_features = LightEntityFeature.EFFECT
+ self._attr_effect_list = [_NONE_SCENE, *capabilities.scenes.keys()]
+
self._attr_supported_color_modes = filter_supported_color_modes(color_modes)
if len(self._attr_supported_color_modes) == 1:
# If the light supports only a single color mode, set it now
@@ -134,21 +157,36 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
- if not self.is_on or not kwargs:
- await self.coordinator.turn_on(self._device)
-
if ATTR_BRIGHTNESS in kwargs:
brightness: int = int((float(kwargs[ATTR_BRIGHTNESS]) / 255.0) * 100.0)
await self.coordinator.set_brightness(self._device, brightness)
if ATTR_RGB_COLOR in kwargs:
self._attr_color_mode = ColorMode.RGB
+ self._attr_effect = None
+ self._last_color_state = None
red, green, blue = kwargs[ATTR_RGB_COLOR]
await self.coordinator.set_rgb_color(self._device, red, green, blue)
elif ATTR_COLOR_TEMP_KELVIN in kwargs:
self._attr_color_mode = ColorMode.COLOR_TEMP
+ self._attr_effect = None
+ self._last_color_state = None
temperature: float = kwargs[ATTR_COLOR_TEMP_KELVIN]
await self.coordinator.set_temperature(self._device, int(temperature))
+ elif ATTR_EFFECT in kwargs:
+ effect = kwargs[ATTR_EFFECT]
+ if effect and self._attr_effect_list and effect in self._attr_effect_list:
+ if effect == _NONE_SCENE:
+ self._attr_effect = None
+ await self._restore_last_color_state()
+ else:
+ self._attr_effect = effect
+ self._save_last_color_state()
+ await self.coordinator.set_scene(self._device, effect)
+
+ if not self.is_on or not kwargs:
+ await self.coordinator.turn_on(self._device)
+
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
@@ -159,3 +197,27 @@ class GoveeLight(CoordinatorEntity[GoveeLocalApiCoordinator], LightEntity):
@callback
def _update_callback(self, device: GoveeDevice) -> None:
self.async_write_ha_state()
+
+ def _save_last_color_state(self) -> None:
+ color_mode = self.color_mode
+ self._last_color_state = (
+ color_mode,
+ self.brightness,
+ (self.color_temp_kelvin,)
+ if color_mode == ColorMode.COLOR_TEMP
+ else self.rgb_color,
+ )
+
+ async def _restore_last_color_state(self) -> None:
+ if self._last_color_state:
+ color_mode, brightness, color = self._last_color_state
+ if color:
+ if color_mode == ColorMode.RGB:
+ await self.coordinator.set_rgb_color(self._device, *color)
+ elif color_mode == ColorMode.COLOR_TEMP:
+ await self.coordinator.set_temperature(self._device, *color)
+ if brightness:
+ await self.coordinator.set_brightness(
+ self._device, int((float(brightness) / 255.0) * 100.0)
+ )
+ self._last_color_state = None
diff --git a/homeassistant/components/govee_light_local/manifest.json b/homeassistant/components/govee_light_local/manifest.json
index cba341cd482..55a6b9e8578 100644
--- a/homeassistant/components/govee_light_local/manifest.json
+++ b/homeassistant/components/govee_light_local/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/govee_light_local",
"iot_class": "local_push",
- "requirements": ["govee-local-api==2.0.1"]
+ "requirements": ["govee-local-api==2.1.0"]
}
diff --git a/homeassistant/components/govee_light_local/strings.json b/homeassistant/components/govee_light_local/strings.json
index ad8f0f41ae7..49f3a2cbeb9 100644
--- a/homeassistant/components/govee_light_local/strings.json
+++ b/homeassistant/components/govee_light_local/strings.json
@@ -9,5 +9,29 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
}
+ },
+ "entity": {
+ "light": {
+ "govee_light": {
+ "state_attributes": {
+ "effect": {
+ "state": {
+ "none": "None",
+ "sunrise": "Sunrise",
+ "sunset": "Sunset",
+ "movie": "Movie",
+ "dating": "Dating",
+ "romantic": "Romantic",
+ "twinkle": "Twinkle",
+ "candlelight": "Candlelight",
+ "snowflake": "Snowflake",
+ "energetic": "Energetic",
+ "breathe": "Breathe",
+ "crossing": "Crossing"
+ }
+ }
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py
index 86d3ab7cc04..cc2257c88f7 100644
--- a/homeassistant/components/gpsd/sensor.py
+++ b/homeassistant/components/gpsd/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
@@ -156,7 +156,7 @@ SENSOR_TYPES: tuple[GpsdSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: GPSDConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the GPSD component."""
async_add_entities(
diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py
index 3ed68ed1b06..be38382098d 100644
--- a/homeassistant/components/gpslogger/device_tracker.py
+++ b/homeassistant/components/gpslogger/device_tracker.py
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from . import DOMAIN as GPL_DOMAIN, TRACKER_UPDATE
@@ -26,7 +26,9 @@ from .const import (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Configure a dispatcher connection based on a config entry."""
diff --git a/homeassistant/components/gree/climate.py b/homeassistant/components/gree/climate.py
index f197f21a4e1..f703ded1ea2 100644
--- a/homeassistant/components/gree/climate.py
+++ b/homeassistant/components/gree/climate.py
@@ -40,7 +40,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
COORDINATORS,
@@ -88,7 +88,7 @@ SWING_MODES = [SWING_OFF, SWING_VERTICAL, SWING_HORIZONTAL, SWING_BOTH]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Gree HVAC device from a config entry."""
diff --git a/homeassistant/components/gree/const.py b/homeassistant/components/gree/const.py
index f926eb1c53e..14236f09fa2 100644
--- a/homeassistant/components/gree/const.py
+++ b/homeassistant/components/gree/const.py
@@ -20,3 +20,4 @@ MAX_ERRORS = 2
TARGET_TEMPERATURE_STEP = 1
UPDATE_INTERVAL = 60
+MAX_EXPECTED_RESPONSE_TIME_INTERVAL = UPDATE_INTERVAL * 2
diff --git a/homeassistant/components/gree/coordinator.py b/homeassistant/components/gree/coordinator.py
index 0d1aa60deaa..c8b4e6cff54 100644
--- a/homeassistant/components/gree/coordinator.py
+++ b/homeassistant/components/gree/coordinator.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import copy
from datetime import datetime, timedelta
import logging
from typing import Any
@@ -24,6 +25,7 @@ from .const import (
DISPATCH_DEVICE_DISCOVERED,
DOMAIN,
MAX_ERRORS,
+ MAX_EXPECTED_RESPONSE_TIME_INTERVAL,
UPDATE_INTERVAL,
)
@@ -48,7 +50,6 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
always_update=False,
)
self.device = device
- self.device.add_handler(Response.DATA, self.device_state_updated)
self.device.add_handler(Response.RESULT, self.device_state_updated)
self._error_count: int = 0
@@ -88,7 +89,9 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# raise update failed if time for more than MAX_ERRORS has passed since last update
now = utcnow()
elapsed_success = now - self._last_response_time
- if self.update_interval and elapsed_success >= self.update_interval:
+ if self.update_interval and elapsed_success >= timedelta(
+ seconds=MAX_EXPECTED_RESPONSE_TIME_INTERVAL
+ ):
if not self._last_error_time or (
(now - self.update_interval) >= self._last_error_time
):
@@ -96,16 +99,19 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._error_count += 1
_LOGGER.warning(
- "Device %s is unresponsive for %s seconds",
+ "Device %s took an unusually long time to respond, %s seconds",
self.name,
elapsed_success,
)
+ else:
+ self._error_count = 0
if self.last_update_success and self._error_count >= MAX_ERRORS:
raise UpdateFailed(
f"Device {self.name} is unresponsive for too long and now unavailable"
)
- return self.device.raw_properties
+ self._last_response_time = utcnow()
+ return copy.deepcopy(self.device.raw_properties)
async def push_state_update(self):
"""Send state updates to the physical device."""
diff --git a/homeassistant/components/gree/strings.json b/homeassistant/components/gree/strings.json
index 45911433b92..403cf7d45fc 100644
--- a/homeassistant/components/gree/strings.json
+++ b/homeassistant/components/gree/strings.json
@@ -16,13 +16,13 @@
"name": "Panel light"
},
"quiet": {
- "name": "Quiet"
+ "name": "Quiet mode"
},
"fresh_air": {
"name": "Fresh air"
},
"xfan": {
- "name": "XFan"
+ "name": "Xtra fan"
},
"health_mode": {
"name": "Health mode"
diff --git a/homeassistant/components/gree/switch.py b/homeassistant/components/gree/switch.py
index c1612ce99de..67dc10138d1 100644
--- a/homeassistant/components/gree/switch.py
+++ b/homeassistant/components/gree/switch.py
@@ -16,7 +16,7 @@ from homeassistant.components.switch import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN
from .entity import GreeEntity
@@ -93,7 +93,7 @@ GREE_SWITCHES: tuple[GreeSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Gree HVAC device from a config entry."""
diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py
index 06c810c2643..fa1777d5510 100644
--- a/homeassistant/components/group/binary_sensor.py
+++ b/homeassistant/components/group/binary_sensor.py
@@ -26,7 +26,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -70,7 +73,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Binary Sensor Group config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/group/button.py b/homeassistant/components/group/button.py
index a18e074b775..c96d60067a1 100644
--- a/homeassistant/components/group/button.py
+++ b/homeassistant/components/group/button.py
@@ -22,7 +22,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -62,7 +65,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize button group config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py
index b2e5c6eef37..64baba6d1e8 100644
--- a/homeassistant/components/group/cover.py
+++ b/homeassistant/components/group/cover.py
@@ -37,7 +37,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -80,7 +83,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Cover Group config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py
index e7f7938edf3..4009c788362 100644
--- a/homeassistant/components/group/event.py
+++ b/homeassistant/components/group/event.py
@@ -27,7 +27,10 @@ from homeassistant.const import (
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -68,7 +71,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize event group config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py
index 87d9cb281f4..78745cb74c6 100644
--- a/homeassistant/components/group/fan.py
+++ b/homeassistant/components/group/fan.py
@@ -37,7 +37,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -82,7 +85,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Fan Group config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py
index 228645df974..259832d6152 100644
--- a/homeassistant/components/group/light.py
+++ b/homeassistant/components/group/light.py
@@ -48,7 +48,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -98,7 +101,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Light Group config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py
index e22e1ecd85c..7b460aa4632 100644
--- a/homeassistant/components/group/lock.py
+++ b/homeassistant/components/group/lock.py
@@ -28,7 +28,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -70,7 +73,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Lock Group config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py
index ab8ee64b3e1..3371e56b1dc 100644
--- a/homeassistant/components/group/media_player.py
+++ b/homeassistant/components/group/media_player.py
@@ -54,7 +54,10 @@ from homeassistant.core import (
callback,
)
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -99,7 +102,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize MediaPlayer Group config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py
index d6a9a6fd3c7..e710485c46f 100644
--- a/homeassistant/components/group/notify.py
+++ b/homeassistant/components/group/notify.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -129,7 +129,7 @@ class GroupNotifyPlatform(BaseNotificationService):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Notify Group config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py
index 4a3e191e511..9f0cc64ecf0 100644
--- a/homeassistant/components/group/sensor.py
+++ b/homeassistant/components/group/sensor.py
@@ -44,7 +44,10 @@ from homeassistant.helpers.entity import (
get_device_class,
get_unit_of_measurement,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
@@ -130,7 +133,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Switch Group config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py
index 101c42d354f..29e625ca8e3 100644
--- a/homeassistant/components/group/switch.py
+++ b/homeassistant/components/group/switch.py
@@ -26,7 +26,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -71,7 +74,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Switch Group config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json
index 98ceb35ee17..7b3e67228b1 100644
--- a/homeassistant/components/growatt_server/manifest.json
+++ b/homeassistant/components/growatt_server/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/growatt_server",
"iot_class": "cloud_polling",
"loggers": ["growattServer"],
- "requirements": ["growattServer==1.5.0"]
+ "requirements": ["growattServer==1.6.0"]
}
diff --git a/homeassistant/components/growatt_server/sensor/__init__.py b/homeassistant/components/growatt_server/sensor/__init__.py
index e77660e6a3a..2794403811d 100644
--- a/homeassistant/components/growatt_server/sensor/__init__.py
+++ b/homeassistant/components/growatt_server/sensor/__init__.py
@@ -14,7 +14,7 @@ from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAM
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle, dt as dt_util
from ..const import (
@@ -61,7 +61,7 @@ def get_device_list(api, config):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Growatt sensor."""
config = {**config_entry.data}
diff --git a/homeassistant/components/growatt_server/sensor/total.py b/homeassistant/components/growatt_server/sensor/total.py
index 8111728d1e9..578745c8610 100644
--- a/homeassistant/components/growatt_server/sensor/total.py
+++ b/homeassistant/components/growatt_server/sensor/total.py
@@ -26,6 +26,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="todayEnergy",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
GrowattSensorEntityDescription(
key="total_output_power",
@@ -33,6 +34,7 @@ TOTAL_SENSOR_TYPES: tuple[GrowattSensorEntityDescription, ...] = (
api_key="invTodayPpv",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
),
GrowattSensorEntityDescription(
key="total_energy_output",
diff --git a/homeassistant/components/growatt_server/strings.json b/homeassistant/components/growatt_server/strings.json
index 9a985d98034..758428d7a55 100644
--- a/homeassistant/components/growatt_server/strings.json
+++ b/homeassistant/components/growatt_server/strings.json
@@ -38,28 +38,28 @@
"name": "Input 1 voltage"
},
"inverter_amperage_input_1": {
- "name": "Input 1 Amperage"
+ "name": "Input 1 amperage"
},
"inverter_wattage_input_1": {
- "name": "Input 1 Wattage"
+ "name": "Input 1 wattage"
},
"inverter_voltage_input_2": {
"name": "Input 2 voltage"
},
"inverter_amperage_input_2": {
- "name": "Input 2 Amperage"
+ "name": "Input 2 amperage"
},
"inverter_wattage_input_2": {
- "name": "Input 2 Wattage"
+ "name": "Input 2 wattage"
},
"inverter_voltage_input_3": {
"name": "Input 3 voltage"
},
"inverter_amperage_input_3": {
- "name": "Input 3 Amperage"
+ "name": "Input 3 amperage"
},
"inverter_wattage_input_3": {
- "name": "Input 3 Wattage"
+ "name": "Input 3 wattage"
},
"inverter_internal_wattage": {
"name": "Internal wattage"
@@ -137,13 +137,13 @@
"name": "Load consumption"
},
"mix_wattage_pv_1": {
- "name": "PV1 Wattage"
+ "name": "PV1 wattage"
},
"mix_wattage_pv_2": {
- "name": "PV2 Wattage"
+ "name": "PV2 wattage"
},
"mix_wattage_pv_all": {
- "name": "All PV Wattage"
+ "name": "All PV wattage"
},
"mix_export_to_grid": {
"name": "Export to grid"
@@ -182,7 +182,7 @@
"name": "Storage production today"
},
"storage_storage_production_lifetime": {
- "name": "Lifetime Storage production"
+ "name": "Lifetime storage production"
},
"storage_grid_discharge_today": {
"name": "Grid discharged today"
@@ -224,7 +224,7 @@
"name": "Storage charging/ discharging(-ve)"
},
"storage_load_consumption_solar_storage": {
- "name": "Load consumption (Solar + Storage)"
+ "name": "Load consumption (solar + storage)"
},
"storage_charge_today": {
"name": "Charge today"
@@ -257,7 +257,7 @@
"name": "Output voltage"
},
"storage_ac_output_frequency": {
- "name": "Ac output frequency"
+ "name": "AC output frequency"
},
"storage_current_pv": {
"name": "Solar charge current"
@@ -290,7 +290,7 @@
"name": "Lifetime total energy input 1"
},
"tlx_energy_today_input_1": {
- "name": "Energy Today Input 1"
+ "name": "Energy today input 1"
},
"tlx_voltage_input_1": {
"name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_1::name%]"
@@ -305,7 +305,7 @@
"name": "Lifetime total energy input 2"
},
"tlx_energy_today_input_2": {
- "name": "Energy Today Input 2"
+ "name": "Energy today input 2"
},
"tlx_voltage_input_2": {
"name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_2::name%]"
@@ -320,7 +320,7 @@
"name": "Lifetime total energy input 3"
},
"tlx_energy_today_input_3": {
- "name": "Energy Today Input 3"
+ "name": "Energy today input 3"
},
"tlx_voltage_input_3": {
"name": "[%key:component::growatt_server::entity::sensor::inverter_voltage_input_3::name%]"
@@ -335,16 +335,16 @@
"name": "Lifetime total energy input 4"
},
"tlx_energy_today_input_4": {
- "name": "Energy Today Input 4"
+ "name": "Energy today input 4"
},
"tlx_voltage_input_4": {
"name": "Input 4 voltage"
},
"tlx_amperage_input_4": {
- "name": "Input 4 Amperage"
+ "name": "Input 4 amperage"
},
"tlx_wattage_input_4": {
- "name": "Input 4 Wattage"
+ "name": "Input 4 wattage"
},
"tlx_solar_generation_total": {
"name": "Lifetime total solar energy"
@@ -434,10 +434,10 @@
"name": "Money lifetime"
},
"total_energy_today": {
- "name": "Energy Today"
+ "name": "Energy today"
},
"total_output_power": {
- "name": "Output Power"
+ "name": "Output power"
},
"total_energy_output": {
"name": "[%key:component::growatt_server::entity::sensor::inverter_energy_total::name%]"
diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py
index 2637a55f772..8c624e2cdd6 100644
--- a/homeassistant/components/gtfs/sensor.py
+++ b/homeassistant/components/gtfs/sensor.py
@@ -341,7 +341,7 @@ def get_next_departure(
{tomorrow_order}
origin_stop_time.departure_time
LIMIT :limit
- """
+ """ # noqa: S608
result = schedule.engine.connect().execute(
text(sql_query),
{
diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py
index c1cbb4c0e5a..075c388c4e4 100644
--- a/homeassistant/components/guardian/__init__.py
+++ b/homeassistant/components/guardian/__init__.py
@@ -11,7 +11,7 @@ from aioguardian import Client
from aioguardian.errors import GuardianError
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
CONF_DEVICE_ID,
@@ -247,12 +247,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# If this is the last loaded instance of Guardian, deregister any services
# defined during integration setup:
for service_name in SERVICES:
diff --git a/homeassistant/components/guardian/binary_sensor.py b/homeassistant/components/guardian/binary_sensor.py
index 84bb61da0e5..7d5f97bdb65 100644
--- a/homeassistant/components/guardian/binary_sensor.py
+++ b/homeassistant/components/guardian/binary_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import GuardianData
from .const import (
@@ -86,7 +86,9 @@ VALVE_CONTROLLER_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Guardian switches based on a config entry."""
data: GuardianData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/guardian/button.py b/homeassistant/components/guardian/button.py
index f4881a9d94b..01bac63c6e3 100644
--- a/homeassistant/components/guardian/button.py
+++ b/homeassistant/components/guardian/button.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import GuardianData
from .const import API_SYSTEM_DIAGNOSTICS, DOMAIN
@@ -68,7 +68,9 @@ BUTTON_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Guardian buttons based on a config entry."""
data: GuardianData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/guardian/sensor.py b/homeassistant/components/guardian/sensor.py
index 3f9547e652a..13dd8e01296 100644
--- a/homeassistant/components/guardian/sensor.py
+++ b/homeassistant/components/guardian/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import GuardianData
@@ -137,7 +137,9 @@ VALVE_CONTROLLER_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Guardian switches based on a config entry."""
data: GuardianData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/guardian/switch.py b/homeassistant/components/guardian/switch.py
index fccf4f55a1f..a2c9ca282be 100644
--- a/homeassistant/components/guardian/switch.py
+++ b/homeassistant/components/guardian/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import GuardianData
from .const import API_VALVE_STATUS, API_WIFI_STATUS, DOMAIN
@@ -110,7 +110,9 @@ VALVE_CONTROLLER_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Guardian switches based on a config entry."""
data: GuardianData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/guardian/valve.py b/homeassistant/components/guardian/valve.py
index 8c9749958bf..6847b3211c5 100644
--- a/homeassistant/components/guardian/valve.py
+++ b/homeassistant/components/guardian/valve.py
@@ -17,7 +17,7 @@ from homeassistant.components.valve import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import GuardianData
from .const import API_VALVE_STATUS, DOMAIN
@@ -109,7 +109,9 @@ VALVE_CONTROLLER_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Guardian switches based on a config entry."""
data: GuardianData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py
index 6198ed14de8..c6f7ee0fb83 100644
--- a/homeassistant/components/habitica/binary_sensor.py
+++ b/homeassistant/components/habitica/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ASSETS_URL
from .coordinator import HabiticaConfigEntry
@@ -56,7 +56,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[HabiticaBinarySensorEntityDescription, ...] =
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HabiticaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the habitica binary sensors."""
diff --git a/homeassistant/components/habitica/button.py b/homeassistant/components/habitica/button.py
index 40325c49a7b..c57ba39fb6a 100644
--- a/homeassistant/components/habitica/button.py
+++ b/homeassistant/components/habitica/button.py
@@ -25,7 +25,7 @@ from homeassistant.components.button import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ASSETS_URL, DOMAIN
from .coordinator import (
@@ -280,7 +280,7 @@ CLASS_SKILLS: tuple[HabiticaButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: HabiticaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up buttons from a config entry."""
diff --git a/homeassistant/components/habitica/calendar.py b/homeassistant/components/habitica/calendar.py
index 5ef9cd2eba1..b87a49670b0 100644
--- a/homeassistant/components/habitica/calendar.py
+++ b/homeassistant/components/habitica/calendar.py
@@ -18,7 +18,7 @@ from homeassistant.components.calendar import (
CalendarEvent,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator
@@ -40,7 +40,7 @@ class HabiticaCalendar(StrEnum):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HabiticaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the calendar platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py
index 5eb616142e5..7a5677cb687 100644
--- a/homeassistant/components/habitica/const.py
+++ b/homeassistant/components/habitica/const.py
@@ -35,6 +35,28 @@ ATTR_TYPE = "type"
ATTR_PRIORITY = "priority"
ATTR_TAG = "tag"
ATTR_KEYWORD = "keyword"
+ATTR_REMOVE_TAG = "remove_tag"
+ATTR_ALIAS = "alias"
+ATTR_PRIORITY = "priority"
+ATTR_COST = "cost"
+ATTR_NOTES = "notes"
+ATTR_UP_DOWN = "up_down"
+ATTR_FREQUENCY = "frequency"
+ATTR_COUNTER_UP = "counter_up"
+ATTR_COUNTER_DOWN = "counter_down"
+ATTR_ADD_CHECKLIST_ITEM = "add_checklist_item"
+ATTR_REMOVE_CHECKLIST_ITEM = "remove_checklist_item"
+ATTR_SCORE_CHECKLIST_ITEM = "score_checklist_item"
+ATTR_UNSCORE_CHECKLIST_ITEM = "unscore_checklist_item"
+ATTR_REMINDER = "reminder"
+ATTR_REMOVE_REMINDER = "remove_reminder"
+ATTR_CLEAR_REMINDER = "clear_reminder"
+ATTR_CLEAR_DATE = "clear_date"
+ATTR_REPEAT = "repeat"
+ATTR_INTERVAL = "every_x"
+ATTR_START_DATE = "start_date"
+ATTR_REPEAT_MONTHLY = "repeat_monthly"
+ATTR_STREAK = "streak"
SERVICE_CAST_SKILL = "cast_skill"
SERVICE_START_QUEST = "start_quest"
@@ -50,6 +72,14 @@ SERVICE_SCORE_REWARD = "score_reward"
SERVICE_TRANSFORMATION = "transformation"
+SERVICE_UPDATE_REWARD = "update_reward"
+SERVICE_CREATE_REWARD = "create_reward"
+SERVICE_UPDATE_HABIT = "update_habit"
+SERVICE_CREATE_HABIT = "create_habit"
+SERVICE_UPDATE_TODO = "update_todo"
+SERVICE_CREATE_TODO = "create_todo"
+SERVICE_UPDATE_DAILY = "update_daily"
+SERVICE_CREATE_DAILY = "create_daily"
DEVELOPER_ID = "4c4ca53f-c059-4ffa-966e-9d29dd405daf"
X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"
@@ -57,3 +87,5 @@ X_CLIENT = f"{DEVELOPER_ID} - {APPLICATION_NAME} {__version__}"
SECTION_REAUTH_LOGIN = "reauth_login"
SECTION_REAUTH_API_KEY = "reauth_api_key"
SECTION_DANGER_ZONE = "danger_zone"
+
+WEEK_DAYS = ["m", "t", "w", "th", "f", "s", "su"]
diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py
index 19d31f18fd7..3c3a16f591a 100644
--- a/homeassistant/components/habitica/coordinator.py
+++ b/homeassistant/components/habitica/coordinator.py
@@ -11,6 +11,7 @@ from typing import Any
from aiohttp import ClientError
from habiticalib import (
+ Avatar,
ContentData,
Habitica,
HabiticaException,
@@ -19,7 +20,6 @@ from habiticalib import (
TaskFilter,
TooManyRequestsError,
UserData,
- UserStyles,
)
from homeassistant.config_entries import ConfigEntry
@@ -165,12 +165,10 @@ class HabiticaDataUpdateCoordinator(DataUpdateCoordinator[HabiticaData]):
else:
await self.async_request_refresh()
- async def generate_avatar(self, user_styles: UserStyles) -> bytes:
+ async def generate_avatar(self, avatar: Avatar) -> bytes:
"""Generate Avatar."""
- avatar = BytesIO()
- await self.habitica.generate_avatar(
- fp=avatar, user_styles=user_styles, fmt="PNG"
- )
+ png = BytesIO()
+ await self.habitica.generate_avatar(fp=png, avatar=avatar, fmt="PNG")
- return avatar.getvalue()
+ return png.getvalue()
diff --git a/homeassistant/components/habitica/diagnostics.py b/homeassistant/components/habitica/diagnostics.py
index 09b8b9ba0bb..40a6d75b366 100644
--- a/homeassistant/components/habitica/diagnostics.py
+++ b/homeassistant/components/habitica/diagnostics.py
@@ -23,5 +23,5 @@ async def async_get_config_entry_diagnostics(
CONF_URL: config_entry.data[CONF_URL],
CONF_API_USER: config_entry.data[CONF_API_USER],
},
- "habitica_data": habitica_data.to_dict()["data"],
+ "habitica_data": habitica_data.to_dict(omit_none=False)["data"],
}
diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py
index 932fec69f83..692ea5e5ac1 100644
--- a/homeassistant/components/habitica/entity.py
+++ b/homeassistant/components/habitica/entity.py
@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
+from yarl import URL
+
from homeassistant.const import CONF_NAME, CONF_URL
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
@@ -36,6 +38,10 @@ class HabiticaBase(CoordinatorEntity[HabiticaDataUpdateCoordinator]):
manufacturer=MANUFACTURER,
model=NAME,
name=coordinator.config_entry.data[CONF_NAME],
- configuration_url=coordinator.config_entry.data[CONF_URL],
+ configuration_url=(
+ URL(coordinator.config_entry.data[CONF_URL])
+ / "profile"
+ / coordinator.config_entry.unique_id
+ ),
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
)
diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json
index 6ae6ebd728b..aac90814af5 100644
--- a/homeassistant/components/habitica/icons.json
+++ b/homeassistant/components/habitica/icons.json
@@ -217,6 +217,67 @@
"sections": {
"filter": "mdi:calendar-filter"
}
+ },
+ "update_reward": {
+ "service": "mdi:treasure-chest",
+ "sections": {
+ "tag_options": "mdi:tag",
+ "developer_options": "mdi:test-tube"
+ }
+ },
+ "create_reward": {
+ "service": "mdi:treasure-chest-outline",
+ "sections": {
+ "developer_options": "mdi:test-tube"
+ }
+ },
+ "update_habit": {
+ "service": "mdi:contrast-box",
+ "sections": {
+ "tag_options": "mdi:tag",
+ "developer_options": "mdi:test-tube"
+ }
+ },
+ "create_habit": {
+ "service": "mdi:contrast-box",
+ "sections": {
+ "developer_options": "mdi:test-tube"
+ }
+ },
+ "update_todo": {
+ "service": "mdi:pencil-box-outline",
+ "sections": {
+ "checklist_options": "mdi:format-list-checks",
+ "tag_options": "mdi:tag",
+ "developer_options": "mdi:test-tube",
+ "duedate_options": "mdi:calendar-blank",
+ "reminder_options": "mdi:reminder"
+ }
+ },
+ "create_todo": {
+ "service": "mdi:pencil-box-outline",
+ "sections": {
+ "developer_options": "mdi:test-tube"
+ }
+ },
+ "update_daily": {
+ "service": "mdi:calendar-month",
+ "sections": {
+ "checklist_options": "mdi:format-list-checks",
+ "tag_options": "mdi:tag",
+ "developer_options": "mdi:test-tube",
+ "reminder_options": "mdi:reminder",
+ "repeat_weekly_options": "mdi:calendar-refresh",
+ "repeat_monthly_options": "mdi:calendar-refresh"
+ }
+ },
+ "create_daily": {
+ "service": "mdi:calendar-month",
+ "sections": {
+ "developer_options": "mdi:test-tube",
+ "repeat_weekly_options": "mdi:calendar-refresh",
+ "repeat_monthly_options": "mdi:calendar-refresh"
+ }
}
}
}
diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py
index b3b2fbb85a8..1669f124bc7 100644
--- a/homeassistant/components/habitica/image.py
+++ b/homeassistant/components/habitica/image.py
@@ -2,14 +2,13 @@
from __future__ import annotations
-from dataclasses import asdict
from enum import StrEnum
-from habiticalib import UserStyles
+from habiticalib import Avatar, extract_avatar
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator
@@ -27,7 +26,7 @@ class HabiticaImageEntity(StrEnum):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HabiticaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the habitica image platform."""
@@ -44,7 +43,7 @@ class HabiticaImage(HabiticaBase, ImageEntity):
translation_key=HabiticaImageEntity.AVATAR,
)
_attr_content_type = "image/png"
- _current_appearance: UserStyles | None = None
+ _avatar: Avatar | None = None
_cache: bytes | None = None
def __init__(
@@ -56,13 +55,13 @@ class HabiticaImage(HabiticaBase, ImageEntity):
super().__init__(coordinator, self.entity_description)
ImageEntity.__init__(self, hass)
self._attr_image_last_updated = dt_util.utcnow()
+ self._avatar = extract_avatar(self.coordinator.data.user)
def _handle_coordinator_update(self) -> None:
"""Check if equipped gear and other things have changed since last avatar image generation."""
- new_appearance = UserStyles.from_dict(asdict(self.coordinator.data.user))
- if self._current_appearance != new_appearance:
- self._current_appearance = new_appearance
+ if self._avatar != self.coordinator.data.user:
+ self._avatar = extract_avatar(self.coordinator.data.user)
self._attr_image_last_updated = dt_util.utcnow()
self._cache = None
@@ -70,8 +69,6 @@ class HabiticaImage(HabiticaBase, ImageEntity):
async def async_image(self) -> bytes | None:
"""Return cached bytes, otherwise generate new avatar."""
- if not self._cache and self._current_appearance:
- self._cache = await self.coordinator.generate_avatar(
- self._current_appearance
- )
+ if not self._cache and self._avatar:
+ self._cache = await self.coordinator.generate_avatar(self._avatar)
return self._cache
diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json
index 9ea346a0dcb..48b6997239e 100644
--- a/homeassistant/components/habitica/manifest.json
+++ b/homeassistant/components/habitica/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
"loggers": ["habiticalib"],
- "requirements": ["habiticalib==0.3.5"]
+ "quality_scale": "platinum",
+ "requirements": ["habiticalib==0.3.7"]
}
diff --git a/homeassistant/components/habitica/quality_scale.yaml b/homeassistant/components/habitica/quality_scale.yaml
index 9eadba496f2..1752e67cf46 100644
--- a/homeassistant/components/habitica/quality_scale.yaml
+++ b/homeassistant/components/habitica/quality_scale.yaml
@@ -51,7 +51,7 @@ rules:
status: exempt
comment: No supportable devices.
docs-supported-functions: done
- docs-troubleshooting: todo
+ docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py
index fa36025c5ce..e715dd6d07b 100644
--- a/homeassistant/components/habitica/sensor.py
+++ b/homeassistant/components/habitica/sensor.py
@@ -28,13 +28,14 @@ from homeassistant.components.sensor import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
async_delete_issue,
)
from homeassistant.helpers.typing import StateType
+from homeassistant.util import dt as dt_util
from .const import ASSETS_URL, DOMAIN
from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator
@@ -105,6 +106,20 @@ SENSOR_DESCRIPTIONS: tuple[HabiticaSensorEntityDescription, ...] = (
key=HabiticaSensorEntity.DISPLAY_NAME,
translation_key=HabiticaSensorEntity.DISPLAY_NAME,
value_fn=lambda user, _: user.profile.name,
+ attributes_fn=lambda user, _: {
+ "blurb": user.profile.blurb,
+ "joined": (
+ dt_util.as_local(joined).date()
+ if (joined := user.auth.timestamps.created)
+ else None
+ ),
+ "last_login": (
+ dt_util.as_local(last).date()
+ if (last := user.auth.timestamps.loggedin)
+ else None
+ ),
+ "total_logins": user.loginIncentives,
+ },
),
HabiticaSensorEntityDescription(
key=HabiticaSensorEntity.HEALTH,
@@ -305,7 +320,7 @@ def entity_used_in(hass: HomeAssistant, entity_id: str) -> list[str]:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HabiticaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the habitica sensors."""
@@ -393,6 +408,11 @@ class HabiticaSensor(HabiticaBase, SensorEntity):
):
return SVG_CLASS[_class]
+ if self.entity_description.key is HabiticaSensorEntity.DISPLAY_NAME and (
+ img_url := self.coordinator.data.user.profile.imageUrl
+ ):
+ return img_url
+
if entity_picture := self.entity_description.entity_picture:
return (
entity_picture
diff --git a/homeassistant/components/habitica/services.py b/homeassistant/components/habitica/services.py
index 12d5b3e6ef8..bcbd6caa7a7 100644
--- a/homeassistant/components/habitica/services.py
+++ b/homeassistant/components/habitica/services.py
@@ -3,16 +3,23 @@
from __future__ import annotations
from dataclasses import asdict
+from datetime import UTC, date, datetime, time
import logging
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, cast
+from uuid import UUID, uuid4
from aiohttp import ClientError
from habiticalib import (
+ Checklist,
Direction,
+ Frequency,
HabiticaException,
NotAuthorizedError,
NotFoundError,
+ Reminders,
+ Repeat,
Skill,
+ Task,
TaskData,
TaskPriority,
TaskType,
@@ -20,8 +27,9 @@ from habiticalib import (
)
import voluptuous as vol
+from homeassistant.components.todo import ATTR_RENAME
from homeassistant.config_entries import ConfigEntryState
-from homeassistant.const import ATTR_NAME, CONF_NAME
+from homeassistant.const import ATTR_DATE, ATTR_NAME, CONF_NAME
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -32,21 +40,43 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.selector import ConfigEntrySelector
+from homeassistant.util import dt as dt_util
from .const import (
+ ATTR_ADD_CHECKLIST_ITEM,
+ ATTR_ALIAS,
ATTR_ARGS,
+ ATTR_CLEAR_DATE,
+ ATTR_CLEAR_REMINDER,
ATTR_CONFIG_ENTRY,
+ ATTR_COST,
+ ATTR_COUNTER_DOWN,
+ ATTR_COUNTER_UP,
ATTR_DATA,
ATTR_DIRECTION,
+ ATTR_FREQUENCY,
+ ATTR_INTERVAL,
ATTR_ITEM,
ATTR_KEYWORD,
+ ATTR_NOTES,
ATTR_PATH,
ATTR_PRIORITY,
+ ATTR_REMINDER,
+ ATTR_REMOVE_CHECKLIST_ITEM,
+ ATTR_REMOVE_REMINDER,
+ ATTR_REMOVE_TAG,
+ ATTR_REPEAT,
+ ATTR_REPEAT_MONTHLY,
+ ATTR_SCORE_CHECKLIST_ITEM,
ATTR_SKILL,
+ ATTR_START_DATE,
+ ATTR_STREAK,
ATTR_TAG,
ATTR_TARGET,
ATTR_TASK,
ATTR_TYPE,
+ ATTR_UNSCORE_CHECKLIST_ITEM,
+ ATTR_UP_DOWN,
DOMAIN,
EVENT_API_CALL_SUCCESS,
SERVICE_ABORT_QUEST,
@@ -54,6 +84,10 @@ from .const import (
SERVICE_API_CALL,
SERVICE_CANCEL_QUEST,
SERVICE_CAST_SKILL,
+ SERVICE_CREATE_DAILY,
+ SERVICE_CREATE_HABIT,
+ SERVICE_CREATE_REWARD,
+ SERVICE_CREATE_TODO,
SERVICE_GET_TASKS,
SERVICE_LEAVE_QUEST,
SERVICE_REJECT_QUEST,
@@ -61,6 +95,11 @@ from .const import (
SERVICE_SCORE_REWARD,
SERVICE_START_QUEST,
SERVICE_TRANSFORMATION,
+ SERVICE_UPDATE_DAILY,
+ SERVICE_UPDATE_HABIT,
+ SERVICE_UPDATE_REWARD,
+ SERVICE_UPDATE_TODO,
+ WEEK_DAYS,
)
from .coordinator import HabiticaConfigEntry
@@ -104,6 +143,65 @@ SERVICE_TRANSFORMATION_SCHEMA = vol.Schema(
}
)
+BASE_TASK_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector(),
+ vol.Optional(ATTR_RENAME): cv.string,
+ vol.Optional(ATTR_NOTES): cv.string,
+ vol.Optional(ATTR_TAG): vol.All(cv.ensure_list, [str]),
+ vol.Optional(ATTR_ALIAS): vol.All(
+ cv.string, cv.matches_regex("^[a-zA-Z0-9-_]*$")
+ ),
+ vol.Optional(ATTR_COST): vol.All(vol.Coerce(float), vol.Range(0)),
+ vol.Optional(ATTR_PRIORITY): vol.All(
+ vol.Upper, vol.In(TaskPriority._member_names_)
+ ),
+ vol.Optional(ATTR_UP_DOWN): vol.All(cv.ensure_list, [str]),
+ vol.Optional(ATTR_COUNTER_UP): vol.All(int, vol.Range(0)),
+ vol.Optional(ATTR_COUNTER_DOWN): vol.All(int, vol.Range(0)),
+ vol.Optional(ATTR_FREQUENCY): vol.Coerce(Frequency),
+ vol.Optional(ATTR_DATE): cv.date,
+ vol.Optional(ATTR_CLEAR_DATE): cv.boolean,
+ vol.Optional(ATTR_REMINDER): vol.All(
+ cv.ensure_list, [vol.Any(cv.datetime, cv.time)]
+ ),
+ vol.Optional(ATTR_REMOVE_REMINDER): vol.All(
+ cv.ensure_list, [vol.Any(cv.datetime, cv.time)]
+ ),
+ vol.Optional(ATTR_CLEAR_REMINDER): cv.boolean,
+ vol.Optional(ATTR_ADD_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
+ vol.Optional(ATTR_REMOVE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
+ vol.Optional(ATTR_SCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
+ vol.Optional(ATTR_UNSCORE_CHECKLIST_ITEM): vol.All(cv.ensure_list, [str]),
+ vol.Optional(ATTR_START_DATE): cv.date,
+ vol.Optional(ATTR_INTERVAL): vol.All(int, vol.Range(0)),
+ vol.Optional(ATTR_REPEAT): vol.All(cv.ensure_list, [vol.In(WEEK_DAYS)]),
+ vol.Optional(ATTR_REPEAT_MONTHLY): vol.All(
+ cv.string, vol.In({"day_of_month", "day_of_week"})
+ ),
+ vol.Optional(ATTR_STREAK): vol.All(int, vol.Range(0)),
+ }
+)
+
+SERVICE_UPDATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend(
+ {
+ vol.Required(ATTR_TASK): cv.string,
+ vol.Optional(ATTR_REMOVE_TAG): vol.All(cv.ensure_list, [str]),
+ }
+)
+
+SERVICE_CREATE_TASK_SCHEMA = BASE_TASK_SCHEMA.extend(
+ {
+ vol.Required(ATTR_NAME): cv.string,
+ }
+)
+
+SERVICE_DAILY_SCHEMA = {
+ vol.Optional(ATTR_REMINDER): vol.All(cv.ensure_list, [cv.time]),
+ vol.Optional(ATTR_REMOVE_REMINDER): vol.All(cv.ensure_list, [cv.time]),
+}
+
+
SERVICE_GET_TASKS_SCHEMA = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): ConfigEntrySelector({"integration": DOMAIN}),
@@ -138,6 +236,17 @@ ITEMID_MAP = {
"shiny_seed": Skill.SHINY_SEED,
}
+SERVICE_TASK_TYPE_MAP = {
+ SERVICE_UPDATE_REWARD: TaskType.REWARD,
+ SERVICE_CREATE_REWARD: TaskType.REWARD,
+ SERVICE_UPDATE_HABIT: TaskType.HABIT,
+ SERVICE_CREATE_HABIT: TaskType.HABIT,
+ SERVICE_UPDATE_TODO: TaskType.TODO,
+ SERVICE_CREATE_TODO: TaskType.TODO,
+ SERVICE_UPDATE_DAILY: TaskType.DAILY,
+ SERVICE_CREATE_DAILY: TaskType.DAILY,
+}
+
def get_config_entry(hass: HomeAssistant, entry_id: str) -> HabiticaConfigEntry:
"""Return config entry or raise if not found or not loaded."""
@@ -510,10 +619,315 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901
or (task.notes and keyword in task.notes.lower())
or any(keyword in item.text.lower() for item in task.checklist)
]
- result: dict[str, Any] = {"tasks": [task.to_dict() for task in response]}
+ result: dict[str, Any] = {
+ "tasks": [task.to_dict(omit_none=False) for task in response]
+ }
return result
+ async def create_or_update_task(call: ServiceCall) -> ServiceResponse: # noqa: C901
+ """Create or update task action."""
+ entry = get_config_entry(hass, call.data[ATTR_CONFIG_ENTRY])
+ coordinator = entry.runtime_data
+ await coordinator.async_refresh()
+ is_update = call.service in (
+ SERVICE_UPDATE_HABIT,
+ SERVICE_UPDATE_REWARD,
+ SERVICE_UPDATE_TODO,
+ SERVICE_UPDATE_DAILY,
+ )
+ task_type = SERVICE_TASK_TYPE_MAP[call.service]
+ current_task = None
+
+ if is_update:
+ try:
+ current_task = next(
+ task
+ for task in coordinator.data.tasks
+ if call.data[ATTR_TASK] in (str(task.id), task.alias, task.text)
+ and task.Type is task_type
+ )
+ except StopIteration as e:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="task_not_found",
+ translation_placeholders={"task": f"'{call.data[ATTR_TASK]}'"},
+ ) from e
+
+ data = Task()
+
+ if not is_update:
+ data["type"] = task_type
+
+ if (text := call.data.get(ATTR_RENAME)) or (text := call.data.get(ATTR_NAME)):
+ data["text"] = text
+
+ if (notes := call.data.get(ATTR_NOTES)) is not None:
+ data["notes"] = notes
+
+ tags = cast(list[str], call.data.get(ATTR_TAG))
+ remove_tags = cast(list[str], call.data.get(ATTR_REMOVE_TAG))
+
+ if tags or remove_tags:
+ update_tags = set(current_task.tags) if current_task else set()
+ user_tags = {
+ tag.name.lower(): tag.id
+ for tag in coordinator.data.user.tags
+ if tag.id and tag.name
+ }
+
+ if tags:
+ # Creates new tag if it doesn't exist
+ async def create_tag(tag_name: str) -> UUID:
+ tag_id = (await coordinator.habitica.create_tag(tag_name)).data.id
+ if TYPE_CHECKING:
+ assert tag_id
+ return tag_id
+
+ try:
+ update_tags.update(
+ {
+ user_tags.get(tag_name.lower())
+ or (await create_tag(tag_name))
+ for tag_name in tags
+ }
+ )
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except HabiticaException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e.error.message)},
+ ) from e
+ except ClientError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
+ ) from e
+
+ if remove_tags:
+ update_tags.difference_update(
+ {
+ user_tags[tag_name.lower()]
+ for tag_name in remove_tags
+ if tag_name.lower() in user_tags
+ }
+ )
+
+ data["tags"] = list(update_tags)
+
+ if (alias := call.data.get(ATTR_ALIAS)) is not None:
+ data["alias"] = alias
+
+ if (cost := call.data.get(ATTR_COST)) is not None:
+ data["value"] = cost
+
+ if priority := call.data.get(ATTR_PRIORITY):
+ data["priority"] = TaskPriority[priority]
+
+ if frequency := call.data.get(ATTR_FREQUENCY):
+ data["frequency"] = frequency
+ else:
+ frequency = current_task.frequency if current_task else Frequency.WEEKLY
+
+ if up_down := call.data.get(ATTR_UP_DOWN):
+ data["up"] = "up" in up_down
+ data["down"] = "down" in up_down
+
+ if counter_up := call.data.get(ATTR_COUNTER_UP):
+ data["counterUp"] = counter_up
+
+ if counter_down := call.data.get(ATTR_COUNTER_DOWN):
+ data["counterDown"] = counter_down
+
+ if due_date := call.data.get(ATTR_DATE):
+ data["date"] = datetime.combine(due_date, time())
+
+ if call.data.get(ATTR_CLEAR_DATE):
+ data["date"] = None
+
+ checklist = current_task.checklist if current_task else []
+
+ if add_checklist_item := call.data.get(ATTR_ADD_CHECKLIST_ITEM):
+ checklist.extend(
+ Checklist(completed=False, id=uuid4(), text=item)
+ for item in add_checklist_item
+ if not any(i.text == item for i in checklist)
+ )
+ if remove_checklist_item := call.data.get(ATTR_REMOVE_CHECKLIST_ITEM):
+ checklist = [
+ item for item in checklist if item.text not in remove_checklist_item
+ ]
+
+ if score_checklist_item := call.data.get(ATTR_SCORE_CHECKLIST_ITEM):
+ for item in checklist:
+ if item.text in score_checklist_item:
+ item.completed = True
+
+ if unscore_checklist_item := call.data.get(ATTR_UNSCORE_CHECKLIST_ITEM):
+ for item in checklist:
+ if item.text in unscore_checklist_item:
+ item.completed = False
+ if (
+ add_checklist_item
+ or remove_checklist_item
+ or score_checklist_item
+ or unscore_checklist_item
+ ):
+ data["checklist"] = checklist
+
+ reminders = current_task.reminders if current_task else []
+
+ if add_reminders := call.data.get(ATTR_REMINDER):
+ if task_type is TaskType.TODO:
+ existing_reminder_datetimes = {
+ r.time.replace(tzinfo=None) for r in reminders
+ }
+
+ reminders.extend(
+ Reminders(id=uuid4(), time=r)
+ for r in add_reminders
+ if r not in existing_reminder_datetimes
+ )
+ if task_type is TaskType.DAILY:
+ existing_reminder_times = {
+ r.time.time().replace(microsecond=0, second=0) for r in reminders
+ }
+
+ reminders.extend(
+ Reminders(
+ id=uuid4(),
+ time=datetime.combine(date.today(), r, tzinfo=UTC),
+ )
+ for r in add_reminders
+ if r not in existing_reminder_times
+ )
+
+ if remove_reminder := call.data.get(ATTR_REMOVE_REMINDER):
+ if task_type is TaskType.TODO:
+ reminders = list(
+ filter(
+ lambda r: r.time.replace(tzinfo=None) not in remove_reminder,
+ reminders,
+ )
+ )
+ if task_type is TaskType.DAILY:
+ reminders = list(
+ filter(
+ lambda r: r.time.time().replace(second=0, microsecond=0)
+ not in remove_reminder,
+ reminders,
+ )
+ )
+
+ if clear_reminders := call.data.get(ATTR_CLEAR_REMINDER):
+ reminders = []
+
+ if add_reminders or remove_reminder or clear_reminders:
+ data["reminders"] = reminders
+
+ if start_date := call.data.get(ATTR_START_DATE):
+ data["startDate"] = datetime.combine(start_date, time())
+ else:
+ start_date = (
+ current_task.startDate
+ if current_task and current_task.startDate
+ else dt_util.start_of_local_day()
+ )
+ if repeat := call.data.get(ATTR_REPEAT):
+ if frequency is Frequency.WEEKLY:
+ data["repeat"] = Repeat(**{d: d in repeat for d in WEEK_DAYS})
+ else:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="frequency_not_weekly",
+ )
+ if repeat_monthly := call.data.get(ATTR_REPEAT_MONTHLY):
+ if frequency is not Frequency.MONTHLY:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="frequency_not_monthly",
+ )
+
+ if repeat_monthly == "day_of_week":
+ weekday = start_date.weekday()
+ data["weeksOfMonth"] = [(start_date.day - 1) // 7]
+ data["repeat"] = Repeat(
+ **{day: i == weekday for i, day in enumerate(WEEK_DAYS)}
+ )
+ data["daysOfMonth"] = []
+
+ else:
+ data["daysOfMonth"] = [start_date.day]
+ data["weeksOfMonth"] = []
+
+ if interval := call.data.get(ATTR_INTERVAL):
+ data["everyX"] = interval
+
+ if streak := call.data.get(ATTR_STREAK):
+ data["streak"] = streak
+
+ try:
+ if is_update:
+ if TYPE_CHECKING:
+ assert current_task
+ assert current_task.id
+ response = await coordinator.habitica.update_task(current_task.id, data)
+ else:
+ response = await coordinator.habitica.create_task(data)
+ except TooManyRequestsError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="setup_rate_limit_exception",
+ translation_placeholders={"retry_after": str(e.retry_after)},
+ ) from e
+ except HabiticaException as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e.error.message)},
+ ) from e
+ except ClientError as e:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="service_call_exception",
+ translation_placeholders={"reason": str(e)},
+ ) from e
+ else:
+ return response.data.to_dict(omit_none=True)
+
+ for service in (
+ SERVICE_UPDATE_DAILY,
+ SERVICE_UPDATE_HABIT,
+ SERVICE_UPDATE_REWARD,
+ SERVICE_UPDATE_TODO,
+ ):
+ hass.services.async_register(
+ DOMAIN,
+ service,
+ create_or_update_task,
+ schema=SERVICE_UPDATE_TASK_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
+ for service in (
+ SERVICE_CREATE_DAILY,
+ SERVICE_CREATE_HABIT,
+ SERVICE_CREATE_REWARD,
+ SERVICE_CREATE_TODO,
+ ):
+ hass.services.async_register(
+ DOMAIN,
+ service,
+ create_or_update_task,
+ schema=SERVICE_CREATE_TASK_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
hass.services.async_register(
DOMAIN,
SERVICE_API_CALL,
diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml
index f3095518290..3fb25e2b4b7 100644
--- a/homeassistant/components/habitica/services.yaml
+++ b/homeassistant/components/habitica/services.yaml
@@ -140,3 +140,307 @@ get_tasks:
required: false
selector:
text:
+update_reward:
+ fields:
+ config_entry: *config_entry
+ task: *task
+ rename: &rename
+ selector:
+ text:
+ notes: ¬es
+ required: false
+ selector:
+ text:
+ multiline: true
+ cost:
+ required: false
+ selector: &cost_selector
+ number:
+ min: 0
+ step: 0.01
+ unit_of_measurement: "🪙"
+ mode: box
+ tag_options: &tag_options
+ collapsed: true
+ fields:
+ tag: &tag
+ required: false
+ selector:
+ text:
+ multiple: true
+ remove_tag:
+ required: false
+ selector:
+ text:
+ multiple: true
+ developer_options: &developer_options
+ collapsed: true
+ fields:
+ alias: &alias
+ required: false
+ selector:
+ text:
+create_reward:
+ fields:
+ config_entry: *config_entry
+ name: &name
+ required: true
+ selector:
+ text:
+ notes: *notes
+ cost:
+ required: true
+ selector: *cost_selector
+ tag: *tag
+ developer_options: *developer_options
+update_habit:
+ fields:
+ config_entry: *config_entry
+ task: *task
+ rename: *rename
+ notes: *notes
+ up_down: &up_down
+ required: false
+ selector:
+ select:
+ options:
+ - value: up
+ label: "➕"
+ - value: down
+ label: "➖"
+ multiple: true
+ mode: list
+ priority: &priority
+ required: false
+ selector:
+ select:
+ options:
+ - "trivial"
+ - "easy"
+ - "medium"
+ - "hard"
+ mode: dropdown
+ translation_key: "priority"
+ frequency: &frequency
+ required: false
+ selector:
+ select:
+ options:
+ - "daily"
+ - "weekly"
+ - "monthly"
+ translation_key: "frequency"
+ mode: dropdown
+ tag_options: *tag_options
+ developer_options:
+ collapsed: true
+ fields:
+ counter_up:
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ unit_of_measurement: "➕"
+ mode: box
+ counter_down:
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ unit_of_measurement: "➖"
+ mode: box
+ alias: *alias
+create_habit:
+ fields:
+ config_entry: *config_entry
+ name: *name
+ notes: *notes
+ up_down: *up_down
+ priority: *priority
+ frequency: *frequency
+ tag: *tag
+ developer_options: *developer_options
+update_todo:
+ fields:
+ config_entry: *config_entry
+ task: *task
+ rename: *rename
+ notes: *notes
+ checklist_options: &checklist_options
+ collapsed: true
+ fields:
+ add_checklist_item: &add_checklist_item
+ required: false
+ selector:
+ text:
+ multiple: true
+ remove_checklist_item:
+ required: false
+ selector:
+ text:
+ multiple: true
+ score_checklist_item:
+ required: false
+ selector:
+ text:
+ multiple: true
+ unscore_checklist_item:
+ required: false
+ selector:
+ text:
+ multiple: true
+ priority: *priority
+ duedate_options:
+ collapsed: true
+ fields:
+ date: &due_date
+ required: false
+ selector:
+ date:
+ clear_date:
+ required: false
+ selector:
+ constant:
+ value: true
+ label: "🗑️"
+ reminder_options:
+ collapsed: true
+ fields:
+ reminder: &reminder
+ required: false
+ selector:
+ text:
+ type: datetime-local
+ multiple: true
+ remove_reminder:
+ required: false
+ selector:
+ text:
+ type: datetime-local
+ multiple: true
+ clear_reminder: &clear_reminder
+ required: false
+ selector:
+ constant:
+ value: true
+ label: "🗑️"
+ tag_options: *tag_options
+ developer_options: *developer_options
+create_todo:
+ fields:
+ config_entry: *config_entry
+ name: *name
+ notes: *notes
+ add_checklist_item: *add_checklist_item
+ priority: *priority
+ date: *due_date
+ reminder: *reminder
+ tag: *tag
+ developer_options: *developer_options
+update_daily:
+ fields:
+ config_entry: *config_entry
+ task: *task
+ rename: *rename
+ notes: *notes
+ checklist_options: *checklist_options
+ priority: *priority
+ start_date: &start_date
+ required: false
+ selector:
+ date:
+ frequency: &frequency_daily
+ required: false
+ selector:
+ select:
+ options:
+ - "daily"
+ - "weekly"
+ - "monthly"
+ - "yearly"
+ translation_key: "frequency"
+ mode: dropdown
+ every_x: &every_x
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ unit_of_measurement: "🔃"
+ mode: box
+ repeat_weekly_options: &repeat_weekly_options
+ collapsed: true
+ fields:
+ repeat:
+ required: false
+ selector:
+ select:
+ options:
+ - "m"
+ - "t"
+ - "w"
+ - "th"
+ - "f"
+ - "s"
+ - "su"
+ mode: list
+ translation_key: repeat
+ multiple: true
+ repeat_monthly_options: &repeat_monthly_options
+ collapsed: true
+ fields:
+ repeat_monthly:
+ required: false
+ selector:
+ select:
+ options:
+ - "day_of_month"
+ - "day_of_week"
+ translation_key: repeat_monthly
+ mode: list
+ reminder_options:
+ collapsed: true
+ fields:
+ reminder: &reminder_daily
+ required: false
+ selector:
+ text:
+ type: time
+ multiple: true
+ remove_reminder:
+ required: false
+ selector:
+ text:
+ type: time
+ multiple: true
+ clear_reminder: *clear_reminder
+ tag_options: *tag_options
+ developer_options:
+ collapsed: true
+ fields:
+ streak: &streak
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ unit_of_measurement: "▶▶"
+ mode: box
+ alias: *alias
+create_daily:
+ fields:
+ config_entry: *config_entry
+ name: *name
+ notes: *notes
+ add_checklist_item: *add_checklist_item
+ priority: *priority
+ start_date: *start_date
+ frequency: *frequency_daily
+ every_x: *every_x
+ repeat_weekly_options: *repeat_weekly_options
+ repeat_monthly_options: *repeat_monthly_options
+ reminder: *reminder_daily
+ tag: *tag
+ developer_options: *developer_options
diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json
index 4d353cec40e..695eb1576fe 100644
--- a/homeassistant/components/habitica/strings.json
+++ b/homeassistant/components/habitica/strings.json
@@ -7,7 +7,64 @@
"unit_tasks": "tasks",
"unit_health_points": "HP",
"unit_mana_points": "MP",
- "unit_experience_points": "XP"
+ "unit_experience_points": "XP",
+ "config_entry_description": "Select the Habitica account to update a task.",
+ "task_description": "The name (or task ID) of the task you want to update.",
+ "rename_name": "Rename",
+ "rename_description": "The title for the Habitica task.",
+ "notes_name": "Notes",
+ "notes_description": "The notes for the Habitica task.",
+ "tag_name": "Add tags",
+ "tag_description": "Add tags to the Habitica task. If a tag does not already exist, a new one will be created.",
+ "remove_tag_name": "Remove tags",
+ "remove_tag_description": "Remove tags from the Habitica task.",
+ "alias_name": "Task alias",
+ "alias_description": "A task alias can be used instead of the name or task ID. Only dashes, underscores, and alphanumeric characters are supported. The task alias must be unique among all your tasks.",
+ "developer_options_name": "Advanced settings",
+ "developer_options_description": "Additional features available in developer mode.",
+ "tag_options_name": "Tags",
+ "tag_options_description": "Add or remove tags from a task.",
+ "name_description": "The title for the Habitica task.",
+ "cost_name": "Cost",
+ "priority_name": "Difficulty",
+ "priority_description": "The difficulty of the task.",
+ "frequency_name": "Counter reset",
+ "frequency_description": "The frequency at which the habit's counter resets: daily at the start of a new day, weekly after Sunday night, or monthly at the beginning of a new month.",
+ "up_down_name": "Rewards or losses",
+ "up_down_description": "Whether the habit is good and rewarding (positive), bad and penalizing (negative), or both.",
+ "add_checklist_item_name": "Add checklist items",
+ "add_checklist_item_description": "The items to add to a task's checklist.",
+ "remove_checklist_item_name": "Delete items",
+ "remove_checklist_item_description": "Remove items from a task's checklist.",
+ "score_checklist_item_name": "Complete items",
+ "score_checklist_item_description": "Mark items from a task's checklist as completed.",
+ "unscore_checklist_item_name": "Uncomplete items",
+ "unscore_checklist_item_description": "Undo completion of items of a task's checklist.",
+ "checklist_options_name": "Checklist",
+ "checklist_options_description": "Add, remove, or update status of an item on a task's checklist.",
+ "reminder_name": "Add reminders",
+ "reminder_description": "Add reminders to a Habitica task.",
+ "remove_reminder_name": "Remove reminders",
+ "remove_reminder_description": "Remove specific reminders from a Habitica task.",
+ "clear_reminder_name": "Clear all reminders",
+ "clear_reminder_description": "Remove all reminders from a Habitica task.",
+ "reminder_options_name": "Reminders",
+ "reminder_options_description": "Add, remove or clear reminders of a Habitica task.",
+ "date_name": "Due date",
+ "date_description": "The to-do's due date.",
+ "repeat_name": "Repeat on",
+ "start_date_name": "Start date",
+ "start_date_description": "Defines when the daily task becomes active and specifies the exact weekday or day of the month it repeats on.",
+ "frequency_daily_name": "Repeat interval",
+ "frequency_daily_description": "The repetition interval of a daily.",
+ "every_x_name": "Repeat every X",
+ "every_x_description": "The number of intervals (days, weeks, months, or years) after which the daily repeats, based on the chosen repetition interval. A value of 0 makes the daily inactive ('Grey Daily').",
+ "repeat_weekly_description": "The days of the week the daily repeats.",
+ "repeat_monthly_description": "Whether a monthly recurring task repeats on the same calendar day each month or on the same weekday and week of the month, based on the start date.",
+ "repeat_weekly_options_name": "Weekly repeat days",
+ "repeat_weekly_options_description": "Options related to weekly repetition, applicable when the repetition interval is set to weekly.",
+ "repeat_monthly_options_name": "Monthly repeat day",
+ "repeat_monthly_options_description": "Options related to monthly repetition, applicable when the repetition interval is set to monthly."
},
"config": {
"abort": {
@@ -199,7 +256,21 @@
},
"sensor": {
"display_name": {
- "name": "Display name"
+ "name": "Display name",
+ "state_attributes": {
+ "blurb": {
+ "name": "About"
+ },
+ "joined": {
+ "name": "Joined"
+ },
+ "last_login": {
+ "name": "Last login"
+ },
+ "total_logins": {
+ "name": "Total logins"
+ }
+ }
},
"health": {
"name": "Health",
@@ -443,6 +514,12 @@
},
"authentication_failed": {
"message": "Authentication failed. It looks like your API token has been reset. Please re-authenticate using your new token"
+ },
+ "frequency_not_weekly": {
+ "message": "Unable to update task, weekly repeat settings apply only to weekly recurring dailies."
+ },
+ "frequency_not_monthly": {
+ "message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies."
}
},
"issues": {
@@ -534,7 +611,7 @@
},
"cancel_quest": {
"name": "Cancel a pending quest",
- "description": "Cancels a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
+ "description": "Cancels a quest that has not yet started. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",
@@ -615,7 +692,7 @@
"description": "Filter tasks by type."
},
"priority": {
- "name": "Difficulty",
+ "name": "[%key:component::habitica::common::priority_name%]",
"description": "Filter tasks by difficulty."
},
"task": {
@@ -637,6 +714,530 @@
"description": "Use the optional filters to narrow the returned tasks."
}
}
+ },
+ "update_reward": {
+ "name": "Update a reward",
+ "description": "Updates a specific reward for the selected Habitica character",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "Select the Habitica account to update a reward."
+ },
+ "task": {
+ "name": "[%key:component::habitica::common::task_name%]",
+ "description": "[%key:component::habitica::common::task_description%]"
+ },
+ "rename": {
+ "name": "[%key:component::habitica::common::rename_name%]",
+ "description": "[%key:component::habitica::common::rename_description%]"
+ },
+ "notes": {
+ "name": "[%key:component::habitica::common::notes_name%]",
+ "description": "[%key:component::habitica::common::notes_description%]"
+ },
+ "tag": {
+ "name": "[%key:component::habitica::common::tag_name%]",
+ "description": "[%key:component::habitica::common::tag_description%]"
+ },
+ "remove_tag": {
+ "name": "[%key:component::habitica::common::remove_tag_name%]",
+ "description": "[%key:component::habitica::common::remove_tag_description%]"
+ },
+ "alias": {
+ "name": "[%key:component::habitica::common::alias_name%]",
+ "description": "[%key:component::habitica::common::alias_description%]"
+ },
+ "cost": {
+ "name": "[%key:component::habitica::common::cost_name%]",
+ "description": "Update the cost of a reward."
+ }
+ },
+ "sections": {
+ "tag_options": {
+ "name": "[%key:component::habitica::common::tag_options_name%]",
+ "description": "[%key:component::habitica::common::tag_options_description%]"
+ },
+ "developer_options": {
+ "name": "[%key:component::habitica::common::developer_options_name%]",
+ "description": "[%key:component::habitica::common::developer_options_description%]"
+ }
+ }
+ },
+ "create_reward": {
+ "name": "Create reward",
+ "description": "Adds a new custom reward.",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "Select the Habitica account to create a reward."
+ },
+ "name": {
+ "name": "[%key:component::habitica::common::task_name%]",
+ "description": "[%key:component::habitica::common::name_description%]"
+ },
+ "notes": {
+ "name": "[%key:component::habitica::common::notes_name%]",
+ "description": "[%key:component::habitica::common::notes_description%]"
+ },
+ "tag": {
+ "name": "[%key:component::habitica::common::tag_options_name%]",
+ "description": "[%key:component::habitica::common::tag_description%]"
+ },
+ "alias": {
+ "name": "[%key:component::habitica::common::alias_name%]",
+ "description": "[%key:component::habitica::common::alias_description%]"
+ },
+ "cost": {
+ "name": "[%key:component::habitica::common::cost_name%]",
+ "description": "The cost of the reward."
+ }
+ },
+ "sections": {
+ "developer_options": {
+ "name": "[%key:component::habitica::common::developer_options_name%]",
+ "description": "[%key:component::habitica::common::developer_options_description%]"
+ }
+ }
+ },
+ "update_habit": {
+ "name": "Update a habit",
+ "description": "Updates a specific habit for the selected Habitica character",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "Select the Habitica account to update a habit."
+ },
+ "task": {
+ "name": "[%key:component::habitica::common::task_name%]",
+ "description": "[%key:component::habitica::common::task_description%]"
+ },
+ "rename": {
+ "name": "[%key:component::habitica::common::rename_name%]",
+ "description": "[%key:component::habitica::common::rename_description%]"
+ },
+ "notes": {
+ "name": "[%key:component::habitica::common::notes_name%]",
+ "description": "[%key:component::habitica::common::notes_description%]"
+ },
+ "tag": {
+ "name": "[%key:component::habitica::common::tag_name%]",
+ "description": "[%key:component::habitica::common::tag_description%]"
+ },
+ "remove_tag": {
+ "name": "[%key:component::habitica::common::remove_tag_name%]",
+ "description": "[%key:component::habitica::common::remove_tag_description%]"
+ },
+ "alias": {
+ "name": "[%key:component::habitica::common::alias_name%]",
+ "description": "[%key:component::habitica::common::alias_description%]"
+ },
+ "priority": {
+ "name": "[%key:component::habitica::common::priority_name%]",
+ "description": "[%key:component::habitica::common::priority_description%]"
+ },
+ "frequency": {
+ "name": "[%key:component::habitica::common::frequency_name%]",
+ "description": "[%key:component::habitica::common::frequency_description%]"
+ },
+ "up_down": {
+ "name": "[%key:component::habitica::common::up_down_name%]",
+ "description": "[%key:component::habitica::common::up_down_description%]"
+ },
+ "counter_up": {
+ "name": "Adjust positive counter",
+ "description": "Update the up counter of a positive habit."
+ },
+ "counter_down": {
+ "name": "Adjust negative counter",
+ "description": "Update the down counter of a negative habit."
+ }
+ },
+ "sections": {
+ "tag_options": {
+ "name": "[%key:component::habitica::common::tag_options_name%]",
+ "description": "[%key:component::habitica::common::tag_options_description%]"
+ },
+ "developer_options": {
+ "name": "[%key:component::habitica::common::developer_options_name%]",
+ "description": "[%key:component::habitica::common::developer_options_description%]"
+ }
+ }
+ },
+ "create_habit": {
+ "name": "Create habit",
+ "description": "Adds a new habit.",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "Select the Habitica account to create a habit."
+ },
+ "name": {
+ "name": "[%key:component::habitica::common::task_name%]",
+ "description": "[%key:component::habitica::common::name_description%]"
+ },
+ "notes": {
+ "name": "[%key:component::habitica::common::notes_name%]",
+ "description": "[%key:component::habitica::common::notes_description%]"
+ },
+ "tag": {
+ "name": "[%key:component::habitica::common::tag_options_name%]",
+ "description": "[%key:component::habitica::common::tag_description%]"
+ },
+ "alias": {
+ "name": "[%key:component::habitica::common::alias_name%]",
+ "description": "[%key:component::habitica::common::alias_description%]"
+ },
+ "priority": {
+ "name": "[%key:component::habitica::common::priority_name%]",
+ "description": "[%key:component::habitica::common::priority_description%]"
+ },
+ "frequency": {
+ "name": "[%key:component::habitica::common::frequency_name%]",
+ "description": "[%key:component::habitica::common::frequency_description%]"
+ },
+ "up_down": {
+ "name": "[%key:component::habitica::common::up_down_name%]",
+ "description": "[%key:component::habitica::common::up_down_description%]"
+ }
+ },
+ "sections": {
+ "developer_options": {
+ "name": "[%key:component::habitica::common::developer_options_name%]",
+ "description": "[%key:component::habitica::common::developer_options_description%]"
+ }
+ }
+ },
+ "update_todo": {
+ "name": "Update a to-do",
+ "description": "Updates a specific to-do for a selected Habitica character",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "[%key:component::habitica::common::config_entry_description%]"
+ },
+ "task": {
+ "name": "[%key:component::habitica::common::task_name%]",
+ "description": "The name (or task ID) of the to-do you want to update."
+ },
+ "rename": {
+ "name": "[%key:component::habitica::common::rename_name%]",
+ "description": "[%key:component::habitica::common::rename_description%]"
+ },
+ "notes": {
+ "name": "[%key:component::habitica::common::notes_name%]",
+ "description": "[%key:component::habitica::common::notes_description%]"
+ },
+ "tag": {
+ "name": "[%key:component::habitica::common::tag_name%]",
+ "description": "[%key:component::habitica::common::tag_description%]"
+ },
+ "remove_tag": {
+ "name": "[%key:component::habitica::common::remove_tag_name%]",
+ "description": "[%key:component::habitica::common::remove_tag_description%]"
+ },
+ "alias": {
+ "name": "[%key:component::habitica::common::alias_name%]",
+ "description": "[%key:component::habitica::common::alias_description%]"
+ },
+ "priority": {
+ "name": "[%key:component::habitica::common::priority_name%]",
+ "description": "[%key:component::habitica::common::priority_description%]"
+ },
+ "date": {
+ "name": "[%key:component::habitica::common::date_name%]",
+ "description": "[%key:component::habitica::common::date_description%]"
+ },
+ "clear_date": {
+ "name": "Clear due date",
+ "description": "Remove the due date from the to-do."
+ },
+ "reminder": {
+ "name": "[%key:component::habitica::common::reminder_name%]",
+ "description": "[%key:component::habitica::common::reminder_description%]"
+ },
+ "remove_reminder": {
+ "name": "[%key:component::habitica::common::remove_reminder_name%]",
+ "description": "[%key:component::habitica::common::remove_reminder_description%]"
+ },
+ "clear_reminder": {
+ "name": "[%key:component::habitica::common::clear_reminder_name%]",
+ "description": "[%key:component::habitica::common::clear_reminder_description%]"
+ },
+ "add_checklist_item": {
+ "name": "[%key:component::habitica::common::add_checklist_item_name%]",
+ "description": "[%key:component::habitica::common::add_checklist_item_description%]"
+ },
+ "remove_checklist_item": {
+ "name": "[%key:component::habitica::common::remove_checklist_item_name%]",
+ "description": "[%key:component::habitica::common::remove_checklist_item_description%]"
+ },
+ "score_checklist_item": {
+ "name": "[%key:component::habitica::common::score_checklist_item_name%]",
+ "description": "[%key:component::habitica::common::score_checklist_item_description%]"
+ },
+ "unscore_checklist_item": {
+ "name": "[%key:component::habitica::common::unscore_checklist_item_name%]",
+ "description": "[%key:component::habitica::common::unscore_checklist_item_description%]"
+ }
+ },
+ "sections": {
+ "checklist_options": {
+ "name": "[%key:component::habitica::common::checklist_options_name%]",
+ "description": "[%key:component::habitica::common::checklist_options_description%]"
+ },
+ "duedate_options": {
+ "name": "[%key:component::habitica::common::date_name%]",
+ "description": "Set, update or remove due dates of a to-do."
+ },
+ "reminder_options": {
+ "name": "[%key:component::habitica::common::reminder_options_name%]",
+ "description": "[%key:component::habitica::common::reminder_options_description%]"
+ },
+ "tag_options": {
+ "name": "[%key:component::habitica::common::tag_options_name%]",
+ "description": "[%key:component::habitica::common::tag_options_description%]"
+ },
+ "developer_options": {
+ "name": "[%key:component::habitica::common::developer_options_name%]",
+ "description": "[%key:component::habitica::common::developer_options_description%]"
+ }
+ }
+ },
+ "create_todo": {
+ "name": "Create to-do",
+ "description": "Adds a new to-do.",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "Select the Habitica account to create a to-do."
+ },
+ "name": {
+ "name": "[%key:component::habitica::common::task_name%]",
+ "description": "[%key:component::habitica::common::name_description%]"
+ },
+ "notes": {
+ "name": "[%key:component::habitica::common::notes_name%]",
+ "description": "[%key:component::habitica::common::notes_description%]"
+ },
+ "tag": {
+ "name": "[%key:component::habitica::common::tag_options_name%]",
+ "description": "[%key:component::habitica::common::tag_description%]"
+ },
+ "alias": {
+ "name": "[%key:component::habitica::common::alias_name%]",
+ "description": "[%key:component::habitica::common::alias_description%]"
+ },
+ "priority": {
+ "name": "[%key:component::habitica::common::priority_name%]",
+ "description": "[%key:component::habitica::common::priority_description%]"
+ },
+ "date": {
+ "name": "[%key:component::habitica::common::date_name%]",
+ "description": "[%key:component::habitica::common::date_description%]"
+ },
+ "reminder": {
+ "name": "[%key:component::habitica::common::reminder_options_name%]",
+ "description": "[%key:component::habitica::common::reminder_description%]"
+ },
+ "add_checklist_item": {
+ "name": "[%key:component::habitica::common::checklist_options_name%]",
+ "description": "[%key:component::habitica::common::add_checklist_item_description%]"
+ }
+ },
+ "sections": {
+ "developer_options": {
+ "name": "[%key:component::habitica::common::developer_options_name%]",
+ "description": "[%key:component::habitica::common::developer_options_description%]"
+ }
+ }
+ },
+ "update_daily": {
+ "name": "Update a daily",
+ "description": "Updates a specific daily for a selected Habitica character",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "[%key:component::habitica::common::config_entry_description%]"
+ },
+ "task": {
+ "name": "[%key:component::habitica::common::task_name%]",
+ "description": "The name (or task ID) of the daily you want to update."
+ },
+ "rename": {
+ "name": "[%key:component::habitica::common::rename_name%]",
+ "description": "[%key:component::habitica::common::rename_description%]"
+ },
+ "notes": {
+ "name": "[%key:component::habitica::common::notes_name%]",
+ "description": "[%key:component::habitica::common::notes_description%]"
+ },
+ "tag": {
+ "name": "[%key:component::habitica::common::tag_name%]",
+ "description": "[%key:component::habitica::common::tag_description%]"
+ },
+ "remove_tag": {
+ "name": "[%key:component::habitica::common::remove_tag_name%]",
+ "description": "[%key:component::habitica::common::remove_tag_description%]"
+ },
+ "alias": {
+ "name": "[%key:component::habitica::common::alias_name%]",
+ "description": "[%key:component::habitica::common::alias_description%]"
+ },
+ "priority": {
+ "name": "[%key:component::habitica::common::priority_name%]",
+ "description": "[%key:component::habitica::common::priority_description%]"
+ },
+ "start_date": {
+ "name": "[%key:component::habitica::common::start_date_name%]",
+ "description": "[%key:component::habitica::common::start_date_description%]"
+ },
+ "frequency": {
+ "name": "[%key:component::habitica::common::frequency_daily_name%]",
+ "description": "[%key:component::habitica::common::frequency_daily_description%]"
+ },
+ "every_x": {
+ "name": "[%key:component::habitica::common::every_x_name%]",
+ "description": "[%key:component::habitica::common::every_x_description%]"
+ },
+ "repeat": {
+ "name": "[%key:component::habitica::common::repeat_name%]",
+ "description": "[%key:component::habitica::common::repeat_weekly_description%]"
+ },
+ "repeat_monthly": {
+ "name": "[%key:component::habitica::common::repeat_name%]",
+ "description": "[%key:component::habitica::common::repeat_monthly_description%]"
+ },
+ "add_checklist_item": {
+ "name": "[%key:component::habitica::common::add_checklist_item_name%]",
+ "description": "[%key:component::habitica::common::add_checklist_item_description%]"
+ },
+ "remove_checklist_item": {
+ "name": "[%key:component::habitica::common::remove_checklist_item_name%]",
+ "description": "[%key:component::habitica::common::remove_checklist_item_description%]"
+ },
+ "score_checklist_item": {
+ "name": "[%key:component::habitica::common::score_checklist_item_name%]",
+ "description": "[%key:component::habitica::common::score_checklist_item_description%]"
+ },
+ "unscore_checklist_item": {
+ "name": "[%key:component::habitica::common::unscore_checklist_item_name%]",
+ "description": "[%key:component::habitica::common::unscore_checklist_item_description%]"
+ },
+ "streak": {
+ "name": "Adjust streak",
+ "description": "Adjust or reset the streak counter of the daily."
+ },
+ "reminder": {
+ "name": "[%key:component::habitica::common::reminder_name%]",
+ "description": "[%key:component::habitica::common::reminder_description%]"
+ },
+ "remove_reminder": {
+ "name": "[%key:component::habitica::common::remove_reminder_name%]",
+ "description": "[%key:component::habitica::common::remove_reminder_description%]"
+ },
+ "clear_reminder": {
+ "name": "[%key:component::habitica::common::clear_reminder_name%]",
+ "description": "[%key:component::habitica::common::clear_reminder_description%]"
+ }
+ },
+ "sections": {
+ "checklist_options": {
+ "name": "[%key:component::habitica::common::checklist_options_name%]",
+ "description": "[%key:component::habitica::common::checklist_options_description%]"
+ },
+ "repeat_weekly_options": {
+ "name": "[%key:component::habitica::common::repeat_weekly_options_name%]",
+ "description": "[%key:component::habitica::common::repeat_weekly_options_description%]"
+ },
+ "repeat_monthly_options": {
+ "name": "[%key:component::habitica::common::repeat_monthly_options_name%]",
+ "description": "[%key:component::habitica::common::repeat_monthly_options_description%]"
+ },
+ "tag_options": {
+ "name": "[%key:component::habitica::common::tag_options_name%]",
+ "description": "[%key:component::habitica::common::tag_options_description%]"
+ },
+ "developer_options": {
+ "name": "[%key:component::habitica::common::developer_options_name%]",
+ "description": "[%key:component::habitica::common::developer_options_description%]"
+ },
+ "reminder_options": {
+ "name": "[%key:component::habitica::common::reminder_options_name%]",
+ "description": "[%key:component::habitica::common::reminder_options_description%]"
+ }
+ }
+ },
+ "create_daily": {
+ "name": "Create a daily",
+ "description": "Adds a new daily.",
+ "fields": {
+ "config_entry": {
+ "name": "[%key:component::habitica::common::config_entry_name%]",
+ "description": "[%key:component::habitica::common::config_entry_description%]"
+ },
+ "name": {
+ "name": "[%key:component::habitica::common::task_name%]",
+ "description": "[%key:component::habitica::common::name_description%]"
+ },
+ "notes": {
+ "name": "[%key:component::habitica::common::notes_name%]",
+ "description": "[%key:component::habitica::common::notes_description%]"
+ },
+ "tag": {
+ "name": "[%key:component::habitica::common::tag_options_name%]",
+ "description": "[%key:component::habitica::common::tag_description%]"
+ },
+ "alias": {
+ "name": "[%key:component::habitica::common::alias_name%]",
+ "description": "[%key:component::habitica::common::alias_description%]"
+ },
+ "priority": {
+ "name": "[%key:component::habitica::common::priority_name%]",
+ "description": "[%key:component::habitica::common::priority_description%]"
+ },
+ "start_date": {
+ "name": "[%key:component::habitica::common::start_date_name%]",
+ "description": "[%key:component::habitica::common::start_date_description%]"
+ },
+ "frequency": {
+ "name": "[%key:component::habitica::common::frequency_daily_name%]",
+ "description": "[%key:component::habitica::common::frequency_daily_description%]"
+ },
+ "every_x": {
+ "name": "[%key:component::habitica::common::every_x_name%]",
+ "description": "[%key:component::habitica::common::every_x_description%]"
+ },
+ "repeat": {
+ "name": "[%key:component::habitica::common::repeat_name%]",
+ "description": "[%key:component::habitica::common::repeat_weekly_description%]"
+ },
+ "repeat_monthly": {
+ "name": "[%key:component::habitica::common::repeat_name%]",
+ "description": "[%key:component::habitica::common::repeat_monthly_description%]"
+ },
+ "add_checklist_item": {
+ "name": "[%key:component::habitica::common::checklist_options_name%]",
+ "description": "[%key:component::habitica::common::add_checklist_item_description%]"
+ },
+ "reminder": {
+ "name": "[%key:component::habitica::common::reminder_options_name%]",
+ "description": "[%key:component::habitica::common::reminder_description%]"
+ }
+ },
+ "sections": {
+ "repeat_weekly_options": {
+ "name": "[%key:component::habitica::common::repeat_weekly_options_name%]",
+ "description": "[%key:component::habitica::common::repeat_weekly_options_description%]"
+ },
+ "repeat_monthly_options": {
+ "name": "[%key:component::habitica::common::repeat_monthly_options_name%]",
+ "description": "[%key:component::habitica::common::repeat_monthly_options_description%]"
+ },
+ "developer_options": {
+ "name": "[%key:component::habitica::common::developer_options_name%]",
+ "description": "[%key:component::habitica::common::developer_options_description%]"
+ }
+ }
}
},
"selector": {
@@ -671,6 +1272,31 @@
"medium": "Medium",
"hard": "Hard"
}
+ },
+ "frequency": {
+ "options": {
+ "daily": "Daily",
+ "weekly": "Weekly",
+ "monthly": "Monthly",
+ "yearly": "Yearly"
+ }
+ },
+ "repeat": {
+ "options": {
+ "m": "[%key:common::time::monday%]",
+ "t": "[%key:common::time::tuesday%]",
+ "w": "[%key:common::time::wednesday%]",
+ "th": "[%key:common::time::thursday%]",
+ "f": "[%key:common::time::friday%]",
+ "s": "[%key:common::time::saturday%]",
+ "su": "[%key:common::time::sunday%]"
+ }
+ },
+ "repeat_monthly": {
+ "options": {
+ "day_of_month": "Day of the month",
+ "day_of_week": "Day of the week"
+ }
}
}
}
diff --git a/homeassistant/components/habitica/switch.py b/homeassistant/components/habitica/switch.py
index fdad85ce3dc..fb98460f7e5 100644
--- a/homeassistant/components/habitica/switch.py
+++ b/homeassistant/components/habitica/switch.py
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
HabiticaConfigEntry,
@@ -55,7 +55,7 @@ SWTICH_DESCRIPTIONS: tuple[HabiticaSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: HabiticaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches from a config entry."""
diff --git a/homeassistant/components/habitica/todo.py b/homeassistant/components/habitica/todo.py
index c46cf92c724..71ba8e60e06 100644
--- a/homeassistant/components/habitica/todo.py
+++ b/homeassistant/components/habitica/todo.py
@@ -26,7 +26,7 @@ from homeassistant.components.todo import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import ASSETS_URL, DOMAIN
@@ -51,7 +51,7 @@ class HabiticaTodoList(StrEnum):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HabiticaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor from a config entry created in the integrations UI."""
coordinator = config_entry.runtime_data
@@ -117,19 +117,24 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
"""Move an item in the To-do list."""
if TYPE_CHECKING:
assert self.todo_items
+ tasks_order = (
+ self.coordinator.data.user.tasksOrder.todos
+ if self.entity_description.key is HabiticaTodoList.TODOS
+ else self.coordinator.data.user.tasksOrder.dailys
+ )
if previous_uid:
- pos = (
- self.todo_items.index(
- next(item for item in self.todo_items if item.uid == previous_uid)
- )
- + 1
- )
+ pos = tasks_order.index(UUID(previous_uid))
+ if pos < tasks_order.index(UUID(uid)):
+ pos += 1
+
else:
pos = 0
try:
- await self.coordinator.habitica.reorder_task(UUID(uid), pos)
+ tasks_order[:] = (
+ await self.coordinator.habitica.reorder_task(UUID(uid), pos)
+ ).data
except TooManyRequestsError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -143,20 +148,6 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
translation_key=f"move_{self.entity_description.key}_item_failed",
translation_placeholders={"pos": str(pos)},
) from e
- else:
- # move tasks in the coordinator until we have fresh data
- tasks = self.coordinator.data.tasks
- new_pos = (
- tasks.index(
- next(task for task in tasks if task.id == UUID(previous_uid))
- )
- + 1
- if previous_uid
- else 0
- )
- old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid)))
- tasks.insert(new_pos, tasks.pop(old_pos))
- await self.coordinator.async_request_refresh()
async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update a Habitica todo."""
@@ -270,7 +261,7 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
def todo_items(self) -> list[TodoItem]:
"""Return the todo items."""
- return [
+ tasks = [
*(
TodoItem(
uid=str(task.id),
@@ -287,6 +278,15 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
if task.Type is TaskType.TODO
),
]
+ return sorted(
+ tasks,
+ key=lambda task: (
+ float("inf")
+ if (uid := UUID(task.uid))
+ not in (tasks_order := self.coordinator.data.user.tasksOrder.todos)
+ else tasks_order.index(uid)
+ ),
+ )
async def async_create_todo_item(self, item: TodoItem) -> None:
"""Create a Habitica todo."""
@@ -347,7 +347,7 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
if TYPE_CHECKING:
assert self.coordinator.data.user.lastCron
- return [
+ tasks = [
*(
TodoItem(
uid=str(task.id),
@@ -364,3 +364,12 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
if task.Type is TaskType.DAILY
)
]
+ return sorted(
+ tasks,
+ key=lambda task: (
+ float("inf")
+ if (uid := UUID(task.uid))
+ not in (tasks_order := self.coordinator.data.user.tasksOrder.dailys)
+ else tasks_order.index(uid)
+ ),
+ )
diff --git a/homeassistant/components/habitica/util.py b/homeassistant/components/habitica/util.py
index 757c675b045..1ca908eb3ff 100644
--- a/homeassistant/components/habitica/util.py
+++ b/homeassistant/components/habitica/util.py
@@ -74,7 +74,7 @@ def build_rrule(task: TaskData) -> rrule:
bysetpos = None
if rrule_frequency == MONTHLY and task.weeksOfMonth:
- bysetpos = task.weeksOfMonth
+ bysetpos = [i + 1 for i in task.weeksOfMonth]
weekdays = weekdays if weekdays else [MO]
return rrule(
diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json
index aab4f51b09a..f67eb4db5aa 100644
--- a/homeassistant/components/harmony/manifest.json
+++ b/homeassistant/components/harmony/manifest.json
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/harmony",
"iot_class": "local_push",
"loggers": ["aioharmony", "slixmpp"],
- "requirements": ["aioharmony==0.4.1"],
+ "requirements": ["aioharmony==0.5.2"],
"ssdp": [
{
"manufacturer": "Logitech",
diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py
index 43bf0a348c0..d09dc3ff7e8 100644
--- a/homeassistant/components/harmony/remote.py
+++ b/homeassistant/components/harmony/remote.py
@@ -22,7 +22,7 @@ from homeassistant.components.remote import (
from homeassistant.core import HassJob, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import VolDictType
@@ -56,7 +56,7 @@ HARMONY_CHANGE_CHANNEL_SCHEMA: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
entry: HarmonyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Harmony config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/harmony/select.py b/homeassistant/components/harmony/select.py
index 731b6836386..3f45a23e26e 100644
--- a/homeassistant/components/harmony/select.py
+++ b/homeassistant/components/harmony/select.py
@@ -6,7 +6,7 @@ import logging
from homeassistant.components.select import SelectEntity
from homeassistant.core import HassJob, HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ACTIVITY_POWER_OFF, DOMAIN
from .data import HarmonyConfigEntry, HarmonyData
@@ -21,7 +21,7 @@ TRANSLATABLE_POWER_OFF = "power_off"
async def async_setup_entry(
hass: HomeAssistant,
entry: HarmonyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up harmony activities select."""
async_add_entities([HarmonyActivitySelect(entry.runtime_data)])
diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py
index d71b2b85f7b..f160c69bae7 100644
--- a/homeassistant/components/hassio/__init__.py
+++ b/homeassistant/components/hassio/__init__.py
@@ -55,7 +55,6 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
from homeassistant.helpers.service_info.hassio import (
HassioServiceInfo as _HassioServiceInfo,
)
-from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.async_ import create_eager_task
@@ -78,6 +77,7 @@ from . import ( # noqa: F401
from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # noqa: F401
from .addon_panel import async_setup_addon_panel
from .auth import async_setup_auth_view
+from .config import HassioConfig
from .const import (
ADDONS_COORDINATOR,
ATTR_ADDON,
@@ -91,6 +91,7 @@ from .const import (
ATTR_PASSWORD,
ATTR_SLUG,
DATA_COMPONENT,
+ DATA_CONFIG_STORE,
DATA_CORE_INFO,
DATA_HOST_INFO,
DATA_INFO,
@@ -144,8 +145,6 @@ _DEPRECATED_HassioServiceInfo = DeprecatedConstant(
"2025.11",
)
-STORAGE_KEY = DOMAIN
-STORAGE_VERSION = 1
# If new platforms are added, be sure to import them above
# so we do not make other components that depend on hassio
# wait for the import of the platforms
@@ -335,13 +334,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
except SupervisorError:
_LOGGER.warning("Not connected with the supervisor / system too busy!")
- store = Store[dict[str, str]](hass, STORAGE_VERSION, STORAGE_KEY)
- if (data := await store.async_load()) is None:
- data = {}
+ # Load the store
+ config_store = HassioConfig(hass)
+ await config_store.load()
+ hass.data[DATA_CONFIG_STORE] = config_store
refresh_token = None
- if "hassio_user" in data:
- user = await hass.auth.async_get_user(data["hassio_user"])
+ if (hassio_user := config_store.data.hassio_user) is not None:
+ user = await hass.auth.async_get_user(hassio_user)
if user and user.refresh_tokens:
refresh_token = list(user.refresh_tokens.values())[0]
@@ -358,8 +358,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN]
)
refresh_token = await hass.auth.async_create_refresh_token(user)
- data["hassio_user"] = user.id
- await store.async_save(data)
+ config_store.update(hassio_user=user.id)
# This overrides the normal API call that would be forwarded
development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO)
diff --git a/homeassistant/components/hassio/backup.py b/homeassistant/components/hassio/backup.py
index ddaa821587f..38bf3c82561 100644
--- a/homeassistant/components/hassio/backup.py
+++ b/homeassistant/components/hassio/backup.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
+from contextlib import suppress
import logging
import os
from pathlib import Path, PurePath
@@ -27,11 +28,13 @@ from homeassistant.components.backup import (
AddonInfo,
AgentBackup,
BackupAgent,
+ BackupConfig,
BackupManagerError,
BackupNotFound,
BackupReaderWriter,
BackupReaderWriterError,
CreateBackupEvent,
+ CreateBackupParametersDict,
CreateBackupStage,
CreateBackupState,
Folder,
@@ -43,18 +46,18 @@ from homeassistant.components.backup import (
RestoreBackupStage,
RestoreBackupState,
WrittenBackup,
- async_get_manager as async_get_backup_manager,
suggested_filename as suggested_backup_filename,
suggested_filename_from_name_date,
)
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
-from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
+from .const import DATA_CONFIG_STORE, DOMAIN, EVENT_SUPERVISOR_EVENT
from .handler import get_supervisor_client
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
@@ -171,7 +174,7 @@ class SupervisorBackupAgent(BackupAgent):
),
)
except SupervisorNotFoundError as err:
- raise BackupNotFound from err
+ raise BackupNotFound(f"Backup {backup_id} not found") from err
async def async_upload_backup(
self,
@@ -184,13 +187,14 @@ class SupervisorBackupAgent(BackupAgent):
The upload will be skipped if the backup already exists in the agent's location.
"""
- if await self.async_get_backup(backup.backup_id):
- _LOGGER.debug(
- "Backup %s already exists in location %s",
- backup.backup_id,
- self.location,
- )
- return
+ with suppress(BackupNotFound):
+ if await self.async_get_backup(backup.backup_id):
+ _LOGGER.debug(
+ "Backup %s already exists in location %s",
+ backup.backup_id,
+ self.location,
+ )
+ return
stream = await open_stream()
upload_options = supervisor_backups.UploadBackupOptions(
location={self.location},
@@ -216,14 +220,14 @@ class SupervisorBackupAgent(BackupAgent):
self,
backup_id: str,
**kwargs: Any,
- ) -> AgentBackup | None:
+ ) -> AgentBackup:
"""Return a backup."""
try:
details = await self._client.backups.backup_info(backup_id)
- except SupervisorNotFoundError:
- return None
+ except SupervisorNotFoundError as err:
+ raise BackupNotFound(f"Backup {backup_id} not found") from err
if self.location not in details.location_attributes:
- return None
+ raise BackupNotFound(f"Backup {backup_id} not found")
return _backup_details_to_agent_backup(details, self.location)
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
@@ -235,8 +239,8 @@ class SupervisorBackupAgent(BackupAgent):
location={self.location}
),
)
- except SupervisorNotFoundError:
- _LOGGER.debug("Backup %s does not exist", backup_id)
+ except SupervisorNotFoundError as err:
+ raise BackupNotFound(f"Backup {backup_id} not found") from err
class SupervisorBackupReaderWriter(BackupReaderWriter):
@@ -490,10 +494,12 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
) -> None:
"""Restore a backup."""
manager = self._hass.data[DATA_MANAGER]
- # The backup manager has already checked that the backup exists so we don't need to
- # check that here.
+ # The backup manager has already checked that the backup exists so we don't
+ # need to catch BackupNotFound here.
backup = await manager.backup_agents[agent_id].async_get_backup(backup_id)
if (
+ # Check for None to be backwards compatible with the old BackupAgent API,
+ # this can be removed in HA Core 2025.10
backup
and restore_homeassistant
and restore_database != backup.database_included
@@ -633,6 +639,27 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
_LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err)
unsub()
+ async def async_validate_config(self, *, config: BackupConfig) -> None:
+ """Validate backup config.
+
+ Replace the core backup agent with the hassio default agent.
+ """
+ core_agent_id = "backup.local"
+ create_backup = config.data.create_backup
+ if core_agent_id not in create_backup.agent_ids:
+ _LOGGER.debug("Backup settings don't need to be adjusted")
+ return
+
+ default_agent = await _default_agent(self._client)
+ _LOGGER.info("Adjusting backup settings to not include core backup location")
+ automatic_agents = [
+ agent_id if agent_id != core_agent_id else default_agent
+ for agent_id in create_backup.agent_ids
+ ]
+ config.update(
+ create_backup=CreateBackupParametersDict(agent_ids=automatic_agents)
+ )
+
@callback
def _async_listen_job_events(
self, job_id: UUID, on_event: Callable[[Mapping[str, Any]], None]
@@ -702,6 +729,18 @@ async def backup_addon_before_update(
if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon
}
+ def _delete_filter(
+ backups: dict[str, ManagerBackup],
+ ) -> dict[str, ManagerBackup]:
+ """Return oldest backups more numerous than copies to delete."""
+ update_config = hass.data[DATA_CONFIG_STORE].data.update_config
+ return dict(
+ sorted(
+ backups.items(),
+ key=lambda backup_item: backup_item[1].date,
+ )[: max(len(backups) - update_config.add_on_backup_retain_copies, 0)]
+ )
+
try:
await backup_manager.async_create_backup(
agent_ids=[await _default_agent(client)],
@@ -720,7 +759,7 @@ async def backup_addon_before_update(
try:
await backup_manager.async_delete_filtered_backups(
include_filter=addon_update_backup_filter,
- delete_filter=lambda backups: backups,
+ delete_filter=_delete_filter,
)
except BackupManagerError as err:
raise HomeAssistantError(f"Error deleting old backups: {err}") from err
@@ -728,7 +767,7 @@ async def backup_addon_before_update(
async def backup_core_before_update(hass: HomeAssistant) -> None:
"""Prepare for updating core."""
- backup_manager = async_get_backup_manager(hass)
+ backup_manager = await async_get_backup_manager(hass)
client = get_supervisor_client(hass)
try:
diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py
index 9d6e2ba19da..e7c7427d728 100644
--- a/homeassistant/components/hassio/binary_sensor.py
+++ b/homeassistant/components/hassio/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS
from .entity import HassioAddonEntity
@@ -38,7 +38,7 @@ ADDON_ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Binary sensor set up for Hass.io config entry."""
coordinator = hass.data[ADDONS_COORDINATOR]
diff --git a/homeassistant/components/hassio/config.py b/homeassistant/components/hassio/config.py
new file mode 100644
index 00000000000..f277249ee94
--- /dev/null
+++ b/homeassistant/components/hassio/config.py
@@ -0,0 +1,148 @@
+"""Provide persistent configuration for the hassio integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+from typing import Required, Self, TypedDict
+
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.storage import Store
+from homeassistant.helpers.typing import UNDEFINED, UndefinedType
+
+from .const import DOMAIN
+
+STORE_DELAY_SAVE = 30
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+STORAGE_VERSION_MINOR = 1
+
+
+class HassioConfig:
+ """Handle update config."""
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize update config."""
+ self.data = HassioConfigData(
+ hassio_user=None,
+ update_config=HassioUpdateConfig(),
+ )
+ self._hass = hass
+ self._store = HassioConfigStore(hass, self)
+
+ async def load(self) -> None:
+ """Load config."""
+ if not (store_data := await self._store.load()):
+ return
+ self.data = HassioConfigData.from_dict(store_data)
+
+ @callback
+ def update(
+ self,
+ *,
+ hassio_user: str | UndefinedType = UNDEFINED,
+ update_config: HassioUpdateParametersDict | UndefinedType = UNDEFINED,
+ ) -> None:
+ """Update config."""
+ if hassio_user is not UNDEFINED:
+ self.data.hassio_user = hassio_user
+ if update_config is not UNDEFINED:
+ self.data.update_config = replace(self.data.update_config, **update_config)
+
+ self._store.save()
+
+
+@dataclass(kw_only=True)
+class HassioConfigData:
+ """Represent loaded update config data."""
+
+ hassio_user: str | None
+ update_config: HassioUpdateConfig
+
+ @classmethod
+ def from_dict(cls, data: StoredHassioConfig) -> Self:
+ """Initialize update config data from a dict."""
+ if update_data := data.get("update_config"):
+ update_config = HassioUpdateConfig(
+ add_on_backup_before_update=update_data["add_on_backup_before_update"],
+ add_on_backup_retain_copies=update_data["add_on_backup_retain_copies"],
+ core_backup_before_update=update_data["core_backup_before_update"],
+ )
+ else:
+ update_config = HassioUpdateConfig()
+ return cls(
+ hassio_user=data["hassio_user"],
+ update_config=update_config,
+ )
+
+ def to_dict(self) -> StoredHassioConfig:
+ """Convert update config data to a dict."""
+ return StoredHassioConfig(
+ hassio_user=self.hassio_user,
+ update_config=self.update_config.to_dict(),
+ )
+
+
+@dataclass(kw_only=True)
+class HassioUpdateConfig:
+ """Represent the backup retention configuration."""
+
+ add_on_backup_before_update: bool = False
+ add_on_backup_retain_copies: int = 1
+ core_backup_before_update: bool = False
+
+ def to_dict(self) -> StoredHassioUpdateConfig:
+ """Convert backup retention configuration to a dict."""
+ return StoredHassioUpdateConfig(
+ add_on_backup_before_update=self.add_on_backup_before_update,
+ add_on_backup_retain_copies=self.add_on_backup_retain_copies,
+ core_backup_before_update=self.core_backup_before_update,
+ )
+
+
+class HassioUpdateParametersDict(TypedDict, total=False):
+ """Represent the parameters for update."""
+
+ add_on_backup_before_update: bool
+ add_on_backup_retain_copies: int
+ core_backup_before_update: bool
+
+
+class HassioConfigStore:
+ """Store hassio config."""
+
+ def __init__(self, hass: HomeAssistant, config: HassioConfig) -> None:
+ """Initialize the hassio config store."""
+ self._hass = hass
+ self._config = config
+ self._store: Store[StoredHassioConfig] = Store(
+ hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
+ )
+
+ async def load(self) -> StoredHassioConfig | None:
+ """Load the store."""
+ return await self._store.async_load()
+
+ @callback
+ def save(self) -> None:
+ """Save config."""
+ self._store.async_delay_save(self._data_to_save, STORE_DELAY_SAVE)
+
+ @callback
+ def _data_to_save(self) -> StoredHassioConfig:
+ """Return data to save."""
+ return self._config.data.to_dict()
+
+
+class StoredHassioConfig(TypedDict, total=False):
+ """Represent the stored hassio config."""
+
+ hassio_user: Required[str | None]
+ update_config: StoredHassioUpdateConfig
+
+
+class StoredHassioUpdateConfig(TypedDict):
+ """Represent the stored update config."""
+
+ add_on_backup_before_update: bool
+ add_on_backup_retain_copies: int
+ core_backup_before_update: bool
diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py
index d1cda51ec7b..562669f674a 100644
--- a/homeassistant/components/hassio/const.py
+++ b/homeassistant/components/hassio/const.py
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
+ from .config import HassioConfig
from .handler import HassIO
@@ -74,6 +75,7 @@ ADDONS_COORDINATOR = "hassio_addons_coordinator"
DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN)
+DATA_CONFIG_STORE: HassKey[HassioConfig] = HassKey("hassio_config_store")
DATA_CORE_INFO = "hassio_core_info"
DATA_CORE_STATS = "hassio_core_stats"
DATA_HOST_INFO = "hassio_host_info"
diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py
index 3a3eb0e945c..a2f5a43b69c 100644
--- a/homeassistant/components/hassio/ingress.py
+++ b/homeassistant/components/hassio/ingress.py
@@ -46,6 +46,8 @@ RESPONSE_HEADERS_FILTER = {
MIN_COMPRESSED_SIZE = 128
MAX_SIMPLE_RESPONSE_SIZE = 4194000
+DISABLED_TIMEOUT = ClientTimeout(total=None)
+
@callback
def async_setup_ingress_view(hass: HomeAssistant, host: str) -> None:
@@ -167,7 +169,7 @@ class HassIOIngress(HomeAssistantView):
params=request.query,
allow_redirects=False,
data=request.content if request.method != "GET" else None,
- timeout=ClientTimeout(total=None),
+ timeout=DISABLED_TIMEOUT,
skip_auto_headers={hdrs.CONTENT_TYPE},
) as result:
headers = _response_header(result)
diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json
index ad98beb5baa..f267f8ce722 100644
--- a/homeassistant/components/hassio/manifest.json
+++ b/homeassistant/components/hassio/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling",
"quality_scale": "internal",
- "requirements": ["aiohasupervisor==0.3.0"],
+ "requirements": ["aiohasupervisor==0.3.1b1"],
"single_config_entry": true
}
diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py
index 039bf483682..9b62faaabcf 100644
--- a/homeassistant/components/hassio/sensor.py
+++ b/homeassistant/components/hassio/sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ADDONS_COORDINATOR,
@@ -111,7 +111,7 @@ HOST_ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Sensor set up for Hass.io config entry."""
coordinator = hass.data[ADDONS_COORDINATOR]
diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json
index 799067b8215..68a747eb16d 100644
--- a/homeassistant/components/hassio/strings.json
+++ b/homeassistant/components/hassio/strings.json
@@ -24,8 +24,8 @@
"fix_menu": {
"description": "Add-on {addon} is set to start at boot but failed to start. Usually this occurs when the configuration is incorrect or the same port is used in multiple add-ons. Check the configuration as well as logs for {addon} and Supervisor.\n\nUse Start to try again or Disable to turn off the start at boot option.",
"menu_options": {
- "addon_execute_start": "Start",
- "addon_disable_boot": "Disable"
+ "addon_execute_start": "[%key:common::action::start%]",
+ "addon_disable_boot": "[%key:common::action::disable%]"
}
}
},
@@ -152,7 +152,7 @@
},
"unsupported_connectivity_check": {
"title": "Unsupported system - Connectivity check disabled",
- "description": "System is unsupported because Home Assistant cannot determine when an internet connection is available. Use the link to learn more and how to fix this."
+ "description": "System is unsupported because Home Assistant cannot determine when an Internet connection is available. Use the link to learn more and how to fix this."
},
"unsupported_content_trust": {
"title": "Unsupported system - Content-trust check disabled",
@@ -216,7 +216,7 @@
},
"unsupported_systemd_journal": {
"title": "Unsupported system - Systemd Journal issues",
- "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured . Use the link to learn more and how to fix this."
+ "description": "System is unsupported because Systemd Journal and/or the gateway service is missing, inactive or misconfigured. Use the link to learn more and how to fix this."
},
"unsupported_systemd_resolved": {
"title": "Unsupported system - Systemd-Resolved issues",
@@ -265,6 +265,11 @@
"version_latest": {
"name": "Newest version"
}
+ },
+ "update": {
+ "update": {
+ "name": "[%key:component::update::title%]"
+ }
}
},
"services": {
@@ -348,7 +353,7 @@
},
"homeassistant_exclude_database": {
"name": "Home Assistant exclude database",
- "description": "Exclude the Home Assistant database file from backup"
+ "description": "Exclude the Home Assistant database file from the backup."
}
}
},
@@ -385,8 +390,8 @@
"description": "[%key:component::hassio::services::backup_full::fields::location::description%]"
},
"homeassistant_exclude_database": {
- "name": "Home Assistant exclude database",
- "description": "Exclude the Home Assistant database file from backup"
+ "name": "[%key:component::hassio::services::backup_full::fields::homeassistant_exclude_database::name%]",
+ "description": "[%key:component::hassio::services::backup_full::fields::homeassistant_exclude_database::description%]"
}
}
},
diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py
index 8e0585892f5..2c325979210 100644
--- a/homeassistant/components/hassio/update.py
+++ b/homeassistant/components/hassio/update.py
@@ -5,7 +5,6 @@ from __future__ import annotations
from typing import Any
from aiohasupervisor import SupervisorError
-from aiohasupervisor.models import OSUpdate
from awesomeversion import AwesomeVersion, AwesomeVersionStrategy
from homeassistant.components.update import (
@@ -17,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON, ATTR_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ADDONS_COORDINATOR,
@@ -36,10 +35,10 @@ from .entity import (
HassioOSEntity,
HassioSupervisorEntity,
)
-from .update_helper import update_addon, update_core
+from .update_helper import update_addon, update_core, update_os
ENTITY_DESCRIPTION = UpdateEntityDescription(
- name="Update",
+ translation_key="update",
key=ATTR_VERSION_LATEST,
)
@@ -47,7 +46,7 @@ ENTITY_DESCRIPTION = UpdateEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Supervisor update based on a config entry."""
coordinator = hass.data[ADDONS_COORDINATOR]
@@ -170,7 +169,9 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
"""Update entity to handle updates for the Home Assistant Operating System."""
_attr_supported_features = (
- UpdateEntityFeature.INSTALL | UpdateEntityFeature.SPECIFIC_VERSION
+ UpdateEntityFeature.INSTALL
+ | UpdateEntityFeature.SPECIFIC_VERSION
+ | UpdateEntityFeature.BACKUP
)
_attr_title = "Home Assistant Operating System"
@@ -203,14 +204,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
- try:
- await self.coordinator.supervisor_client.os.update(
- OSUpdate(version=version)
- )
- except SupervisorError as err:
- raise HomeAssistantError(
- f"Error updating Home Assistant Operating System: {err}"
- ) from err
+ await update_os(self.hass, version, backup)
class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
diff --git a/homeassistant/components/hassio/update_helper.py b/homeassistant/components/hassio/update_helper.py
index d801f6b5771..65a3ba38485 100644
--- a/homeassistant/components/hassio/update_helper.py
+++ b/homeassistant/components/hassio/update_helper.py
@@ -3,7 +3,11 @@
from __future__ import annotations
from aiohasupervisor import SupervisorError
-from aiohasupervisor.models import HomeAssistantUpdateOptions, StoreAddonUpdate
+from aiohasupervisor.models import (
+ HomeAssistantUpdateOptions,
+ OSUpdate,
+ StoreAddonUpdate,
+)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
@@ -57,3 +61,24 @@ async def update_core(hass: HomeAssistant, version: str | None, backup: bool) ->
)
except SupervisorError as err:
raise HomeAssistantError(f"Error updating Home Assistant Core: {err}") from err
+
+
+async def update_os(hass: HomeAssistant, version: str | None, backup: bool) -> None:
+ """Update OS.
+
+ Optionally make a core backup before updating.
+ """
+ client = get_supervisor_client(hass)
+
+ if backup:
+ # pylint: disable-next=import-outside-toplevel
+ from .backup import backup_core_before_update
+
+ await backup_core_before_update(hass)
+
+ try:
+ await client.os.update(OSUpdate(version=version))
+ except SupervisorError as err:
+ raise HomeAssistantError(
+ f"Error updating Home Assistant Operating System: {err}"
+ ) from err
diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py
index c046e20feab..81f7ab9d0da 100644
--- a/homeassistant/components/hassio/websocket_api.py
+++ b/homeassistant/components/hassio/websocket_api.py
@@ -3,7 +3,7 @@
import logging
from numbers import Number
import re
-from typing import Any
+from typing import Any, cast
import voluptuous as vol
@@ -19,6 +19,7 @@ from homeassistant.helpers.dispatcher import (
)
from . import HassioAPIError
+from .config import HassioUpdateParametersDict
from .const import (
ATTR_DATA,
ATTR_ENDPOINT,
@@ -29,6 +30,7 @@ from .const import (
ATTR_VERSION,
ATTR_WS_EVENT,
DATA_COMPONENT,
+ DATA_CONFIG_STORE,
EVENT_SUPERVISOR_EVENT,
WS_ID,
WS_TYPE,
@@ -65,6 +67,8 @@ def async_load_websocket_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_subscribe)
websocket_api.async_register_command(hass, websocket_update_addon)
websocket_api.async_register_command(hass, websocket_update_core)
+ websocket_api.async_register_command(hass, websocket_update_config_info)
+ websocket_api.async_register_command(hass, websocket_update_config_update)
@callback
@@ -182,6 +186,45 @@ async def websocket_update_addon(
async def websocket_update_core(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
- """Websocket handler to update an addon."""
+ """Websocket handler to update Home Assistant Core."""
await update_core(hass, None, msg["backup"])
connection.send_result(msg[WS_ID])
+
+
+@callback
+@websocket_api.require_admin
+@websocket_api.websocket_command({vol.Required("type"): "hassio/update/config/info"})
+def websocket_update_config_info(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Send the stored backup config."""
+ connection.send_result(
+ msg["id"], hass.data[DATA_CONFIG_STORE].data.update_config.to_dict()
+ )
+
+
+@callback
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "hassio/update/config/update",
+ vol.Optional("add_on_backup_before_update"): bool,
+ vol.Optional("add_on_backup_retain_copies"): vol.All(int, vol.Range(min=1)),
+ vol.Optional("core_backup_before_update"): bool,
+ }
+)
+def websocket_update_config_update(
+ hass: HomeAssistant,
+ connection: websocket_api.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Update the stored backup config."""
+ changes = dict(msg)
+ changes.pop("id")
+ changes.pop("type")
+ hass.data[DATA_CONFIG_STORE].update(
+ update_config=cast(HassioUpdateParametersDict, changes)
+ )
+ connection.send_result(msg["id"])
diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py
index 7bbd3765602..4df1a2fa0e1 100644
--- a/homeassistant/components/heos/__init__.py
+++ b/homeassistant/components/heos/__init__.py
@@ -69,3 +69,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool
async def async_unload_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_remove_config_entry_device(
+ hass: HomeAssistant, entry: HeosConfigEntry, device: dr.DeviceEntry
+) -> bool:
+ """Remove config entry from device if no longer present."""
+ return not any(
+ (domain, key)
+ for domain, key in device.identifiers
+ if domain == DOMAIN and int(key) in entry.runtime_data.heos.players
+ )
diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py
index db2abee559c..e2d3e2522dc 100644
--- a/homeassistant/components/heos/config_flow.py
+++ b/homeassistant/components/heos/config_flow.py
@@ -5,11 +5,17 @@ import logging
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
-from pyheos import CommandAuthenticationError, Heos, HeosError, HeosOptions
+from pyheos import (
+ CommandAuthenticationError,
+ ConnectionState,
+ Heos,
+ HeosError,
+ HeosOptions,
+)
import voluptuous as vol
from homeassistant.config_entries import (
- ConfigEntryState,
+ SOURCE_IGNORE,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
@@ -17,12 +23,9 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import selector
-from homeassistant.helpers.service_info.ssdp import (
- ATTR_UPNP_FRIENDLY_NAME,
- SsdpServiceInfo,
-)
+from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
-from .const import DOMAIN
+from .const import DOMAIN, ENTRY_TITLE
from .coordinator import HeosConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -37,11 +40,6 @@ AUTH_SCHEMA = vol.Schema(
)
-def format_title(host: str) -> str:
- """Format the title for config entries."""
- return f"HEOS System (via {host})"
-
-
async def _validate_host(host: str, errors: dict[str, str]) -> bool:
"""Validate host is reachable, return True, otherwise populate errors and return False."""
heos = Heos(HeosOptions(host, events=False, heart_beat=False))
@@ -56,13 +54,19 @@ async def _validate_host(host: str, errors: dict[str, str]) -> bool:
async def _validate_auth(
- user_input: dict[str, str], heos: Heos, errors: dict[str, str]
+ user_input: dict[str, str], entry: HeosConfigEntry, errors: dict[str, str]
) -> bool:
"""Validate authentication by signing in or out, otherwise populate errors if needed."""
+ can_validate = (
+ hasattr(entry, "runtime_data")
+ and entry.runtime_data.heos.connection_state is ConnectionState.CONNECTED
+ )
if not user_input:
# Log out (neither username nor password provided)
+ if not can_validate:
+ return True
try:
- await heos.sign_out()
+ await entry.runtime_data.heos.sign_out()
except HeosError:
errors["base"] = "unknown"
_LOGGER.exception("Unexpected error occurred during sign-out")
@@ -81,8 +85,12 @@ async def _validate_auth(
return False
# Attempt to login (both username and password provided)
+ if not can_validate:
+ return True
try:
- await heos.sign_in(user_input[CONF_USERNAME], user_input[CONF_PASSWORD])
+ await entry.runtime_data.heos.sign_in(
+ user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
+ )
except CommandAuthenticationError as err:
errors["base"] = "invalid_auth"
_LOGGER.warning("Failed to sign-in to HEOS Account: %s", err)
@@ -94,16 +102,32 @@ async def _validate_auth(
else:
_LOGGER.debug(
"Successfully signed-in to HEOS Account: %s",
- heos.signed_in_username,
+ entry.runtime_data.heos.signed_in_username,
)
return True
+def _get_current_hosts(entry: HeosConfigEntry) -> set[str]:
+ """Get a set of current hosts from the entry."""
+ hosts = set(entry.data[CONF_HOST])
+ if hasattr(entry, "runtime_data"):
+ hosts.update(
+ player.ip_address
+ for player in entry.runtime_data.heos.players.values()
+ if player.ip_address is not None
+ )
+ return hosts
+
+
class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
"""Define a flow for HEOS."""
VERSION = 1
+ def __init__(self) -> None:
+ """Initialize the HEOS flow."""
+ self._discovered_host: str | None = None
+
@staticmethod
@callback
def async_get_options_flow(config_entry: HeosConfigEntry) -> OptionsFlow:
@@ -117,40 +141,86 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
# Store discovered host
if TYPE_CHECKING:
assert discovery_info.ssdp_location
+
+ entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN)
hostname = urlparse(discovery_info.ssdp_location).hostname
- friendly_name = f"{discovery_info.upnp[ATTR_UPNP_FRIENDLY_NAME]} ({hostname})"
- self.hass.data.setdefault(DOMAIN, {})
- self.hass.data[DOMAIN][friendly_name] = hostname
- await self.async_set_unique_id(DOMAIN)
- # Show selection form
- return self.async_show_form(step_id="user")
+ assert hostname is not None
+
+ # Abort early when discovery is ignored or host is part of the current system
+ if entry and (
+ entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry)
+ ):
+ return self.async_abort(reason="single_instance_allowed")
+
+ # Connect to discovered host and get system information
+ heos = Heos(HeosOptions(hostname, events=False, heart_beat=False))
+ try:
+ await heos.connect()
+ system_info = await heos.get_system_info()
+ except HeosError as error:
+ _LOGGER.debug(
+ "Failed to retrieve system information from discovered HEOS device %s",
+ hostname,
+ exc_info=error,
+ )
+ return self.async_abort(reason="cannot_connect")
+ finally:
+ await heos.disconnect()
+
+ # Select the preferred host, if available
+ if system_info.preferred_hosts:
+ hostname = system_info.preferred_hosts[0].ip_address
+
+ # Move to confirmation when not configured
+ if entry is None:
+ self._discovered_host = hostname
+ return await self.async_step_confirm_discovery()
+
+ # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload
+ if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]:
+ _LOGGER.debug(
+ "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname
+ )
+ return self.async_update_reload_and_abort(
+ entry,
+ data_updates={CONF_HOST: hostname},
+ reason="reconfigure_successful",
+ )
+ return self.async_abort(reason="single_instance_allowed")
+
+ async def async_step_confirm_discovery(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm discovered HEOS system."""
+ if user_input is not None:
+ assert self._discovered_host is not None
+ return self.async_create_entry(
+ title=ENTRY_TITLE, data={CONF_HOST: self._discovered_host}
+ )
+
+ self._set_confirm_only()
+ return self.async_show_form(step_id="confirm_discovery")
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Obtain host and validate connection."""
- self.hass.data.setdefault(DOMAIN, {})
- await self.async_set_unique_id(DOMAIN)
+ await self.async_set_unique_id(DOMAIN, raise_on_progress=False)
+ self._abort_if_unique_id_configured(error="single_instance_allowed")
# Try connecting to host if provided
errors: dict[str, str] = {}
host = None
if user_input is not None:
host = user_input[CONF_HOST]
- # Map host from friendly name if in discovered hosts
- host = self.hass.data[DOMAIN].get(host, host)
if await _validate_host(host, errors):
- self.hass.data.pop(DOMAIN) # Remove discovery data
return self.async_create_entry(
- title=format_title(host), data={CONF_HOST: host}
+ title=ENTRY_TITLE, data={CONF_HOST: host}
)
# Return form
- host_type = (
- str if not self.hass.data[DOMAIN] else vol.In(list(self.hass.data[DOMAIN]))
- )
return self.async_show_form(
step_id="user",
- data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): host_type}),
+ data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}),
errors=errors,
)
@@ -186,8 +256,7 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
entry: HeosConfigEntry = self._get_reauth_entry()
if user_input is not None:
- assert entry.state is ConfigEntryState.LOADED
- if await _validate_auth(user_input, entry.runtime_data.heos, errors):
+ if await _validate_auth(user_input, entry, errors):
return self.async_update_reload_and_abort(entry, options=user_input)
return self.async_show_form(
@@ -208,8 +277,7 @@ class HeosOptionsFlowHandler(OptionsFlow):
"""Manage the options."""
errors: dict[str, str] = {}
if user_input is not None:
- entry: HeosConfigEntry = self.config_entry
- if await _validate_auth(user_input, entry.runtime_data.heos, errors):
+ if await _validate_auth(user_input, self.config_entry, errors):
return self.async_create_entry(data=user_input)
return self.async_show_form(
diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py
index 7f03fa11e79..d49fc17aa53 100644
--- a/homeassistant/components/heos/const.py
+++ b/homeassistant/components/heos/const.py
@@ -2,6 +2,15 @@
ATTR_PASSWORD = "password"
ATTR_USERNAME = "username"
+ATTR_DESTINATION_POSITION = "destination_position"
+ATTR_QUEUE_IDS = "queue_ids"
DOMAIN = "heos"
+ENTRY_TITLE = "HEOS System"
+SERVICE_GET_QUEUE = "get_queue"
+SERVICE_GROUP_VOLUME_SET = "group_volume_set"
+SERVICE_GROUP_VOLUME_DOWN = "group_volume_down"
+SERVICE_GROUP_VOLUME_UP = "group_volume_up"
+SERVICE_MOVE_QUEUE_ITEM = "move_queue_item"
+SERVICE_REMOVE_FROM_QUEUE = "remove_from_queue"
SERVICE_SIGN_IN = "sign_in"
SERVICE_SIGN_OUT = "sign_out"
diff --git a/homeassistant/components/heos/coordinator.py b/homeassistant/components/heos/coordinator.py
index 94aa4ad0ab5..5e72eb1427e 100644
--- a/homeassistant/components/heos/coordinator.py
+++ b/homeassistant/components/heos/coordinator.py
@@ -6,7 +6,6 @@ entities to update. Entities subscribe to entity-specific updates within the ent
"""
from collections.abc import Callable, Sequence
-from datetime import datetime, timedelta
import logging
from typing import Any
@@ -16,6 +15,7 @@ from pyheos import (
HeosError,
HeosNowPlayingMedia,
HeosOptions,
+ HeosPlayer,
MediaItem,
MediaType,
PlayerUpdateResult,
@@ -24,10 +24,10 @@ from pyheos import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
-from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
-from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
@@ -42,7 +42,6 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
def __init__(self, hass: HomeAssistant, config_entry: HeosConfigEntry) -> None:
"""Set up the coordinator and set in config_entry."""
- self.host: str = config_entry.data[CONF_HOST]
credentials: Credentials | None = None
if config_entry.options:
credentials = Credentials(
@@ -52,18 +51,31 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
# media position update upon start of playback or when media changes
self.heos = Heos(
HeosOptions(
- self.host,
+ config_entry.data[CONF_HOST],
all_progress_events=False,
auto_reconnect=True,
+ auto_failover=True,
credentials=credentials,
)
)
- self._update_sources_pending: bool = False
+ self._platform_callbacks: list[Callable[[Sequence[HeosPlayer]], None]] = []
+ self._update_sources_debouncer = Debouncer(
+ hass,
+ _LOGGER,
+ immediate=True,
+ cooldown=2.0,
+ function=self._async_update_sources,
+ )
self._source_list: list[str] = []
self._favorites: dict[int, MediaItem] = {}
self._inputs: Sequence[MediaItem] = []
super().__init__(hass, _LOGGER, config_entry=config_entry, name=DOMAIN)
+ @property
+ def host(self) -> str:
+ """Get the host address of the device."""
+ return self.heos.current_host
+
@property
def inputs(self) -> Sequence[MediaItem]:
"""Get input sources across all devices."""
@@ -124,6 +136,27 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
self.async_update_listeners()
return remove_listener
+ def async_add_platform_callback(
+ self, add_entities_callback: Callable[[Sequence[HeosPlayer]], None]
+ ) -> None:
+ """Add a callback to add entities for a platform."""
+ self._platform_callbacks.append(add_entities_callback)
+
+ def _async_handle_player_update_result(
+ self, update_result: PlayerUpdateResult
+ ) -> None:
+ """Handle a player update result."""
+ if update_result.added_player_ids and self._platform_callbacks:
+ new_players = [
+ self.heos.players[player_id]
+ for player_id in update_result.added_player_ids
+ ]
+ for add_entities_callback in self._platform_callbacks:
+ add_entities_callback(new_players)
+
+ if update_result.updated_player_ids:
+ self._async_update_player_ids(update_result.updated_player_ids)
+
async def _async_on_auth_failure(self) -> None:
"""Handle when the user credentials are no longer valid."""
assert self.config_entry is not None
@@ -136,44 +169,27 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
async def _async_on_reconnected(self) -> None:
"""Handle when reconnected so resources are updated and entities marked available."""
- await self._async_update_players()
+ assert self.config_entry is not None
+ if self.host != self.config_entry.data[CONF_HOST]:
+ self.hass.config_entries.async_update_entry(
+ self.config_entry, data={CONF_HOST: self.host}
+ )
+ _LOGGER.warning("Successfully failed over to HEOS host %s", self.host)
+ else:
+ _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host)
await self._async_update_sources()
- _LOGGER.warning("Successfully reconnected to HEOS host %s", self.host)
self.async_update_listeners()
async def _async_on_controller_event(
- self, event: str, data: PlayerUpdateResult | None
+ self, event: str, data: PlayerUpdateResult | None = None
) -> None:
"""Handle a controller event, such as players or groups changed."""
if event == const.EVENT_PLAYERS_CHANGED:
assert data is not None
- if data.updated_player_ids:
- self._async_update_player_ids(data.updated_player_ids)
- elif (
- event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED)
- and not self._update_sources_pending
- ):
- # Update the sources after a brief delay as we may have received multiple qualifying
- # events at once and devices cannot handle immediately attempting to refresh sources.
- self._update_sources_pending = True
-
- async def update_sources_job(_: datetime | None = None) -> None:
- await self._async_update_sources()
- self._update_sources_pending = False
- self.async_update_listeners()
-
- assert self.config_entry is not None
- self.config_entry.async_on_unload(
- async_call_later(
- self.hass,
- timedelta(seconds=1),
- HassJob(
- update_sources_job,
- "heos_update_sources",
- cancel_on_shutdown=True,
- ),
- )
- )
+ self._async_handle_player_update_result(data)
+ elif event in (const.EVENT_SOURCES_CHANGED, const.EVENT_USER_CHANGED):
+ # Debounce because we may have received multiple qualifying events in rapid succession.
+ await self._update_sources_debouncer.async_call()
self.async_update_listeners()
def _async_update_player_ids(self, updated_player_ids: dict[int, int]) -> None:
@@ -235,17 +251,6 @@ class HeosCoordinator(DataUpdateCoordinator[None]):
else:
self._source_list.extend([source.name for source in self._inputs])
- async def _async_update_players(self) -> None:
- """Update players after reconnection."""
- try:
- player_updates = await self.heos.load_players()
- except HeosError as error:
- _LOGGER.error("Unable to refresh players: %s", error)
- return
- # After reconnecting, player_id may have changed
- if player_updates.updated_player_ids:
- self._async_update_player_ids(player_updates.updated_player_ids)
-
@callback
def async_get_source_list(self) -> list[str]:
"""Return the list of sources for players."""
diff --git a/homeassistant/components/heos/icons.json b/homeassistant/components/heos/icons.json
index 23c2c8faeaf..b03f15a4b0f 100644
--- a/homeassistant/components/heos/icons.json
+++ b/homeassistant/components/heos/icons.json
@@ -1,5 +1,23 @@
{
"services": {
+ "get_queue": {
+ "service": "mdi:playlist-music"
+ },
+ "remove_from_queue": {
+ "service": "mdi:playlist-remove"
+ },
+ "move_queue_item": {
+ "service": "mdi:playlist-edit"
+ },
+ "group_volume_set": {
+ "service": "mdi:volume-medium"
+ },
+ "group_volume_down": {
+ "service": "mdi:volume-low"
+ },
+ "group_volume_up": {
+ "service": "mdi:volume-high"
+ },
"sign_in": {
"service": "mdi:login"
},
diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json
index 22dbbf4da28..8a88913456d 100644
--- a/homeassistant/components/heos/manifest.json
+++ b/homeassistant/components/heos/manifest.json
@@ -7,9 +7,8 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyheos"],
- "quality_scale": "silver",
- "requirements": ["pyheos==1.0.1"],
- "single_config_entry": true,
+ "quality_scale": "platinum",
+ "requirements": ["pyheos==1.0.5"],
"ssdp": [
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py
index b53cb94d8e7..810244a815a 100644
--- a/homeassistant/components/heos/media_player.py
+++ b/homeassistant/components/heos/media_player.py
@@ -2,26 +2,35 @@
from __future__ import annotations
-from collections.abc import Awaitable, Callable, Coroutine
+from collections.abc import Awaitable, Callable, Coroutine, Sequence
+from contextlib import suppress
+import dataclasses
from datetime import datetime
from functools import reduce, wraps
+import logging
from operator import ior
-from typing import Any
+from typing import Any, Final
from pyheos import (
AddCriteriaType,
ControlType,
HeosError,
HeosPlayer,
+ MediaItem,
+ MediaMusicSource,
+ MediaType as HeosMediaType,
PlayState,
RepeatType,
const as heos_const,
)
+from pyheos.util import mediauri as heos_source
from homeassistant.components import media_source
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
+ BrowseError,
BrowseMedia,
+ MediaClass,
MediaPlayerEnqueue,
MediaPlayerEntity,
MediaPlayerEntityFeature,
@@ -30,20 +39,24 @@ from homeassistant.components.media_player import (
RepeatMode,
async_process_play_media_url,
)
+from homeassistant.components.media_source import BrowseMediaSource
from homeassistant.const import Platform
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant, ServiceResponse, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utcnow
+from . import services
from .const import DOMAIN as HEOS_DOMAIN
from .coordinator import HeosConfigEntry, HeosCoordinator
PARALLEL_UPDATES = 0
+BROWSE_ROOT: Final = "heos://media"
+
BASE_SUPPORTED_FEATURES = (
MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_SET
@@ -58,6 +71,7 @@ BASE_SUPPORTED_FEATURES = (
PLAY_STATE_TO_STATE = {
None: MediaPlayerState.IDLE,
+ PlayState.UNKNOWN: MediaPlayerState.IDLE,
PlayState.PLAY: MediaPlayerState.PLAYING,
PlayState.STOP: MediaPlayerState.IDLE,
PlayState.PAUSE: MediaPlayerState.PAUSED,
@@ -86,32 +100,55 @@ HEOS_HA_REPEAT_TYPE_MAP = {
}
HA_HEOS_REPEAT_TYPE_MAP = {v: k for k, v in HEOS_HA_REPEAT_TYPE_MAP.items()}
+HEOS_MEDIA_TYPE_TO_MEDIA_CLASS = {
+ HeosMediaType.ALBUM: MediaClass.ALBUM,
+ HeosMediaType.ARTIST: MediaClass.ARTIST,
+ HeosMediaType.CONTAINER: MediaClass.DIRECTORY,
+ HeosMediaType.GENRE: MediaClass.GENRE,
+ HeosMediaType.HEOS_SERVER: MediaClass.DIRECTORY,
+ HeosMediaType.HEOS_SERVICE: MediaClass.DIRECTORY,
+ HeosMediaType.MUSIC_SERVICE: MediaClass.DIRECTORY,
+ HeosMediaType.PLAYLIST: MediaClass.PLAYLIST,
+ HeosMediaType.SONG: MediaClass.TRACK,
+ HeosMediaType.STATION: MediaClass.TRACK,
+}
+
+_LOGGER = logging.getLogger(__name__)
+
async def async_setup_entry(
- hass: HomeAssistant, entry: HeosConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: HeosConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add media players for a config entry."""
- devices = [
- HeosMediaPlayer(entry.runtime_data, player)
- for player in entry.runtime_data.heos.players.values()
- ]
- async_add_entities(devices)
+ services.register_media_player_services()
+
+ def add_entities_callback(players: Sequence[HeosPlayer]) -> None:
+ """Add entities for each player."""
+ async_add_entities(
+ [HeosMediaPlayer(entry.runtime_data, player) for player in players]
+ )
+
+ coordinator = entry.runtime_data
+ coordinator.async_add_platform_callback(add_entities_callback)
+ add_entities_callback(list(coordinator.heos.players.values()))
-type _FuncType[**_P] = Callable[_P, Awaitable[Any]]
-type _ReturnFuncType[**_P] = Callable[_P, Coroutine[Any, Any, None]]
+type _FuncType[**_P, _R] = Callable[_P, Awaitable[_R]]
+type _ReturnFuncType[**_P, _R] = Callable[_P, Coroutine[Any, Any, _R]]
-def catch_action_error[**_P](
+def catch_action_error[**_P, _R](
action: str,
-) -> Callable[[_FuncType[_P]], _ReturnFuncType[_P]]:
+) -> Callable[[_FuncType[_P, _R]], _ReturnFuncType[_P, _R]]:
"""Return decorator that catches errors and raises HomeAssistantError."""
- def decorator(func: _FuncType[_P]) -> _ReturnFuncType[_P]:
+ def decorator(func: _FuncType[_P, _R]) -> _ReturnFuncType[_P, _R]:
@wraps(func)
- async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None:
+ async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
- await func(*args, **kwargs)
+ return await func(*args, **kwargs)
except (HeosError, ValueError) as ex:
raise HomeAssistantError(
translation_domain=HEOS_DOMAIN,
@@ -211,6 +248,12 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
self.async_on_remove(self._player.add_on_player_event(self._player_update))
await super().async_added_to_hass()
+ @catch_action_error("get queue")
+ async def async_get_queue(self) -> ServiceResponse:
+ """Get the queue for the current player."""
+ queue = await self._player.get_queue()
+ return {"queue": [dataclasses.asdict(item) for item in queue]}
+
@catch_action_error("clear playlist")
async def async_clear_playlist(self) -> None:
"""Clear players playlist."""
@@ -251,6 +294,16 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
"""Play a piece of media."""
+ if heos_source.is_media_uri(media_id):
+ media, data = heos_source.from_media_uri(media_id)
+ if not isinstance(media, MediaItem):
+ raise ValueError(f"Invalid media id '{media_id}'")
+ await self._player.play_media(
+ media,
+ HA_HEOS_ENQUEUE_MAP[kwargs.get(ATTR_MEDIA_ENQUEUE)],
+ )
+ return
+
if media_source.is_media_source_id(media_id):
media_type = MediaType.URL
play_item = await media_source.async_resolve_media(
@@ -301,6 +354,15 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
await self._player.play_preset_station(index)
return
+ if media_type == "queue":
+ # media_id must be an int
+ try:
+ queue_id = int(media_id)
+ except ValueError:
+ raise ValueError(f"Invalid queue id '{media_id}'") from None
+ await self._player.play_queue(queue_id)
+ return
+
raise ValueError(f"Unsupported media type '{media_type}'")
@catch_action_error("select source")
@@ -339,6 +401,41 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
"""Set volume level, range 0..1."""
await self._player.set_volume(int(volume * 100))
+ @catch_action_error("set group volume level")
+ async def async_set_group_volume_level(self, volume_level: float) -> None:
+ """Set group volume level."""
+ if self._player.group_id is None:
+ raise ServiceValidationError(
+ translation_domain=HEOS_DOMAIN,
+ translation_key="entity_not_grouped",
+ translation_placeholders={"entity_id": self.entity_id},
+ )
+ await self.coordinator.heos.set_group_volume(
+ self._player.group_id, int(volume_level * 100)
+ )
+
+ @catch_action_error("group volume down")
+ async def async_group_volume_down(self) -> None:
+ """Turn group volume down for media player."""
+ if self._player.group_id is None:
+ raise ServiceValidationError(
+ translation_domain=HEOS_DOMAIN,
+ translation_key="entity_not_grouped",
+ translation_placeholders={"entity_id": self.entity_id},
+ )
+ await self.coordinator.heos.group_volume_down(self._player.group_id)
+
+ @catch_action_error("group volume up")
+ async def async_group_volume_up(self) -> None:
+ """Turn group volume up for media player."""
+ if self._player.group_id is None:
+ raise ServiceValidationError(
+ translation_domain=HEOS_DOMAIN,
+ translation_key="entity_not_grouped",
+ translation_placeholders={"entity_id": self.entity_id},
+ )
+ await self.coordinator.heos.group_volume_up(self._player.group_id)
+
@catch_action_error("join players")
async def async_join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player."""
@@ -379,6 +476,17 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
await self.coordinator.heos.set_group(new_members)
return
+ async def async_remove_from_queue(self, queue_ids: list[int]) -> None:
+ """Remove items from the queue."""
+ await self._player.remove_from_queue(queue_ids)
+
+ @catch_action_error("move queue item")
+ async def async_move_queue_item(
+ self, queue_ids: list[int], destination_position: int
+ ) -> None:
+ """Move items in the queue."""
+ await self._player.move_queue_item(queue_ids, destination_position)
+
@property
def available(self) -> bool:
"""Return True if the device is available."""
@@ -468,14 +576,103 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
"""Volume level of the media player (0..1)."""
return self._player.volume / 100
+ async def _async_browse_media_root(self) -> BrowseMedia:
+ """Return media browsing root."""
+ if not self.coordinator.heos.music_sources:
+ try:
+ await self.coordinator.heos.get_music_sources()
+ except HeosError as error:
+ _LOGGER.debug("Unable to load music sources: %s", error)
+ children: list[BrowseMedia] = [
+ _media_to_browse_media(source)
+ for source in self.coordinator.heos.music_sources.values()
+ if source.available or source.source_id == heos_const.MUSIC_SOURCE_TUNEIN
+ ]
+ root = BrowseMedia(
+ title="Music Sources",
+ media_class=MediaClass.DIRECTORY,
+ children_media_class=MediaClass.DIRECTORY,
+ media_content_type="",
+ media_content_id=BROWSE_ROOT,
+ can_expand=True,
+ can_play=False,
+ children=children,
+ )
+ # Append media source items
+ with suppress(BrowseError):
+ browse = await self._async_browse_media_source()
+ # If domain is None, it's an overview of available sources
+ if browse.domain is None and browse.children:
+ children.extend(browse.children)
+ else:
+ children.append(browse)
+ return root
+
+ async def _async_browse_heos_media(self, media_content_id: str) -> BrowseMedia:
+ """Browse a HEOS media item."""
+ media, data = heos_source.from_media_uri(media_content_id)
+ browse_media = _media_to_browse_media(media)
+ try:
+ browse_result = await self.coordinator.heos.browse_media(media)
+ except HeosError as error:
+ _LOGGER.debug("Unable to browse media %s: %s", media, error)
+ else:
+ browse_media.children = [
+ _media_to_browse_media(item)
+ for item in browse_result.items
+ if item.browsable or item.playable
+ ]
+ return browse_media
+
+ async def _async_browse_media_source(
+ self, media_content_id: str | None = None
+ ) -> BrowseMediaSource:
+ """Browse a media source item."""
+ return await media_source.async_browse_media(
+ self.hass,
+ media_content_id,
+ content_filter=lambda item: item.media_content_type.startswith("audio/"),
+ )
+
async def async_browse_media(
self,
media_content_type: MediaType | str | None = None,
media_content_id: str | None = None,
) -> BrowseMedia:
"""Implement the websocket media browsing helper."""
- return await media_source.async_browse_media(
- self.hass,
- media_content_id,
- content_filter=lambda item: item.media_content_type.startswith("audio/"),
+ if media_content_id in (None, BROWSE_ROOT):
+ return await self._async_browse_media_root()
+ assert media_content_id is not None
+ if heos_source.is_media_uri(media_content_id):
+ return await self._async_browse_heos_media(media_content_id)
+ if media_source.is_media_source_id(media_content_id):
+ return await self._async_browse_media_source(media_content_id)
+ raise ServiceValidationError(
+ translation_domain=HEOS_DOMAIN,
+ translation_key="unsupported_media_content_id",
+ translation_placeholders={"media_content_id": media_content_id},
)
+
+
+def _media_to_browse_media(media: MediaItem | MediaMusicSource) -> BrowseMedia:
+ """Convert a HEOS media item to a browse media item."""
+ can_expand = False
+ can_play = False
+
+ if isinstance(media, MediaMusicSource):
+ can_expand = (
+ media.source_id == heos_const.MUSIC_SOURCE_TUNEIN or media.available
+ )
+ else:
+ can_expand = media.browsable
+ can_play = media.playable
+
+ return BrowseMedia(
+ can_expand=can_expand,
+ can_play=can_play,
+ media_content_id=heos_source.to_media_uri(media),
+ media_content_type="",
+ media_class=HEOS_MEDIA_TYPE_TO_MEDIA_CLASS[media.type],
+ title=media.name,
+ thumbnail=media.image_url,
+ )
diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml
index 67022ec492c..5f5062b6a82 100644
--- a/homeassistant/components/heos/quality_scale.yaml
+++ b/homeassistant/components/heos/quality_scale.yaml
@@ -38,9 +38,7 @@ rules:
# Gold
devices: done
diagnostics: done
- discovery-update-info:
- status: todo
- comment: Explore if this is possible.
+ discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
@@ -49,7 +47,7 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
- dynamic-devices: todo
+ dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -57,8 +55,8 @@ rules:
exception-translations: done
icon-translations: done
reconfiguration-flow: done
- repair-issues: todo
- stale-devices: todo
+ repair-issues: done
+ stale-devices: done
# Platinum
async-dependency: done
inject-websession:
diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py
index dc11bb7a76d..86c6f6d0533 100644
--- a/homeassistant/components/heos/services.py
+++ b/homeassistant/components/heos/services.py
@@ -1,19 +1,35 @@
"""Services for the HEOS integration."""
+from dataclasses import dataclass
import logging
+from typing import Final
from pyheos import CommandAuthenticationError, Heos, HeosError
import voluptuous as vol
+from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
from homeassistant.config_entries import ConfigEntryState
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from homeassistant.helpers import config_validation as cv, issue_registry as ir
+from homeassistant.helpers import (
+ config_validation as cv,
+ entity_platform,
+ issue_registry as ir,
+)
+from homeassistant.helpers.typing import VolDictType, VolSchemaType
from .const import (
+ ATTR_DESTINATION_POSITION,
ATTR_PASSWORD,
+ ATTR_QUEUE_IDS,
ATTR_USERNAME,
DOMAIN,
+ SERVICE_GET_QUEUE,
+ SERVICE_GROUP_VOLUME_DOWN,
+ SERVICE_GROUP_VOLUME_SET,
+ SERVICE_GROUP_VOLUME_UP,
+ SERVICE_MOVE_QUEUE_ITEM,
+ SERVICE_REMOVE_FROM_QUEUE,
SERVICE_SIGN_IN,
SERVICE_SIGN_OUT,
)
@@ -44,6 +60,75 @@ def register(hass: HomeAssistant) -> None:
)
+@dataclass(frozen=True)
+class EntityServiceDescription:
+ """Describe an entity service."""
+
+ name: str
+ method_name: str
+ schema: VolDictType | VolSchemaType | None = None
+ supports_response: SupportsResponse = SupportsResponse.NONE
+
+ def async_register(self, platform: entity_platform.EntityPlatform) -> None:
+ """Register the service with the platform."""
+ platform.async_register_entity_service(
+ self.name,
+ self.schema,
+ self.method_name,
+ supports_response=self.supports_response,
+ )
+
+
+REMOVE_FROM_QUEUE_SCHEMA: Final[VolDictType] = {
+ vol.Required(ATTR_QUEUE_IDS): vol.All(
+ cv.ensure_list,
+ [vol.All(cv.positive_int, vol.Range(min=1))],
+ vol.Unique(),
+ )
+}
+GROUP_VOLUME_SET_SCHEMA: Final[VolDictType] = {
+ vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float
+}
+MOVE_QEUEUE_ITEM_SCHEMA: Final[VolDictType] = {
+ vol.Required(ATTR_QUEUE_IDS): vol.All(
+ cv.ensure_list,
+ [vol.All(vol.Coerce(int), vol.Range(min=1, max=1000))],
+ vol.Unique(),
+ ),
+ vol.Required(ATTR_DESTINATION_POSITION): vol.All(
+ vol.Coerce(int), vol.Range(min=1, max=1000)
+ ),
+}
+
+MEDIA_PLAYER_ENTITY_SERVICES: Final = (
+ # Player queue services
+ EntityServiceDescription(
+ SERVICE_GET_QUEUE, "async_get_queue", supports_response=SupportsResponse.ONLY
+ ),
+ EntityServiceDescription(
+ SERVICE_REMOVE_FROM_QUEUE, "async_remove_from_queue", REMOVE_FROM_QUEUE_SCHEMA
+ ),
+ EntityServiceDescription(
+ SERVICE_MOVE_QUEUE_ITEM, "async_move_queue_item", MOVE_QEUEUE_ITEM_SCHEMA
+ ),
+ # Group volume services
+ EntityServiceDescription(
+ SERVICE_GROUP_VOLUME_SET,
+ "async_set_group_volume_level",
+ GROUP_VOLUME_SET_SCHEMA,
+ ),
+ EntityServiceDescription(SERVICE_GROUP_VOLUME_DOWN, "async_group_volume_down"),
+ EntityServiceDescription(SERVICE_GROUP_VOLUME_UP, "async_group_volume_up"),
+)
+
+
+def register_media_player_services() -> None:
+ """Register media_player entity services."""
+ platform = entity_platform.async_get_current_platform()
+ for service in MEDIA_PLAYER_ENTITY_SERVICES:
+ service.async_register(platform)
+
+
def _get_controller(hass: HomeAssistant) -> Heos:
"""Get the HEOS controller instance."""
_LOGGER.warning(
diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml
index 8dc222d65ba..333a15940bc 100644
--- a/homeassistant/components/heos/services.yaml
+++ b/homeassistant/components/heos/services.yaml
@@ -1,3 +1,68 @@
+get_queue:
+ target:
+ entity:
+ integration: heos
+ domain: media_player
+
+remove_from_queue:
+ target:
+ entity:
+ integration: heos
+ domain: media_player
+ fields:
+ queue_ids:
+ required: true
+ selector:
+ text:
+ multiple: true
+ type: number
+
+move_queue_item:
+ target:
+ entity:
+ integration: heos
+ domain: media_player
+ fields:
+ queue_ids:
+ required: true
+ selector:
+ text:
+ multiple: true
+ type: number
+ destination_position:
+ required: true
+ selector:
+ number:
+ min: 1
+ max: 1000
+ step: 1
+
+group_volume_set:
+ target:
+ entity:
+ integration: heos
+ domain: media_player
+ fields:
+ volume_level:
+ required: true
+ selector:
+ number:
+ min: 0
+ max: 1
+ step: 0.01
+
+group_volume_down:
+ target:
+ entity:
+ integration: heos
+ domain: media_player
+
+group_volume_up:
+ target:
+ entity:
+ integration: heos
+ domain: media_player
+
sign_in:
fields:
username:
diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json
index 53e20a032b5..c99d73a70d7 100644
--- a/homeassistant/components/heos/strings.json
+++ b/homeassistant/components/heos/strings.json
@@ -8,9 +8,13 @@
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
- "host": "Host name or IP address of a HEOS-capable product (preferrably one connected via wire to the network)."
+ "host": "Host name or IP address of a HEOS-capable product (preferably one connected via wire to the network)."
}
},
+ "confirm_discovery": {
+ "title": "Discovered HEOS System",
+ "description": "Do you want to add your HEOS devices to Home Assistant?"
+ },
"reconfigure": {
"title": "Reconfigure HEOS",
"description": "Change the host name or IP address of the HEOS-capable product used to access your HEOS System.",
@@ -43,6 +47,7 @@
},
"abort": {
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
@@ -71,6 +76,52 @@
}
},
"services": {
+ "group_volume_set": {
+ "name": "Set group volume",
+ "description": "Sets the group's volume while preserving member volume ratios.",
+ "fields": {
+ "volume_level": {
+ "name": "Level",
+ "description": "The volume. 0 is inaudible, 1 is the maximum volume."
+ }
+ }
+ },
+ "get_queue": {
+ "name": "Get queue",
+ "description": "Retrieves the queue of the media player."
+ },
+ "remove_from_queue": {
+ "name": "Remove from queue",
+ "description": "Removes items from the play queue.",
+ "fields": {
+ "queue_ids": {
+ "name": "Queue IDs",
+ "description": "The IDs (indexes) of the items in the queue to remove."
+ }
+ }
+ },
+ "move_queue_item": {
+ "name": "Move queue item",
+ "description": "Move one or more items within the play queue.",
+ "fields": {
+ "queue_ids": {
+ "name": "Queue IDs",
+ "description": "The IDs (indexes) of the items in the queue to move."
+ },
+ "destination_position": {
+ "name": "Destination position",
+ "description": "The position index in the queue to move the items to."
+ }
+ }
+ },
+ "group_volume_down": {
+ "name": "Turn down group volume",
+ "description": "Turns down the group volume."
+ },
+ "group_volume_up": {
+ "name": "Turn up group volume",
+ "description": "Turns up the group volume."
+ },
"sign_in": {
"name": "Sign in",
"description": "Signs in to a HEOS account.",
@@ -94,6 +145,9 @@
"action_error": {
"message": "Unable to {action}: {error}"
},
+ "entity_not_grouped": {
+ "message": "Entity {entity_id} is not joined to a group"
+ },
"entity_not_found": {
"message": "Entity {entity_id} was not found"
},
@@ -120,6 +174,9 @@
},
"unknown_source": {
"message": "Unknown source: {source}"
+ },
+ "unsupported_media_content_id": {
+ "message": "Unsupported media_content_id: {media_content_id}"
}
},
"issues": {
diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py
index 4d7566ef2e2..0f0cbb7d3cb 100644
--- a/homeassistant/components/here_travel_time/sensor.py
+++ b/homeassistant/components/here_travel_time/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -78,7 +78,7 @@ def sensor_descriptions(travel_mode: str) -> tuple[SensorEntityDescription, ...]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add HERE travel time entities from a config_entry."""
diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py
index 1dc1eaabcaa..cd9f3666e08 100644
--- a/homeassistant/components/hisense_aehw4a1/climate.py
+++ b/homeassistant/components/hisense_aehw4a1/climate.py
@@ -28,7 +28,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import CONF_IP_ADDRESS, DOMAIN
@@ -121,7 +121,7 @@ def _build_entity(device):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the AEH-W4A1 climate platform."""
# Priority 1: manual config
diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py
index c57e766eaed..3761c935992 100644
--- a/homeassistant/components/history/websocket_api.py
+++ b/homeassistant/components/history/websocket_api.py
@@ -52,7 +52,7 @@ class HistoryLiveStream:
subscriptions: list[CALLBACK_TYPE]
end_time_unsub: CALLBACK_TYPE | None = None
task: asyncio.Task | None = None
- wait_sync_task: asyncio.Task | None = None
+ wait_sync_future: asyncio.Future[None] | None = None
@callback
@@ -491,8 +491,8 @@ async def ws_stream(
subscriptions.clear()
if live_stream.task:
live_stream.task.cancel()
- if live_stream.wait_sync_task:
- live_stream.wait_sync_task.cancel()
+ if live_stream.wait_sync_future:
+ live_stream.wait_sync_future.cancel()
if live_stream.end_time_unsub:
live_stream.end_time_unsub()
live_stream.end_time_unsub = None
@@ -554,10 +554,12 @@ async def ws_stream(
)
)
- live_stream.wait_sync_task = create_eager_task(
- get_instance(hass).async_block_till_done()
- )
- await live_stream.wait_sync_task
+ if sync_future := get_instance(hass).async_get_commit_future():
+ # Set the future so we can cancel it if the client
+ # unsubscribes before the commit is done so we don't
+ # query the database needlessly
+ live_stream.wait_sync_future = sync_future
+ await live_stream.wait_sync_future
#
# Fetch any states from the database that have
diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py
index b25daf56598..6935b13bc3d 100644
--- a/homeassistant/components/history_stats/sensor.py
+++ b/homeassistant/components/history_stats/sensor.py
@@ -27,7 +27,10 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_device_info_to_link_from_entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -116,7 +119,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
entry: HistoryStatsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the History stats sensor entry."""
diff --git a/homeassistant/components/history_stats/strings.json b/homeassistant/components/history_stats/strings.json
index aff2ac50bef..e10a72f6742 100644
--- a/homeassistant/components/history_stats/strings.json
+++ b/homeassistant/components/history_stats/strings.json
@@ -24,7 +24,7 @@
}
},
"options": {
- "description": "Read the documention for further details on how to configure the history stats sensor using these options.",
+ "description": "Read the documentation for further details on how to configure the history stats sensor using these options.",
"data": {
"start": "Start",
"end": "End",
diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py
index 2b196ce820b..c2fe47642a0 100644
--- a/homeassistant/components/hive/alarm_control_panel.py
+++ b/homeassistant/components/hive/alarm_control_panel.py
@@ -11,7 +11,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import HiveEntity
@@ -27,7 +27,9 @@ HIVETOHA = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hive thermostat based on a config entry."""
diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py
index d2938896f92..2076d592a7c 100644
--- a/homeassistant/components/hive/binary_sensor.py
+++ b/homeassistant/components/hive/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import HiveEntity
@@ -68,7 +68,9 @@ SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hive thermostat based on a config entry."""
diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py
index c76379cf940..bd7553faa1a 100644
--- a/homeassistant/components/hive/climate.py
+++ b/homeassistant/components/hive/climate.py
@@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import refresh_system
from .const import (
@@ -58,7 +58,9 @@ _LOGGER = logging.getLogger()
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hive thermostat based on a config entry."""
diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py
index e941087c6fb..80a81583429 100644
--- a/homeassistant/components/hive/light.py
+++ b/homeassistant/components/hive/light.py
@@ -14,7 +14,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from . import refresh_system
@@ -29,7 +29,9 @@ SCAN_INTERVAL = timedelta(seconds=15)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hive thermostat based on a config entry."""
diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json
index f68478516ab..712ccf09cae 100644
--- a/homeassistant/components/hive/manifest.json
+++ b/homeassistant/components/hive/manifest.json
@@ -9,5 +9,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["apyhiveapi"],
- "requirements": ["pyhive-integration==1.0.1"]
+ "requirements": ["pyhive-integration==1.0.2"]
}
diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py
index 00a2116e268..0609e43c4a9 100644
--- a/homeassistant/components/hive/sensor.py
+++ b/homeassistant/components/hive/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
@@ -89,7 +89,9 @@ SENSOR_TYPES: tuple[HiveSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hive thermostat based on a config entry."""
hive = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json
index 219776ad7e6..58ba949d325 100644
--- a/homeassistant/components/hive/strings.json
+++ b/homeassistant/components/hive/strings.json
@@ -2,27 +2,27 @@
"config": {
"step": {
"user": {
- "title": "Hive Login",
+ "title": "Hive login",
"description": "Enter your Hive login information.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
- "scan_interval": "Scan Interval (seconds)"
+ "scan_interval": "Scan interval (seconds)"
}
},
"2fa": {
- "title": "Hive Two-factor Authentication.",
- "description": "Enter your Hive authentication code. \n \n Please enter code 0000 to request another code.",
+ "title": "Hive two-factor authentication",
+ "description": "Enter your Hive authentication code.\n\nPlease enter code 0000 to request another code.",
"data": {
"2fa": "Two-factor code"
}
},
"configuration": {
+ "title": "Hive configuration",
+ "description": "Enter your Hive configuration.",
"data": {
- "device_name": "Device Name"
- },
- "description": "Enter your Hive configuration",
- "title": "Hive Configuration."
+ "device_name": "Device name"
+ }
},
"reauth": {
"title": "[%key:component::hive::config::step::user::title%]",
@@ -34,10 +34,10 @@
}
},
"error": {
- "invalid_username": "Failed to sign into Hive. Your email address is not recognised.",
- "invalid_password": "Failed to sign into Hive. Incorrect password, please try again.",
- "invalid_code": "Failed to sign into Hive. Your two-factor authentication code was incorrect.",
- "no_internet_available": "An internet connection is required to connect to Hive.",
+ "invalid_username": "Failed to sign in to Hive. Your email address is not recognised.",
+ "invalid_password": "Failed to sign in to Hive. Incorrect password, please try again.",
+ "invalid_code": "Failed to sign in to Hive. Your two-factor authentication code was incorrect.",
+ "no_internet_available": "An Internet connection is required to connect to Hive.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
@@ -105,7 +105,7 @@
"sensor": {
"heating": {
"state": {
- "manual": "Manual",
+ "manual": "[%key:common::state::manual%]",
"off": "[%key:common::state::off%]",
"schedule": "Schedule"
}
diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py
index 1421616db57..d4fefea5a56 100644
--- a/homeassistant/components/hive/switch.py
+++ b/homeassistant/components/hive/switch.py
@@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import refresh_system
from .const import ATTR_MODE, DOMAIN
@@ -33,7 +33,9 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hive thermostat based on a config entry."""
diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py
index b038739d2ad..5f0a3d0f3fa 100644
--- a/homeassistant/components/hive/water_heater.py
+++ b/homeassistant/components/hive/water_heater.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import refresh_system
from .const import (
@@ -45,7 +45,9 @@ SUPPORT_WATER_HEATER = [STATE_ECO, STATE_ON, STATE_OFF]
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hive thermostat based on a config entry."""
diff --git a/homeassistant/components/hko/config_flow.py b/homeassistant/components/hko/config_flow.py
index 8548bb4767d..1e2a6230455 100644
--- a/homeassistant/components/hko/config_flow.py
+++ b/homeassistant/components/hko/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from asyncio import timeout
+import logging
from typing import Any
from hko import HKO, LOCATIONS, HKOError
@@ -15,6 +16,8 @@ from homeassistant.helpers.selector import SelectSelector, SelectSelectorConfig
from .const import API_RHRREAD, DEFAULT_LOCATION, DOMAIN, KEY_LOCATION
+_LOGGER = logging.getLogger(__name__)
+
def get_loc_name(item):
"""Return an array of supported locations."""
@@ -54,7 +57,8 @@ class HKOConfigFlow(ConfigFlow, domain=DOMAIN):
except HKOError:
errors["base"] = "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(
diff --git a/homeassistant/components/hko/coordinator.py b/homeassistant/components/hko/coordinator.py
index 5845e8831fe..aede960e702 100644
--- a/homeassistant/components/hko/coordinator.py
+++ b/homeassistant/components/hko/coordinator.py
@@ -11,7 +11,6 @@ from hko import HKO, HKOError
from homeassistant.components.weather import (
ATTR_CONDITION_CLOUDY,
ATTR_CONDITION_FOG,
- ATTR_CONDITION_HAIL,
ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_CONDITION_PARTLYCLOUDY,
ATTR_CONDITION_POURING,
@@ -145,7 +144,7 @@ class HKOUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Return the condition corresponding to the weather info."""
info = info.lower()
if WEATHER_INFO_RAIN in info:
- return ATTR_CONDITION_HAIL
+ return ATTR_CONDITION_RAINY
if WEATHER_INFO_SNOW in info and WEATHER_INFO_RAIN in info:
return ATTR_CONDITION_SNOWY_RAINY
if WEATHER_INFO_SNOW in info:
diff --git a/homeassistant/components/hko/weather.py b/homeassistant/components/hko/weather.py
index 6d3d12d8ab4..e746d4304d3 100644
--- a/homeassistant/components/hko/weather.py
+++ b/homeassistant/components/hko/weather.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -28,7 +28,7 @@ from .coordinator import HKOUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a HKO weather entity from a config_entry."""
assert config_entry.unique_id is not None
diff --git a/homeassistant/components/hlk_sw16/entity.py b/homeassistant/components/hlk_sw16/entity.py
index fdef5f6764b..91510760968 100644
--- a/homeassistant/components/hlk_sw16/entity.py
+++ b/homeassistant/components/hlk_sw16/entity.py
@@ -35,7 +35,7 @@ class SW16Entity(Entity):
self.async_write_ha_state()
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if entity is available."""
return bool(self._client.is_connected)
@@ -44,7 +44,7 @@ class SW16Entity(Entity):
"""Update availability state."""
self.async_write_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register update callback."""
self._client.register_status_callback(
self.handle_event_callback, self._device_port
diff --git a/homeassistant/components/hlk_sw16/switch.py b/homeassistant/components/hlk_sw16/switch.py
index 3911dd6eab9..c6e6f7f5201 100644
--- a/homeassistant/components/hlk_sw16/switch.py
+++ b/homeassistant/components/hlk_sw16/switch.py
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DATA_DEVICE_REGISTER
from .const import DOMAIN
@@ -26,7 +26,9 @@ def devices_from_entities(hass, entry):
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HLK-SW16 platform."""
async_add_entities(devices_from_entities(hass, entry))
diff --git a/homeassistant/components/holiday/calendar.py b/homeassistant/components/holiday/calendar.py
index 6dccd972164..1c01319129b 100644
--- a/homeassistant/components/holiday/calendar.py
+++ b/homeassistant/components/holiday/calendar.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COUNTRY
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util import dt as dt_util
@@ -69,7 +69,7 @@ def _get_obj_holidays_and_language(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Holiday Calendar config entry."""
country: str = config_entry.data[CONF_COUNTRY]
diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json
index 6952d48ef32..d54d6955087 100644
--- a/homeassistant/components/holiday/manifest.json
+++ b/homeassistant/components/holiday/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling",
- "requirements": ["holidays==0.66", "babel==2.15.0"]
+ "requirements": ["holidays==0.70", "babel==2.15.0"]
}
diff --git a/homeassistant/components/holiday/strings.json b/homeassistant/components/holiday/strings.json
index d464f9e8bfd..6e317b8fa7b 100644
--- a/homeassistant/components/holiday/strings.json
+++ b/homeassistant/components/holiday/strings.json
@@ -8,7 +8,7 @@
"step": {
"user": {
"data": {
- "country": "Country"
+ "country": "[%key:common::config_flow::data::country%]"
}
},
"options": {
diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py
index becc78cef90..38db34aa72a 100644
--- a/homeassistant/components/home_connect/__init__.py
+++ b/homeassistant/components/home_connect/__init__.py
@@ -3,100 +3,34 @@
from __future__ import annotations
import logging
-from typing import Any, cast
+from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient
-from aiohomeconnect.model import CommandKey, Option, OptionKey, ProgramKey, SettingKey
-from aiohomeconnect.model.error import HomeConnectError
-import voluptuous as vol
+import aiohttp
-from homeassistant.const import ATTR_DEVICE_ID, Platform
-from homeassistant.core import HomeAssistant, ServiceCall, callback
-from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
config_entry_oauth2_flow,
config_validation as cv,
- device_registry as dr,
+ issue_registry as ir,
)
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
from homeassistant.helpers.typing import ConfigType
from .api import AsyncConfigEntryAuth
-from .const import (
- ATTR_KEY,
- ATTR_PROGRAM,
- ATTR_UNIT,
- ATTR_VALUE,
- DOMAIN,
- OLD_NEW_UNIQUE_ID_SUFFIX_MAP,
- SERVICE_OPTION_ACTIVE,
- SERVICE_OPTION_SELECTED,
- SERVICE_PAUSE_PROGRAM,
- SERVICE_RESUME_PROGRAM,
- SERVICE_SELECT_PROGRAM,
- SERVICE_SETTING,
- SERVICE_START_PROGRAM,
- SVE_TRANSLATION_PLACEHOLDER_KEY,
- SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
- SVE_TRANSLATION_PLACEHOLDER_VALUE,
-)
+from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
-from .utils import get_dict_from_home_connect_error
+from .services import register_actions
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-SERVICE_SETTING_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_DEVICE_ID): str,
- vol.Required(ATTR_KEY): vol.All(
- vol.Coerce(SettingKey),
- vol.NotIn([SettingKey.UNKNOWN]),
- ),
- vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
- }
-)
-
-SERVICE_OPTION_SCHEMA = vol.Schema(
- {
- vol.Required(ATTR_DEVICE_ID): str,
- vol.Required(ATTR_KEY): vol.All(
- vol.Coerce(OptionKey),
- vol.NotIn([OptionKey.UNKNOWN]),
- ),
- vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
- vol.Optional(ATTR_UNIT): str,
- }
-)
-
-SERVICE_PROGRAM_SCHEMA = vol.Any(
- {
- vol.Required(ATTR_DEVICE_ID): str,
- vol.Required(ATTR_PROGRAM): vol.All(
- vol.Coerce(ProgramKey),
- vol.NotIn([ProgramKey.UNKNOWN]),
- ),
- vol.Required(ATTR_KEY): vol.All(
- vol.Coerce(OptionKey),
- vol.NotIn([OptionKey.UNKNOWN]),
- ),
- vol.Required(ATTR_VALUE): vol.Any(int, str),
- vol.Optional(ATTR_UNIT): str,
- },
- {
- vol.Required(ATTR_DEVICE_ID): str,
- vol.Required(ATTR_PROGRAM): vol.All(
- vol.Coerce(ProgramKey),
- vol.NotIn([ProgramKey.UNKNOWN]),
- ),
- },
-)
-
-SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str})
-
PLATFORMS = [
Platform.BINARY_SENSOR,
+ Platform.BUTTON,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,
@@ -106,216 +40,9 @@ PLATFORMS = [
]
-async def _get_client_and_ha_id(
- hass: HomeAssistant, device_id: str
-) -> tuple[HomeConnectClient, str]:
- device_registry = dr.async_get(hass)
- device_entry = device_registry.async_get(device_id)
- if device_entry is None:
- raise ServiceValidationError("Device entry not found for device id")
- entry: HomeConnectConfigEntry | None = None
- for entry_id in device_entry.config_entries:
- _entry = hass.config_entries.async_get_entry(entry_id)
- assert _entry
- if _entry.domain == DOMAIN:
- entry = cast(HomeConnectConfigEntry, _entry)
- break
- if entry is None:
- raise ServiceValidationError(
- "Home Connect config entry not found for that device id"
- )
-
- ha_id = next(
- (
- identifier[1]
- for identifier in device_entry.identifiers
- if identifier[0] == DOMAIN
- ),
- None,
- )
- if ha_id is None:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="appliance_not_found",
- translation_placeholders={
- "device_id": device_id,
- },
- )
- return entry.runtime_data.client, ha_id
-
-
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Home Connect component."""
-
- async def _async_service_program(call: ServiceCall, start: bool):
- """Execute calls to services taking a program."""
- program = call.data[ATTR_PROGRAM]
- client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
-
- option_key = call.data.get(ATTR_KEY)
- options = (
- [
- Option(
- option_key,
- call.data[ATTR_VALUE],
- unit=call.data.get(ATTR_UNIT),
- )
- ]
- if option_key is not None
- else None
- )
-
- try:
- if start:
- await client.start_program(ha_id, program_key=program, options=options)
- else:
- await client.set_selected_program(
- ha_id, program_key=program, options=options
- )
- except HomeConnectError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="start_program" if start else "select_program",
- translation_placeholders={
- **get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program,
- },
- ) from err
-
- async def _async_service_set_program_options(call: ServiceCall, active: bool):
- """Execute calls to services taking a program."""
- option_key = call.data[ATTR_KEY]
- value = call.data[ATTR_VALUE]
- unit = call.data.get(ATTR_UNIT)
- client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
-
- try:
- if active:
- await client.set_active_program_option(
- ha_id,
- option_key=option_key,
- value=value,
- unit=unit,
- )
- else:
- await client.set_selected_program_option(
- ha_id,
- option_key=option_key,
- value=value,
- unit=unit,
- )
- except HomeConnectError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="set_options_active_program"
- if active
- else "set_options_selected_program",
- translation_placeholders={
- **get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_KEY: option_key,
- SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
- },
- ) from err
-
- async def _async_service_command(call: ServiceCall, command_key: CommandKey):
- """Execute calls to services executing a command."""
- client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
-
- try:
- await client.put_command(ha_id, command_key=command_key, value=True)
- except HomeConnectError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="execute_command",
- translation_placeholders={
- **get_dict_from_home_connect_error(err),
- "command": command_key.value,
- },
- ) from err
-
- async def async_service_option_active(call: ServiceCall):
- """Service for setting an option for an active program."""
- await _async_service_set_program_options(call, True)
-
- async def async_service_option_selected(call: ServiceCall):
- """Service for setting an option for a selected program."""
- await _async_service_set_program_options(call, False)
-
- async def async_service_setting(call: ServiceCall):
- """Service for changing a setting."""
- key = call.data[ATTR_KEY]
- value = call.data[ATTR_VALUE]
- client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
-
- try:
- await client.set_setting(ha_id, setting_key=key, value=value)
- except HomeConnectError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="set_setting",
- translation_placeholders={
- **get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_KEY: key,
- SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
- },
- ) from err
-
- async def async_service_pause_program(call: ServiceCall):
- """Service for pausing a program."""
- await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM)
-
- async def async_service_resume_program(call: ServiceCall):
- """Service for resuming a paused program."""
- await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM)
-
- async def async_service_select_program(call: ServiceCall):
- """Service for selecting a program."""
- await _async_service_program(call, False)
-
- async def async_service_start_program(call: ServiceCall):
- """Service for starting a program."""
- await _async_service_program(call, True)
-
- hass.services.async_register(
- DOMAIN,
- SERVICE_OPTION_ACTIVE,
- async_service_option_active,
- schema=SERVICE_OPTION_SCHEMA,
- )
- hass.services.async_register(
- DOMAIN,
- SERVICE_OPTION_SELECTED,
- async_service_option_selected,
- schema=SERVICE_OPTION_SCHEMA,
- )
- hass.services.async_register(
- DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA
- )
- hass.services.async_register(
- DOMAIN,
- SERVICE_PAUSE_PROGRAM,
- async_service_pause_program,
- schema=SERVICE_COMMAND_SCHEMA,
- )
- hass.services.async_register(
- DOMAIN,
- SERVICE_RESUME_PROGRAM,
- async_service_resume_program,
- schema=SERVICE_COMMAND_SCHEMA,
- )
- hass.services.async_register(
- DOMAIN,
- SERVICE_SELECT_PROGRAM,
- async_service_select_program,
- schema=SERVICE_PROGRAM_SCHEMA,
- )
- hass.services.async_register(
- DOMAIN,
- SERVICE_START_PROGRAM,
- async_service_start_program,
- schema=SERVICE_PROGRAM_SCHEMA,
- )
-
+ register_actions(hass)
return True
@@ -330,18 +57,31 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
config_entry_auth = AsyncConfigEntryAuth(hass, session)
+ try:
+ await config_entry_auth.async_get_access_token()
+ except aiohttp.ClientResponseError as err:
+ if 400 <= err.status < 500:
+ raise ConfigEntryAuthFailed from err
+ raise ConfigEntryNotReady from err
+ except aiohttp.ClientError as err:
+ raise ConfigEntryNotReady from err
home_connect_client = HomeConnectClient(config_entry_auth)
coordinator = HomeConnectCoordinator(hass, entry, home_connect_client)
- await coordinator.async_config_entry_first_refresh()
-
+ await coordinator.async_setup()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.runtime_data.start_event_listener()
+ entry.async_create_background_task(
+ hass,
+ coordinator.async_refresh(),
+ f"home_connect-initial-full-refresh-{entry.entry_id}",
+ )
+
return True
@@ -349,6 +89,18 @@ async def async_unload_entry(
hass: HomeAssistant, entry: HomeConnectConfigEntry
) -> bool:
"""Unload a config entry."""
+ issue_registry = ir.async_get(hass)
+ issues_to_delete = [
+ "deprecated_set_program_and_option_actions",
+ "deprecated_command_actions",
+ ] + [
+ issue_id
+ for (issue_domain, issue_id) in issue_registry.issues
+ if issue_domain == DOMAIN
+ and issue_id.startswith("home_connect_too_many_connected_paired_events")
+ ]
+ for issue_id in issues_to_delete:
+ issue_registry.async_delete(DOMAIN, issue_id)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py
index 5d711dae032..b66236c367d 100644
--- a/homeassistant/components/home_connect/api.py
+++ b/homeassistant/components/home_connect/api.py
@@ -1,5 +1,7 @@
"""API for Home Connect bound to HASS OAuth."""
+from typing import cast
+
from aiohomeconnect.client import AbstractAuth
from aiohomeconnect.const import API_ENDPOINT
@@ -25,4 +27,4 @@ class AsyncConfigEntryAuth(AbstractAuth):
"""Return a valid access token."""
await self.session.async_ensure_token_valid()
- return self.session.token["access_token"]
+ return cast(str, self.session.token["access_token"])
diff --git a/homeassistant/components/home_connect/binary_sensor.py b/homeassistant/components/home_connect/binary_sensor.py
index 67e3d56e713..7e4523201f9 100644
--- a/homeassistant/components/home_connect/binary_sensor.py
+++ b/homeassistant/components/home_connect/binary_sensor.py
@@ -3,40 +3,24 @@
from dataclasses import dataclass
from typing import cast
-from aiohomeconnect.model import StatusKey
+from aiohomeconnect.model import EventKey, StatusKey
-from homeassistant.components.automation import automations_with_entity
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.components.script import scripts_with_entity
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import (
- IssueSeverity,
- async_create_issue,
- async_delete_issue,
-)
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
-from .const import (
- BSH_DOOR_STATE_CLOSED,
- BSH_DOOR_STATE_LOCKED,
- BSH_DOOR_STATE_OPEN,
- DOMAIN,
- REFRIGERATION_STATUS_DOOR_CLOSED,
- REFRIGERATION_STATUS_DOOR_OPEN,
-)
-from .coordinator import (
- HomeConnectApplianceData,
- HomeConnectConfigEntry,
- HomeConnectCoordinator,
-)
+from .const import REFRIGERATION_STATUS_DOOR_CLOSED, REFRIGERATION_STATUS_DOOR_OPEN
+from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
+PARALLEL_UPDATES = 0
+
REFRIGERATION_DOOR_BOOLEAN_MAP = {
REFRIGERATION_STATUS_DOOR_CLOSED: False,
REFRIGERATION_STATUS_DOOR_OPEN: True,
@@ -93,12 +77,42 @@ BINARY_SENSORS = (
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST,
translation_key="lost",
),
+ HomeConnectBinarySensorEntityDescription(
+ key=StatusKey.REFRIGERATION_COMMON_DOOR_BOTTLE_COOLER,
+ boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
+ device_class=BinarySensorDeviceClass.DOOR,
+ translation_key="bottle_cooler_door",
+ ),
HomeConnectBinarySensorEntityDescription(
key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
device_class=BinarySensorDeviceClass.DOOR,
+ translation_key="common_chiller_door",
+ ),
+ HomeConnectBinarySensorEntityDescription(
+ key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER,
+ boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
+ device_class=BinarySensorDeviceClass.DOOR,
translation_key="chiller_door",
),
+ HomeConnectBinarySensorEntityDescription(
+ key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_LEFT,
+ boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
+ device_class=BinarySensorDeviceClass.DOOR,
+ translation_key="left_chiller_door",
+ ),
+ HomeConnectBinarySensorEntityDescription(
+ key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_RIGHT,
+ boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
+ device_class=BinarySensorDeviceClass.DOOR,
+ translation_key="right_chiller_door",
+ ),
+ HomeConnectBinarySensorEntityDescription(
+ key=StatusKey.REFRIGERATION_COMMON_DOOR_FLEX_COMPARTMENT,
+ boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
+ device_class=BinarySensorDeviceClass.DOOR,
+ translation_key="flex_compartment_door",
+ ),
HomeConnectBinarySensorEntityDescription(
key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
@@ -111,6 +125,17 @@ BINARY_SENSORS = (
device_class=BinarySensorDeviceClass.DOOR,
translation_key="refrigerator_door",
),
+ HomeConnectBinarySensorEntityDescription(
+ key=StatusKey.REFRIGERATION_COMMON_DOOR_WINE_COMPARTMENT,
+ boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
+ device_class=BinarySensorDeviceClass.DOOR,
+ translation_key="wine_compartment_door",
+ ),
+)
+
+CONNECTED_BINARY_ENTITY_DESCRIPTION = BinarySensorEntityDescription(
+ key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
)
@@ -119,21 +144,23 @@ def _get_entities_for_appliance(
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
- entities: list[HomeConnectEntity] = []
+ entities: list[HomeConnectEntity] = [
+ HomeConnectConnectivityBinarySensor(
+ entry.runtime_data, appliance, CONNECTED_BINARY_ENTITY_DESCRIPTION
+ )
+ ]
entities.extend(
HomeConnectBinarySensor(entry.runtime_data, appliance, description)
for description in BINARY_SENSORS
if description.key in appliance.status
)
- if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status:
- entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance))
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect binary sensor."""
setup_home_connect_entry(
@@ -159,79 +186,16 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
self._attr_is_on = None
-class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
- """Binary sensor for Home Connect Generic Door."""
+class HomeConnectConnectivityBinarySensor(HomeConnectEntity, BinarySensorEntity):
+ """Binary sensor for Home Connect appliance's connection status."""
- _attr_has_entity_name = False
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
- def __init__(
- self,
- coordinator: HomeConnectCoordinator,
- appliance: HomeConnectApplianceData,
- ) -> None:
- """Initialize the entity."""
- super().__init__(
- coordinator,
- appliance,
- HomeConnectBinarySensorEntityDescription(
- key=StatusKey.BSH_COMMON_DOOR_STATE,
- device_class=BinarySensorDeviceClass.DOOR,
- boolean_map={
- BSH_DOOR_STATE_CLOSED: False,
- BSH_DOOR_STATE_LOCKED: False,
- BSH_DOOR_STATE_OPEN: True,
- },
- ),
- )
- self._attr_unique_id = f"{appliance.info.ha_id}-Door"
- self._attr_name = f"{appliance.info.name} Door"
+ def update_native_value(self) -> None:
+ """Set the native value of the binary sensor."""
+ self._attr_is_on = self.appliance.info.connected
- async def async_added_to_hass(self) -> None:
- """Call when entity is added to hass."""
- await super().async_added_to_hass()
- automations = automations_with_entity(self.hass, self.entity_id)
- scripts = scripts_with_entity(self.hass, self.entity_id)
- items = automations + scripts
- if not items:
- return
-
- entity_reg: er.EntityRegistry = er.async_get(self.hass)
- entity_automations = [
- automation_entity
- for automation_id in automations
- if (automation_entity := entity_reg.async_get(automation_id))
- ]
- entity_scripts = [
- script_entity
- for script_id in scripts
- if (script_entity := entity_reg.async_get(script_id))
- ]
-
- items_list = [
- f"- [{item.original_name}](/config/automation/edit/{item.unique_id})"
- for item in entity_automations
- ] + [
- f"- [{item.original_name}](/config/script/edit/{item.unique_id})"
- for item in entity_scripts
- ]
-
- async_create_issue(
- self.hass,
- DOMAIN,
- f"deprecated_binary_common_door_sensor_{self.entity_id}",
- breaks_in_ha_version="2025.5.0",
- is_fixable=False,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_binary_common_door_sensor",
- translation_placeholders={
- "entity": self.entity_id,
- "items": "\n".join(items_list),
- },
- )
-
- async def async_will_remove_from_hass(self) -> None:
- """Call when entity will be removed from hass."""
- await super().async_will_remove_from_hass()
- async_delete_issue(
- self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}"
- )
+ @property
+ def available(self) -> bool:
+ """Return the availability."""
+ return self.coordinator.last_update_success
diff --git a/homeassistant/components/home_connect/button.py b/homeassistant/components/home_connect/button.py
new file mode 100644
index 00000000000..0bd31c6b7c9
--- /dev/null
+++ b/homeassistant/components/home_connect/button.py
@@ -0,0 +1,156 @@
+"""Provides button entities for Home Connect."""
+
+from aiohomeconnect.model import CommandKey
+from aiohomeconnect.model.error import HomeConnectError
+
+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 .common import setup_home_connect_entry
+from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
+from .coordinator import (
+ HomeConnectApplianceData,
+ HomeConnectConfigEntry,
+ HomeConnectCoordinator,
+)
+from .entity import HomeConnectEntity
+from .utils import get_dict_from_home_connect_error
+
+PARALLEL_UPDATES = 1
+
+
+class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription):
+ """Describes Home Connect button entity."""
+
+ key: CommandKey
+
+
+COMMAND_BUTTONS = (
+ HomeConnectCommandButtonEntityDescription(
+ key=CommandKey.BSH_COMMON_OPEN_DOOR,
+ translation_key="open_door",
+ ),
+ HomeConnectCommandButtonEntityDescription(
+ key=CommandKey.BSH_COMMON_PARTLY_OPEN_DOOR,
+ translation_key="partly_open_door",
+ ),
+ HomeConnectCommandButtonEntityDescription(
+ key=CommandKey.BSH_COMMON_PAUSE_PROGRAM,
+ translation_key="pause_program",
+ ),
+ HomeConnectCommandButtonEntityDescription(
+ key=CommandKey.BSH_COMMON_RESUME_PROGRAM,
+ translation_key="resume_program",
+ ),
+)
+
+
+def _get_entities_for_appliance(
+ entry: HomeConnectConfigEntry,
+ appliance: HomeConnectApplianceData,
+) -> list[HomeConnectEntity]:
+ """Get a list of entities."""
+ entities: list[HomeConnectEntity] = []
+ entities.extend(
+ HomeConnectCommandButtonEntity(entry.runtime_data, appliance, description)
+ for description in COMMAND_BUTTONS
+ if description.key in appliance.commands
+ )
+ if appliance.info.type in APPLIANCES_WITH_PROGRAMS:
+ entities.append(
+ HomeConnectStopProgramButtonEntity(entry.runtime_data, appliance)
+ )
+
+ return entities
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: HomeConnectConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Home Connect button entities."""
+ setup_home_connect_entry(
+ entry,
+ _get_entities_for_appliance,
+ async_add_entities,
+ )
+
+
+class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity):
+ """Describes Home Connect button entity."""
+
+ entity_description: ButtonEntityDescription
+
+ def __init__(
+ self,
+ coordinator: HomeConnectCoordinator,
+ appliance: HomeConnectApplianceData,
+ desc: ButtonEntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(
+ coordinator,
+ appliance,
+ desc,
+ (appliance.info.ha_id,),
+ )
+
+ def update_native_value(self) -> None:
+ """Set the value of the entity."""
+
+
+class HomeConnectCommandButtonEntity(HomeConnectButtonEntity):
+ """Button entity for Home Connect commands."""
+
+ entity_description: HomeConnectCommandButtonEntityDescription
+
+ async def async_press(self) -> None:
+ """Press the button."""
+ try:
+ await self.coordinator.client.put_command(
+ self.appliance.info.ha_id,
+ command_key=self.entity_description.key,
+ value=True,
+ )
+ except HomeConnectError as error:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="execute_command",
+ translation_placeholders={
+ **get_dict_from_home_connect_error(error),
+ "command": self.entity_description.key,
+ },
+ ) from error
+
+
+class HomeConnectStopProgramButtonEntity(HomeConnectButtonEntity):
+ """Button entity for stopping a program."""
+
+ def __init__(
+ self,
+ coordinator: HomeConnectCoordinator,
+ appliance: HomeConnectApplianceData,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(
+ coordinator,
+ appliance,
+ ButtonEntityDescription(
+ key="StopProgram",
+ translation_key="stop_program",
+ ),
+ )
+
+ async def async_press(self) -> None:
+ """Press the button."""
+ try:
+ await self.coordinator.client.stop_program(self.appliance.info.ha_id)
+ except HomeConnectError as error:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="stop_program",
+ translation_placeholders=get_dict_from_home_connect_error(error),
+ ) from error
diff --git a/homeassistant/components/home_connect/common.py b/homeassistant/components/home_connect/common.py
index 6bd098a76fc..cd3fefad80c 100644
--- a/homeassistant/components/home_connect/common.py
+++ b/homeassistant/components/home_connect/common.py
@@ -1,15 +1,41 @@
"""Common callbacks for all Home Connect platforms."""
+from collections import defaultdict
from collections.abc import Callable
from functools import partial
from typing import cast
from aiohomeconnect.model import EventKey
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
-from .entity import HomeConnectEntity
+from .entity import HomeConnectEntity, HomeConnectOptionEntity
+
+
+def _create_option_entities(
+ entry: HomeConnectConfigEntry,
+ appliance: HomeConnectApplianceData,
+ known_entity_unique_ids: dict[str, str],
+ get_option_entities_for_appliance: Callable[
+ [HomeConnectConfigEntry, HomeConnectApplianceData],
+ list[HomeConnectOptionEntity],
+ ],
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Create the required option entities for the appliances."""
+ option_entities_to_add = [
+ entity
+ for entity in get_option_entities_for_appliance(entry, appliance)
+ if entity.unique_id not in known_entity_unique_ids
+ ]
+ known_entity_unique_ids.update(
+ {
+ cast(str, entity.unique_id): appliance.info.ha_id
+ for entity in option_entities_to_add
+ }
+ )
+ async_add_entities(option_entities_to_add)
def _handle_paired_or_connected_appliance(
@@ -18,7 +44,13 @@ def _handle_paired_or_connected_appliance(
get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
],
- async_add_entities: AddEntitiesCallback,
+ get_option_entities_for_appliance: Callable[
+ [HomeConnectConfigEntry, HomeConnectApplianceData],
+ list[HomeConnectOptionEntity],
+ ]
+ | None,
+ changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Handle a new paired appliance or an appliance that has been connected.
@@ -34,6 +66,33 @@ def _handle_paired_or_connected_appliance(
for entity in get_entities_for_appliance(entry, appliance)
if entity.unique_id not in known_entity_unique_ids
]
+ if get_option_entities_for_appliance:
+ entities_to_add.extend(
+ entity
+ for entity in get_option_entities_for_appliance(entry, appliance)
+ if entity.unique_id not in known_entity_unique_ids
+ )
+ for event_key in (
+ EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
+ EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
+ ):
+ changed_options_listener_remove_callback = (
+ entry.runtime_data.async_add_listener(
+ partial(
+ _create_option_entities,
+ entry,
+ appliance,
+ known_entity_unique_ids,
+ get_option_entities_for_appliance,
+ async_add_entities,
+ ),
+ (appliance.info.ha_id, event_key),
+ )
+ )
+ entry.async_on_unload(changed_options_listener_remove_callback)
+ changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
+ changed_options_listener_remove_callback
+ )
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
@@ -47,11 +106,17 @@ def _handle_paired_or_connected_appliance(
def _handle_depaired_appliance(
entry: HomeConnectConfigEntry,
known_entity_unique_ids: dict[str, str],
+ changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
) -> None:
"""Handle a removed appliance."""
for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items():
if appliance_id not in entry.runtime_data.data:
known_entity_unique_ids.pop(entity_unique_id, None)
+ if appliance_id in changed_options_listener_remove_callbacks:
+ for listener in changed_options_listener_remove_callbacks.pop(
+ appliance_id
+ ):
+ listener()
def setup_home_connect_entry(
@@ -59,22 +124,18 @@ def setup_home_connect_entry(
get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ get_option_entities_for_appliance: Callable[
+ [HomeConnectConfigEntry, HomeConnectApplianceData],
+ list[HomeConnectOptionEntity],
+ ]
+ | None = None,
) -> None:
"""Set up the callbacks for paired and depaired appliances."""
known_entity_unique_ids: dict[str, str] = {}
-
- entities: list[HomeConnectEntity] = []
- for appliance in entry.runtime_data.data.values():
- entities_to_add = get_entities_for_appliance(entry, appliance)
- known_entity_unique_ids.update(
- {
- cast(str, entity.unique_id): appliance.info.ha_id
- for entity in entities_to_add
- }
- )
- entities.extend(entities_to_add)
- async_add_entities(entities)
+ changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]] = (
+ defaultdict(list)
+ )
entry.async_on_unload(
entry.runtime_data.async_add_special_listener(
@@ -83,6 +144,8 @@ def setup_home_connect_entry(
entry,
known_entity_unique_ids,
get_entities_for_appliance,
+ get_option_entities_for_appliance,
+ changed_options_listener_remove_callbacks,
async_add_entities,
),
(
@@ -93,7 +156,12 @@ def setup_home_connect_entry(
)
entry.async_on_unload(
entry.runtime_data.async_add_special_listener(
- partial(_handle_depaired_appliance, entry, known_entity_unique_ids),
+ partial(
+ _handle_depaired_appliance,
+ entry,
+ known_entity_unique_ids,
+ changed_options_listener_remove_callbacks,
+ ),
(EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,),
)
)
diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py
index 127aa1ffe92..64bf4af29a4 100644
--- a/homeassistant/components/home_connect/const.py
+++ b/homeassistant/components/home_connect/const.py
@@ -1,9 +1,16 @@
"""Constants for the Home Connect integration."""
-from aiohomeconnect.model import EventKey, SettingKey, StatusKey
+from typing import cast
+
+from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey, StatusKey
+
+from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
+
+from .utils import bsh_key_to_translation_key
DOMAIN = "home_connect"
+API_DEFAULT_RETRY_AFTER = 60
APPLIANCES_WITH_PROGRAMS = (
"CleaningRobot",
@@ -17,6 +24,13 @@ APPLIANCES_WITH_PROGRAMS = (
"WasherDryer",
)
+UNIT_MAP = {
+ "seconds": UnitOfTime.SECONDS,
+ "ml": UnitOfVolume.MILLILITERS,
+ "°C": UnitOfTemperature.CELSIUS,
+ "°F": UnitOfTemperature.FAHRENHEIT,
+}
+
BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On"
BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off"
@@ -52,22 +66,283 @@ SERVICE_OPTION_SELECTED = "set_option_selected"
SERVICE_PAUSE_PROGRAM = "pause_program"
SERVICE_RESUME_PROGRAM = "resume_program"
SERVICE_SELECT_PROGRAM = "select_program"
+SERVICE_SET_PROGRAM_AND_OPTIONS = "set_program_and_options"
SERVICE_SETTING = "change_setting"
SERVICE_START_PROGRAM = "start_program"
-
+ATTR_AFFECTS_TO = "affects_to"
ATTR_KEY = "key"
ATTR_PROGRAM = "program"
ATTR_UNIT = "unit"
ATTR_VALUE = "value"
+AFFECTS_TO_ACTIVE_PROGRAM = "active_program"
+AFFECTS_TO_SELECTED_PROGRAM = "selected_program"
-SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity"
-SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name"
-SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id"
-SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program"
-SVE_TRANSLATION_PLACEHOLDER_KEY = "key"
-SVE_TRANSLATION_PLACEHOLDER_VALUE = "value"
+
+TRANSLATION_KEYS_PROGRAMS_MAP = {
+ bsh_key_to_translation_key(program.value): cast(ProgramKey, program)
+ for program in ProgramKey
+ if program != ProgramKey.UNKNOWN
+}
+
+PROGRAMS_TRANSLATION_KEYS_MAP = {
+ value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
+}
+
+AVAILABLE_MAPS_ENUM = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap",
+ "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map1",
+ "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map2",
+ "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.Map3",
+ )
+}
+
+CLEANING_MODE_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Silent",
+ "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Standard",
+ "ConsumerProducts.CleaningRobot.EnumType.CleaningModes.Power",
+ )
+}
+
+BEAN_AMOUNT_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryMild",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Mild",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.MildPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Normal",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.NormalPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.Strong",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.StrongPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrong",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.VeryStrongPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.ExtraStrong",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShot",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.DoubleShotPlusPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShot",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.TripleShotPlus",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanAmount.CoffeeGround",
+ )
+}
+
+COFFEE_TEMPERATURE_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.88C",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.90C",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.92C",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.94C",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.95C",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeTemperature.96C",
+ )
+}
+
+BEAN_CONTAINER_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Right",
+ "ConsumerProducts.CoffeeMaker.EnumType.BeanContainerSelection.Left",
+ )
+}
+
+FLOW_RATE_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Normal",
+ "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.Intense",
+ "ConsumerProducts.CoffeeMaker.EnumType.FlowRate.IntensePlus",
+ )
+}
+
+COFFEE_MILK_RATIO_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.10Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.20Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.25Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.30Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.40Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.50Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.55Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.60Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.65Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.67Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.70Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.75Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.80Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.85Percent",
+ "ConsumerProducts.CoffeeMaker.EnumType.CoffeeMilkRatio.90Percent",
+ )
+}
+
+HOT_WATER_TEMPERATURE_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.WhiteTea",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.GreenTea",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.BlackTea",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.50C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.55C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.60C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.65C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.70C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.75C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.80C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.85C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.90C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.95C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.97C",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.122F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.131F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.140F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.149F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.158F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.167F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.176F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.185F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.194F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.203F",
+ "ConsumerProducts.CoffeeMaker.EnumType.HotWaterTemperature.Max",
+ )
+}
+
+DRYING_TARGET_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "LaundryCare.Dryer.EnumType.DryingTarget.IronDry",
+ "LaundryCare.Dryer.EnumType.DryingTarget.GentleDry",
+ "LaundryCare.Dryer.EnumType.DryingTarget.CupboardDry",
+ "LaundryCare.Dryer.EnumType.DryingTarget.CupboardDryPlus",
+ "LaundryCare.Dryer.EnumType.DryingTarget.ExtraDry",
+ )
+}
+
+VENTING_LEVEL_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "Cooking.Hood.EnumType.Stage.FanOff",
+ "Cooking.Hood.EnumType.Stage.FanStage01",
+ "Cooking.Hood.EnumType.Stage.FanStage02",
+ "Cooking.Hood.EnumType.Stage.FanStage03",
+ "Cooking.Hood.EnumType.Stage.FanStage04",
+ "Cooking.Hood.EnumType.Stage.FanStage05",
+ )
+}
+
+INTENSIVE_LEVEL_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "Cooking.Hood.EnumType.IntensiveStage.IntensiveStageOff",
+ "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage1",
+ "Cooking.Hood.EnumType.IntensiveStage.IntensiveStage2",
+ )
+}
+
+WARMING_LEVEL_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "Cooking.Oven.EnumType.WarmingLevel.Low",
+ "Cooking.Oven.EnumType.WarmingLevel.Medium",
+ "Cooking.Oven.EnumType.WarmingLevel.High",
+ )
+}
+
+TEMPERATURE_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "LaundryCare.Washer.EnumType.Temperature.Cold",
+ "LaundryCare.Washer.EnumType.Temperature.GC20",
+ "LaundryCare.Washer.EnumType.Temperature.GC30",
+ "LaundryCare.Washer.EnumType.Temperature.GC40",
+ "LaundryCare.Washer.EnumType.Temperature.GC50",
+ "LaundryCare.Washer.EnumType.Temperature.GC60",
+ "LaundryCare.Washer.EnumType.Temperature.GC70",
+ "LaundryCare.Washer.EnumType.Temperature.GC80",
+ "LaundryCare.Washer.EnumType.Temperature.GC90",
+ "LaundryCare.Washer.EnumType.Temperature.UlCold",
+ "LaundryCare.Washer.EnumType.Temperature.UlWarm",
+ "LaundryCare.Washer.EnumType.Temperature.UlHot",
+ "LaundryCare.Washer.EnumType.Temperature.UlExtraHot",
+ )
+}
+
+SPIN_SPEED_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "LaundryCare.Washer.EnumType.SpinSpeed.Off",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM400",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM600",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM700",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM800",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM900",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM1000",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM1200",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM1400",
+ "LaundryCare.Washer.EnumType.SpinSpeed.RPM1600",
+ "LaundryCare.Washer.EnumType.SpinSpeed.UlOff",
+ "LaundryCare.Washer.EnumType.SpinSpeed.UlLow",
+ "LaundryCare.Washer.EnumType.SpinSpeed.UlMedium",
+ "LaundryCare.Washer.EnumType.SpinSpeed.UlHigh",
+ )
+}
+
+VARIO_PERFECT_OPTIONS = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "LaundryCare.Common.EnumType.VarioPerfect.Off",
+ "LaundryCare.Common.EnumType.VarioPerfect.EcoPerfect",
+ "LaundryCare.Common.EnumType.VarioPerfect.SpeedPerfect",
+ )
+}
+
+
+PROGRAM_ENUM_OPTIONS = {
+ bsh_key_to_translation_key(option_key): (
+ option_key,
+ options,
+ )
+ for option_key, options in (
+ (
+ OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
+ AVAILABLE_MAPS_ENUM,
+ ),
+ (
+ OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,
+ CLEANING_MODE_OPTIONS,
+ ),
+ (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT, BEAN_AMOUNT_OPTIONS),
+ (
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE,
+ COFFEE_TEMPERATURE_OPTIONS,
+ ),
+ (
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION,
+ BEAN_CONTAINER_OPTIONS,
+ ),
+ (OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE, FLOW_RATE_OPTIONS),
+ (
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO,
+ COFFEE_MILK_RATIO_OPTIONS,
+ ),
+ (
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE,
+ HOT_WATER_TEMPERATURE_OPTIONS,
+ ),
+ (OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET, DRYING_TARGET_OPTIONS),
+ (OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL, VENTING_LEVEL_OPTIONS),
+ (OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL, INTENSIVE_LEVEL_OPTIONS),
+ (OptionKey.COOKING_OVEN_WARMING_LEVEL, WARMING_LEVEL_OPTIONS),
+ (OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, TEMPERATURE_OPTIONS),
+ (OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED, SPIN_SPEED_OPTIONS),
+ (OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT, VARIO_PERFECT_OPTIONS),
+ )
+}
OLD_NEW_UNIQUE_ID_SUFFIX_MAP = {
diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py
index da47d8ec91c..ab09989e200 100644
--- a/homeassistant/components/home_connect/coordinator.py
+++ b/homeassistant/components/home_connect/coordinator.py
@@ -2,21 +2,25 @@
from __future__ import annotations
-import asyncio
+from asyncio import sleep as asyncio_sleep
from collections import defaultdict
from collections.abc import Callable
+from contextlib import suppress
from dataclasses import dataclass
import logging
-from typing import Any
+from typing import Any, cast
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import (
+ CommandKey,
Event,
EventKey,
EventMessage,
EventType,
GetSetting,
HomeAppliance,
+ OptionKey,
+ ProgramKey,
SettingKey,
Status,
StatusKey,
@@ -26,46 +30,66 @@ from aiohomeconnect.model.error import (
HomeConnectApiError,
HomeConnectError,
HomeConnectRequestError,
+ TooManyRequestsError,
UnauthorizedError,
)
-from aiohomeconnect.model.program import EnumerateProgram
+from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption
from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
-from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers import device_registry as dr
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
+from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
-type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
+MAX_EXECUTIONS_TIME_WINDOW = 15 * 60 # 15 minutes
+MAX_EXECUTIONS = 5
-EVENT_STREAM_RECONNECT_DELAY = 30
+type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
@dataclass(frozen=True, kw_only=True)
class HomeConnectApplianceData:
"""Class to hold Home Connect appliance data."""
+ commands: set[CommandKey]
events: dict[EventKey, Event]
info: HomeAppliance
+ options: dict[OptionKey, ProgramDefinitionOption]
programs: list[EnumerateProgram]
settings: dict[SettingKey, GetSetting]
status: dict[StatusKey, Status]
def update(self, other: HomeConnectApplianceData) -> None:
"""Update data with data from other instance."""
+ self.commands.update(other.commands)
self.events.update(other.events)
self.info.connected = other.info.connected
+ self.options.clear()
+ self.options.update(other.options)
self.programs.clear()
self.programs.extend(other.programs)
self.settings.update(other.settings)
self.status.update(other.status)
+ @classmethod
+ def empty(cls, appliance: HomeAppliance) -> HomeConnectApplianceData:
+ """Return empty data."""
+ return cls(
+ commands=set(),
+ events={},
+ info=appliance,
+ options={},
+ programs=[],
+ settings={},
+ status={},
+ )
+
class HomeConnectCoordinator(
DataUpdateCoordinator[dict[str, HomeConnectApplianceData]]
@@ -92,6 +116,8 @@ class HomeConnectCoordinator(
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
] = {}
self.device_registry = dr.async_get(self.hass)
+ self.data = {}
+ self._execution_tracker: dict[str, list[float]] = defaultdict(list)
@cached_property
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
@@ -111,8 +137,11 @@ class HomeConnectCoordinator(
self.__dict__.pop("context_listeners", None)
def remove_listener_and_invalidate_context_listeners() -> None:
- remove_listener()
- self.__dict__.pop("context_listeners", None)
+ # There are cases where the remove_listener will be called
+ # although it has been already removed somewhere else
+ with suppress(KeyError):
+ remove_listener()
+ self.__dict__.pop("context_listeners", None)
return remove_listener_and_invalidate_context_listeners
@@ -147,12 +176,22 @@ class HomeConnectCoordinator(
f"home_connect-events_listener_task-{self.config_entry.entry_id}",
)
- async def _event_listener(self) -> None:
+ async def _event_listener(self) -> None: # noqa: C901
"""Match event with listener for event type."""
+ retry_time = 10
while True:
try:
async for event_message in self.client.stream_all_events():
+ retry_time = 10
event_message_ha_id = event_message.ha_id
+ if (
+ event_message_ha_id in self.data
+ and not self.data[event_message_ha_id].info.connected
+ ):
+ self.data[event_message_ha_id].info.connected = True
+ self._call_all_event_listeners_for_appliance(
+ event_message_ha_id
+ )
match event_message.type:
case EventType.STATUS:
statuses = self.data[event_message_ha_id].status
@@ -172,8 +211,9 @@ class HomeConnectCoordinator(
settings = self.data[event_message_ha_id].settings
events = self.data[event_message_ha_id].events
for event in event_message.data.items:
- if event.key in SettingKey:
- setting_key = SettingKey(event.key)
+ event_key = event.key
+ if event_key in SettingKey.__members__.values(): # type: ignore[comparison-overlap]
+ setting_key = SettingKey(event_key)
if setting_key in settings:
settings[setting_key].value = event.value
else:
@@ -183,7 +223,16 @@ class HomeConnectCoordinator(
value=event.value,
)
else:
- events[event.key] = event
+ if event_key in (
+ EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
+ EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
+ ):
+ await self.update_options(
+ event_message_ha_id,
+ event_key,
+ ProgramKey(cast(str, event.value)),
+ )
+ events[event_key] = event
self._call_event_listener(event_message)
case EventType.EVENT:
@@ -193,6 +242,9 @@ class HomeConnectCoordinator(
self._call_event_listener(event_message)
case EventType.CONNECTED | EventType.PAIRED:
+ if self.refreshed_too_often_recently(event_message_ha_id):
+ continue
+
appliance_info = await self.client.get_specific_appliance(
event_message_ha_id
)
@@ -200,19 +252,17 @@ class HomeConnectCoordinator(
appliance_data = await self._get_appliance_data(
appliance_info, self.data.get(appliance_info.ha_id)
)
- if event_message_ha_id in self.data:
- self.data[event_message_ha_id].update(appliance_data)
- else:
+ if event_message_ha_id not in self.data:
self.data[event_message_ha_id] = appliance_data
- for listener, context in list(
- self._special_listeners.values()
- ) + list(self._listeners.values()):
- assert isinstance(context, tuple)
+ for listener, context in self._special_listeners.values():
if (
EventKey.BSH_COMMON_APPLIANCE_DEPAIRED
not in context
):
listener()
+ self._call_all_event_listeners_for_appliance(
+ event_message_ha_id
+ )
case EventType.DISCONNECTED:
self.data[event_message_ha_id].info.connected = False
@@ -238,23 +288,21 @@ class HomeConnectCoordinator(
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
_LOGGER.debug(
"Non-breaking error (%s) while listening for events,"
- " continuing in 30 seconds",
- type(error).__name__,
+ " continuing in %s seconds",
+ error,
+ retry_time,
)
- await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY)
+ await asyncio_sleep(retry_time)
+ retry_time = min(retry_time * 2, 3600)
except HomeConnectApiError as error:
_LOGGER.error("Error while listening for events: %s", error)
self.hass.config_entries.async_schedule_reload(
self.config_entry.entry_id
)
break
- # if there was a non-breaking error, we continue listening
- # but we need to refresh the data to get the possible changes
- # that happened while the event stream was interrupted
- await self.async_refresh()
@callback
- def _call_event_listener(self, event_message: EventMessage):
+ def _call_event_listener(self, event_message: EventMessage) -> None:
"""Call listener for event."""
for event in event_message.data.items:
for listener in self.context_listeners.get(
@@ -263,13 +311,49 @@ class HomeConnectCoordinator(
listener()
@callback
- def _call_all_event_listeners_for_appliance(self, ha_id: str):
+ def _call_all_event_listeners_for_appliance(self, ha_id: str) -> None:
for listener, context in self._listeners.values():
if isinstance(context, tuple) and context[0] == ha_id:
listener()
async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
"""Fetch data from Home Connect."""
+ await self._async_setup()
+
+ for appliance_data in self.data.values():
+ appliance = appliance_data.info
+ ha_id = appliance.ha_id
+ while True:
+ try:
+ self.data[ha_id] = await self._get_appliance_data(
+ appliance, self.data.get(ha_id)
+ )
+ except TooManyRequestsError as err:
+ _LOGGER.debug(
+ "Rate limit exceeded on initial fetch: %s",
+ err,
+ )
+ await asyncio_sleep(err.retry_after or API_DEFAULT_RETRY_AFTER)
+ else:
+ break
+
+ for listener, context in self._special_listeners.values():
+ assert isinstance(context, tuple)
+ if EventKey.BSH_COMMON_APPLIANCE_PAIRED in context:
+ listener()
+
+ return self.data
+
+ async def async_setup(self) -> None:
+ """Set up the devices."""
+ try:
+ await self._async_setup()
+ except UpdateFailed as err:
+ raise ConfigEntryNotReady from err
+
+ async def _async_setup(self) -> None:
+ """Set up the devices."""
+ old_appliances = set(self.data.keys())
try:
appliances = await self.client.get_home_appliances()
except UnauthorizedError as error:
@@ -279,18 +363,45 @@ class HomeConnectCoordinator(
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
except HomeConnectError as error:
+ for appliance_data in self.data.values():
+ appliance_data.info.connected = False
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="fetch_api_error",
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
- return {
- appliance.ha_id: await self._get_appliance_data(
- appliance, self.data.get(appliance.ha_id) if self.data else None
+ for appliance in appliances.homeappliances:
+ self.device_registry.async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ identifiers={(DOMAIN, appliance.ha_id)},
+ manufacturer=appliance.brand,
+ name=appliance.name,
+ model=appliance.vib,
)
- for appliance in appliances.homeappliances
- }
+ if appliance.ha_id not in self.data:
+ self.data[appliance.ha_id] = HomeConnectApplianceData.empty(appliance)
+ else:
+ self.data[appliance.ha_id].info.connected = appliance.connected
+ old_appliances.remove(appliance.ha_id)
+
+ for ha_id in old_appliances:
+ self.data.pop(ha_id, None)
+ device = self.device_registry.async_get_device(
+ identifiers={(DOMAIN, ha_id)}
+ )
+ if device:
+ self.device_registry.async_update_device(
+ device_id=device.id,
+ remove_config_entry_id=self.config_entry.entry_id,
+ )
+
+ # Trigger to delete the possible depaired device entities
+ # from known_entities variable at common.py
+ for listener, context in self._special_listeners.values():
+ assert isinstance(context, tuple)
+ if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
+ listener()
async def _get_appliance_data(
self,
@@ -305,6 +416,15 @@ class HomeConnectCoordinator(
name=appliance.name,
model=appliance.vib,
)
+ if not appliance.connected:
+ _LOGGER.debug(
+ "Appliance %s is not connected, skipping data fetch",
+ appliance.ha_id,
+ )
+ if appliance_data_to_update:
+ appliance_data_to_update.info.connected = False
+ return appliance_data_to_update
+ return HomeConnectApplianceData.empty(appliance)
try:
settings = {
setting.key: setting
@@ -312,13 +432,13 @@ class HomeConnectCoordinator(
await self.client.get_settings(appliance.ha_id)
).settings
}
+ except TooManyRequestsError:
+ raise
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching settings for %s: %s",
appliance.ha_id,
- error
- if isinstance(error, HomeConnectApiError)
- else type(error).__name__,
+ error,
)
settings = {}
try:
@@ -326,40 +446,43 @@ class HomeConnectCoordinator(
status.key: status
for status in (await self.client.get_status(appliance.ha_id)).status
}
+ except TooManyRequestsError:
+ raise
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching status for %s: %s",
appliance.ha_id,
- error
- if isinstance(error, HomeConnectApiError)
- else type(error).__name__,
+ error,
)
status = {}
programs = []
events = {}
+ options = {}
if appliance.type in APPLIANCES_WITH_PROGRAMS:
try:
all_programs = await self.client.get_all_programs(appliance.ha_id)
+ except TooManyRequestsError:
+ raise
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching programs for %s: %s",
appliance.ha_id,
- error
- if isinstance(error, HomeConnectApiError)
- else type(error).__name__,
+ error,
)
else:
programs.extend(all_programs.programs)
+ current_program_key = None
+ program_options = None
for program, event_key in (
- (
- all_programs.active,
- EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
- ),
(
all_programs.selected,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
),
+ (
+ all_programs.active,
+ EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
+ ),
):
if program and program.key:
events[event_key] = Event(
@@ -370,10 +493,43 @@ class HomeConnectCoordinator(
"",
program.key,
)
+ current_program_key = program.key
+ program_options = program.options
+ if current_program_key:
+ options = await self.get_options_definitions(
+ appliance.ha_id, current_program_key
+ )
+ for option in program_options or []:
+ option_event_key = EventKey(option.key)
+ events[option_event_key] = Event(
+ option_event_key,
+ option.key,
+ 0,
+ "",
+ "",
+ option.value,
+ option.name,
+ display_value=option.display_value,
+ unit=option.unit,
+ )
+
+ try:
+ commands = {
+ command.key
+ for command in (
+ await self.client.get_available_commands(appliance.ha_id)
+ ).commands
+ }
+ except TooManyRequestsError:
+ raise
+ except HomeConnectError:
+ commands = set()
appliance_data = HomeConnectApplianceData(
+ commands=commands,
events=events,
info=appliance,
+ options=options,
programs=programs,
settings=settings,
status=status,
@@ -383,3 +539,118 @@ class HomeConnectCoordinator(
appliance_data = appliance_data_to_update
return appliance_data
+
+ async def get_options_definitions(
+ self, ha_id: str, program_key: ProgramKey
+ ) -> dict[OptionKey, ProgramDefinitionOption]:
+ """Get options with constraints for appliance."""
+ if program_key is ProgramKey.UNKNOWN:
+ return {}
+ try:
+ return {
+ option.key: option
+ for option in (
+ await self.client.get_available_program(
+ ha_id, program_key=program_key
+ )
+ ).options
+ or []
+ }
+ except TooManyRequestsError:
+ raise
+ except HomeConnectError as error:
+ _LOGGER.debug(
+ "Error fetching options for %s: %s",
+ ha_id,
+ error,
+ )
+ return {}
+
+ async def update_options(
+ self, ha_id: str, event_key: EventKey, program_key: ProgramKey
+ ) -> None:
+ """Update options for appliance."""
+ options = self.data[ha_id].options
+ events = self.data[ha_id].events
+ options_to_notify = options.copy()
+ options.clear()
+ options.update(await self.get_options_definitions(ha_id, program_key))
+
+ for option in options.values():
+ option_value = option.constraints.default if option.constraints else None
+ if option_value is not None:
+ option_event_key = EventKey(option.key)
+ events[option_event_key] = Event(
+ option_event_key,
+ option.key.value,
+ 0,
+ "",
+ "",
+ option_value,
+ option.name,
+ unit=option.unit,
+ )
+ options_to_notify.update(options)
+ for option_key in options_to_notify:
+ for listener in self.context_listeners.get(
+ (ha_id, EventKey(option_key)),
+ [],
+ ):
+ listener()
+
+ def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool:
+ """Check if the appliance data hasn't been refreshed too often recently."""
+
+ now = self.hass.loop.time()
+ if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS:
+ return True
+
+ execution_tracker = self._execution_tracker[appliance_ha_id] = [
+ timestamp
+ for timestamp in self._execution_tracker[appliance_ha_id]
+ if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW
+ ]
+
+ execution_tracker.append(now)
+
+ if len(execution_tracker) >= MAX_EXECUTIONS:
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ f"home_connect_too_many_connected_paired_events_{appliance_ha_id}",
+ is_fixable=True,
+ is_persistent=True,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="home_connect_too_many_connected_paired_events",
+ data={
+ "entry_id": self.config_entry.entry_id,
+ "appliance_ha_id": appliance_ha_id,
+ },
+ translation_placeholders={
+ "appliance_name": self.data[appliance_ha_id].info.name,
+ "times": str(MAX_EXECUTIONS),
+ "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60),
+ "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/",
+ "home_assistant_core_new_issue_url": (
+ "https://github.com/home-assistant/core/issues/new?template=bug_report.yml"
+ f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/"
+ ),
+ },
+ )
+ return True
+
+ return False
+
+ async def reset_execution_tracker(self, appliance_ha_id: str) -> None:
+ """Reset the execution tracker for a specific appliance."""
+ self._execution_tracker.pop(appliance_ha_id, None)
+ appliance_info = await self.client.get_specific_appliance(appliance_ha_id)
+
+ appliance_data = await self._get_appliance_data(
+ appliance_info, self.data.get(appliance_info.ha_id)
+ )
+ self.data[appliance_ha_id].update(appliance_data)
+ for listener, context in self._special_listeners.values():
+ if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED not in context:
+ listener()
+ self._call_all_event_listeners_for_appliance(appliance_ha_id)
diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py
index 8eb9d757f14..facb3b14a9b 100644
--- a/homeassistant/components/home_connect/entity.py
+++ b/homeassistant/components/home_connect/entity.py
@@ -1,17 +1,30 @@
"""Home Connect entity base class."""
from abc import abstractmethod
+from collections.abc import Callable, Coroutine
+import contextlib
+from datetime import datetime
import logging
+from typing import Any, Concatenate, cast
-from aiohomeconnect.model import EventKey
+from aiohomeconnect.model import EventKey, OptionKey
+from aiohomeconnect.model.error import (
+ ActiveProgramNotSetError,
+ HomeConnectError,
+ TooManyRequestsError,
+)
+from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DOMAIN
+from .const import API_DEFAULT_RETRY_AFTER, DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
+from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
@@ -27,9 +40,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
desc: EntityDescription,
+ context_override: Any | None = None,
) -> None:
"""Initialize the entity."""
- super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key)))
+ context = (appliance.info.ha_id, EventKey(desc.key))
+ if context_override is not None:
+ context = context_override
+ super().__init__(coordinator, context)
self.appliance = appliance
self.entity_description = desc
self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}"
@@ -46,17 +63,109 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_native_value()
+ available = self._attr_available = self.appliance.info.connected
self.async_write_ha_state()
- _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
+ state = STATE_UNAVAILABLE if not available else self.state
+ _LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
@property
def bsh_key(self) -> str:
"""Return the BSH key."""
return self.entity_description.key
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available.
+
+ Do not use self.last_update_success for available state
+ as event updates should take precedence over the coordinator
+ refresh.
+ """
+ return self._attr_available
+
+
+class HomeConnectOptionEntity(HomeConnectEntity):
+ """Class for entities that represents program options."""
+
@property
def available(self) -> bool:
"""Return True if entity is available."""
- return (
- self.appliance.info.connected and self._attr_available and super().available
- )
+ return super().available and self.bsh_key in self.appliance.options
+
+ @property
+ def option_value(self) -> str | int | float | bool | None:
+ """Return the state of the entity."""
+ if event := self.appliance.events.get(EventKey(self.bsh_key)):
+ return event.value
+ return None
+
+ async def async_set_option(self, value: str | float | bool) -> None:
+ """Set an option for the entity."""
+ try:
+ # We try to set the active program option first,
+ # if it fails we try to set the selected program option
+ with contextlib.suppress(ActiveProgramNotSetError):
+ await self.coordinator.client.set_active_program_option(
+ self.appliance.info.ha_id,
+ option_key=self.bsh_key,
+ value=value,
+ )
+ _LOGGER.debug(
+ "Updated %s for the active program, new state: %s",
+ self.entity_id,
+ self.state,
+ )
+ return
+
+ await self.coordinator.client.set_selected_program_option(
+ self.appliance.info.ha_id,
+ option_key=self.bsh_key,
+ value=value,
+ )
+ _LOGGER.debug(
+ "Updated %s for the selected program, new state: %s",
+ self.entity_id,
+ self.state,
+ )
+ except HomeConnectError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_option",
+ translation_placeholders=get_dict_from_home_connect_error(err),
+ ) from err
+
+ @property
+ def bsh_key(self) -> OptionKey:
+ """Return the BSH key."""
+ return cast(OptionKey, self.entity_description.key)
+
+
+def constraint_fetcher[_EntityT: HomeConnectEntity, **_P](
+ func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
+) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
+ """Decorate the function to catch Home Connect too many requests error and retry later.
+
+ If it needs to be called later, it will call async_write_ha_state function
+ """
+
+ async def handler_to_return(
+ self: _EntityT, *args: _P.args, **kwargs: _P.kwargs
+ ) -> None:
+ async def handler(_datetime: datetime | None = None) -> None:
+ try:
+ await func(self, *args, **kwargs)
+ except TooManyRequestsError as err:
+ if (retry_after := err.retry_after) is None:
+ retry_after = API_DEFAULT_RETRY_AFTER
+ async_call_later(self.hass, retry_after, handler)
+ except HomeConnectError as err:
+ _LOGGER.error(
+ "Error fetching constraints for %s: %s", self.entity_id, err
+ )
+ else:
+ if _datetime is not None:
+ self.async_write_ha_state()
+
+ await handler()
+
+ return handler_to_return
diff --git a/homeassistant/components/home_connect/icons.json b/homeassistant/components/home_connect/icons.json
index 166b2fe2c34..9b4c9276998 100644
--- a/homeassistant/components/home_connect/icons.json
+++ b/homeassistant/components/home_connect/icons.json
@@ -18,6 +18,9 @@
"set_option_selected": {
"service": "mdi:gesture-tap"
},
+ "set_program_and_options": {
+ "service": "mdi:form-select"
+ },
"change_setting": {
"service": "mdi:cog"
}
@@ -46,6 +49,23 @@
"default": "mdi:map-marker-remove-variant"
}
},
+ "button": {
+ "open_door": {
+ "default": "mdi:door-open"
+ },
+ "partly_open_door": {
+ "default": "mdi:door-open"
+ },
+ "pause_program": {
+ "default": "mdi:pause"
+ },
+ "resume_program": {
+ "default": "mdi:play"
+ },
+ "stop_program": {
+ "default": "mdi:stop"
+ }
+ },
"sensor": {
"operation_state": {
"default": "mdi:state-machine",
@@ -93,7 +113,7 @@
"milk_counter": {
"default": "mdi:cup"
},
- "coffee_and_milk": {
+ "coffee_and_milk_counter": {
"default": "mdi:coffee"
},
"ristretto_espresso_counter": {
@@ -205,6 +225,39 @@
},
"door-assistant_freezer": {
"default": "mdi:door"
+ },
+ "silence_on_demand": {
+ "default": "mdi:volume-mute",
+ "state": {
+ "on": "mdi:volume-mute",
+ "off": "mdi:volume-high"
+ }
+ },
+ "half_load": {
+ "default": "mdi:fraction-one-half"
+ },
+ "hygiene_plus": {
+ "default": "mdi:silverware-clean"
+ },
+ "eco_dry": {
+ "default": "mdi:sprout"
+ },
+ "fast_pre_heat": {
+ "default": "mdi:fire"
+ },
+ "i_dos_1_active": {
+ "default": "mdi:numeric-1-circle"
+ },
+ "i_dos_2_active": {
+ "default": "mdi:numeric-2-circle"
+ }
+ },
+ "time": {
+ "start_in_relative": {
+ "default": "mdi:progress-clock"
+ },
+ "finish_in_relative": {
+ "default": "mdi:progress-clock"
}
}
}
diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py
index 05c154d9153..de55a60bd43 100644
--- a/homeassistant/components/home_connect/light.py
+++ b/homeassistant/components/home_connect/light.py
@@ -17,15 +17,11 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .common import setup_home_connect_entry
-from .const import (
- BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
- DOMAIN,
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
-)
+from .const import BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, DOMAIN
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
@@ -36,6 +32,8 @@ from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 1
+
@dataclass(frozen=True, kw_only=True)
class HomeConnectLightEntityDescription(LightEntityDescription):
@@ -94,7 +92,7 @@ def _get_entities_for_appliance(
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect light."""
setup_home_connect_entry(
@@ -162,7 +160,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
translation_key="turn_on_light",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
+ "entity_id": self.entity_id,
},
) from err
if self._color_key and self._custom_color_key:
@@ -181,7 +179,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
translation_key="select_light_custom_color",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
+ "entity_id": self.entity_id,
},
) from err
@@ -199,7 +197,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
translation_key="set_light_color",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
+ "entity_id": self.entity_id,
},
) from err
return
@@ -209,11 +207,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
brightness = round(
color_util.brightness_to_value(
self._brightness_scale,
- kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness),
+ cast(int, kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness)),
)
)
- hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color)
+ hs_color = cast(
+ tuple[float, float], kwargs.get(ATTR_HS_COLOR, self._attr_hs_color)
+ )
rgb = color_util.color_hsv_to_RGB(hs_color[0], hs_color[1], brightness)
hex_val = color_util.color_rgb_to_hex(*rgb)
@@ -229,7 +229,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
translation_key="set_light_color",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
+ "entity_id": self.entity_id,
},
) from err
return
@@ -252,7 +252,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
translation_key="set_light_brightness",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
+ "entity_id": self.entity_id,
},
) from err
@@ -270,7 +270,7 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
translation_key="turn_off_light",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
+ "entity_id": self.entity_id,
},
) from err
diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json
index 94085af2fc3..c5e277c4974 100644
--- a/homeassistant/components/home_connect/manifest.json
+++ b/homeassistant/components/home_connect/manifest.json
@@ -3,10 +3,10 @@
"name": "Home Connect",
"codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"],
"config_flow": true,
- "dependencies": ["application_credentials"],
+ "dependencies": ["application_credentials", "repairs"],
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
- "requirements": ["aiohomeconnect==0.12.3"],
+ "requirements": ["aiohomeconnect==0.17.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/home_connect/number.py b/homeassistant/components/home_connect/number.py
index aa0c4e4ae3f..1bb793f4015 100644
--- a/homeassistant/components/home_connect/number.py
+++ b/homeassistant/components/home_connect/number.py
@@ -1,9 +1,9 @@
-"""Provides number enties for Home Connect."""
+"""Provides number entities for Home Connect."""
import logging
from typing import cast
-from aiohomeconnect.model import GetSetting, SettingKey
+from aiohomeconnect.model import GetSetting, OptionKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
from homeassistant.components.number import (
@@ -13,24 +13,24 @@ from homeassistant.components.number import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
-from .const import (
- DOMAIN,
- SVE_TRANSLATION_KEY_SET_SETTING,
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
- SVE_TRANSLATION_PLACEHOLDER_KEY,
- SVE_TRANSLATION_PLACEHOLDER_VALUE,
-)
+from .const import DOMAIN, UNIT_MAP
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
-from .entity import HomeConnectEntity
+from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 1
NUMBERS = (
+ NumberEntityDescription(
+ key=SettingKey.BSH_COMMON_ALARM_CLOCK,
+ device_class=NumberDeviceClass.DURATION,
+ translation_key="alarm_clock",
+ ),
NumberEntityDescription(
key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,
device_class=NumberDeviceClass.TEMPERATURE,
@@ -76,6 +76,47 @@ NUMBERS = (
device_class=NumberDeviceClass.TEMPERATURE,
translation_key="wine_compartment_3_setpoint_temperature",
),
+ NumberEntityDescription(
+ key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE_PERCENT,
+ translation_key="color_temperature_percent",
+ native_unit_of_measurement="%",
+ ),
+ NumberEntityDescription(
+ key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL,
+ device_class=NumberDeviceClass.VOLUME,
+ translation_key="washer_i_dos_1_base_level",
+ ),
+ NumberEntityDescription(
+ key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_2_BASE_LEVEL,
+ device_class=NumberDeviceClass.VOLUME,
+ translation_key="washer_i_dos_2_base_level",
+ ),
+)
+
+NUMBER_OPTIONS = (
+ NumberEntityDescription(
+ key=OptionKey.BSH_COMMON_DURATION,
+ translation_key="duration",
+ ),
+ NumberEntityDescription(
+ key=OptionKey.BSH_COMMON_FINISH_IN_RELATIVE,
+ translation_key="finish_in_relative",
+ ),
+ NumberEntityDescription(
+ key=OptionKey.BSH_COMMON_START_IN_RELATIVE,
+ translation_key="start_in_relative",
+ ),
+ NumberEntityDescription(
+ key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY,
+ translation_key="fill_quantity",
+ device_class=NumberDeviceClass.VOLUME,
+ native_step=1,
+ ),
+ NumberEntityDescription(
+ key=OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE,
+ translation_key="setpoint_temperature",
+ device_class=NumberDeviceClass.TEMPERATURE,
+ ),
)
@@ -91,16 +132,29 @@ def _get_entities_for_appliance(
]
+def _get_option_entities_for_appliance(
+ entry: HomeConnectConfigEntry,
+ appliance: HomeConnectApplianceData,
+) -> list[HomeConnectOptionEntity]:
+ """Get a list of currently available option entities."""
+ return [
+ HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description)
+ for description in NUMBER_OPTIONS
+ if description.key in appliance.options
+ ]
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect number."""
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
+ _get_option_entities_for_appliance,
)
@@ -124,28 +178,34 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key=SVE_TRANSLATION_KEY_SET_SETTING,
+ translation_key="set_setting_entity",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
- SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key,
- SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
+ "entity_id": self.entity_id,
+ "key": self.bsh_key,
+ "value": str(value),
},
) from err
+ @constraint_fetcher
async def async_fetch_constraints(self) -> None:
"""Fetch the max and min values and step for the number entity."""
- try:
+ setting_key = cast(SettingKey, self.bsh_key)
+ data = self.appliance.settings.get(setting_key)
+ if not data or not data.unit or not data.constraints:
data = await self.coordinator.client.get_setting(
- self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key)
+ self.appliance.info.ha_id, setting_key=setting_key
)
- except HomeConnectError as err:
- _LOGGER.error("An error occurred: %s", err)
- else:
+ if data.unit:
+ self._attr_native_unit_of_measurement = data.unit
self.set_constraints(data)
def set_constraints(self, setting: GetSetting) -> None:
"""Set constraints for the number entity."""
+ if setting.unit:
+ self._attr_native_unit_of_measurement = UNIT_MAP.get(
+ setting.unit, setting.unit
+ )
if not (constraints := setting.constraints):
return
if constraints.max:
@@ -166,11 +226,51 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
"""When entity is added to hass."""
await super().async_added_to_hass()
data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
- self._attr_native_unit_of_measurement = data.unit
self.set_constraints(data)
if (
- not hasattr(self, "_attr_native_min_value")
+ not hasattr(self, "_attr_native_unit_of_measurement")
+ or not hasattr(self, "_attr_native_min_value")
or not hasattr(self, "_attr_native_max_value")
or not hasattr(self, "_attr_native_step")
):
await self.async_fetch_constraints()
+
+
+class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity):
+ """Number option class for Home Connect."""
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the native value of the entity."""
+ await self.async_set_option(value)
+
+ def update_native_value(self) -> None:
+ """Set the value of the entity."""
+ self._attr_native_value = cast(float | None, self.option_value)
+ option_definition = self.appliance.options.get(self.bsh_key)
+ if option_definition:
+ if option_definition.unit:
+ candidate_unit = UNIT_MAP.get(
+ option_definition.unit, option_definition.unit
+ )
+ if (
+ not hasattr(self, "_attr_native_unit_of_measurement")
+ or candidate_unit != self._attr_native_unit_of_measurement
+ ):
+ self._attr_native_unit_of_measurement = candidate_unit
+ option_constraints = option_definition.constraints
+ if option_constraints:
+ if (
+ not hasattr(self, "_attr_native_min_value")
+ or self._attr_native_min_value != option_constraints.min
+ ) and option_constraints.min:
+ self._attr_native_min_value = option_constraints.min
+ if (
+ not hasattr(self, "_attr_native_max_value")
+ or self._attr_native_max_value != option_constraints.max
+ ) and option_constraints.max:
+ self._attr_native_max_value = option_constraints.max
+ if (
+ not hasattr(self, "_attr_native_step")
+ or self._attr_native_step != option_constraints.step_size
+ ) and option_constraints.step_size:
+ self._attr_native_step = option_constraints.step_size
diff --git a/homeassistant/components/home_connect/repairs.py b/homeassistant/components/home_connect/repairs.py
new file mode 100644
index 00000000000..21c6775e549
--- /dev/null
+++ b/homeassistant/components/home_connect/repairs.py
@@ -0,0 +1,60 @@
+"""Repairs flows for Home Connect."""
+
+from typing import cast
+
+import voluptuous as vol
+
+from homeassistant import data_entry_flow
+from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import issue_registry as ir
+
+from .coordinator import HomeConnectConfigEntry
+
+
+class EnableApplianceUpdatesFlow(RepairsFlow):
+ """Handler for enabling appliance's updates after being refreshed too many times."""
+
+ async def async_step_init(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the first step of a fix flow."""
+ return await self.async_step_confirm()
+
+ async def async_step_confirm(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the confirm step of a fix flow."""
+ if user_input is not None:
+ assert self.data
+ entry = self.hass.config_entries.async_get_entry(
+ cast(str, self.data["entry_id"])
+ )
+ assert entry
+ entry = cast(HomeConnectConfigEntry, entry)
+ await entry.runtime_data.reset_execution_tracker(
+ cast(str, self.data["appliance_ha_id"])
+ )
+ return self.async_create_entry(data={})
+
+ issue_registry = ir.async_get(self.hass)
+ description_placeholders = None
+ if issue := issue_registry.async_get_issue(self.handler, self.issue_id):
+ description_placeholders = issue.translation_placeholders
+
+ return self.async_show_form(
+ step_id="confirm",
+ data_schema=vol.Schema({}),
+ description_placeholders=description_placeholders,
+ )
+
+
+async def async_create_fix_flow(
+ hass: HomeAssistant,
+ issue_id: str,
+ data: dict[str, str | int | float | None] | None,
+) -> RepairsFlow:
+ """Create flow."""
+ if issue_id.startswith("home_connect_too_many_connected_paired_events"):
+ return EnableApplianceUpdatesFlow()
+ return ConfirmRepairFlow()
diff --git a/homeassistant/components/home_connect/select.py b/homeassistant/components/home_connect/select.py
index 13518c5dea2..7d8b315b657 100644
--- a/homeassistant/components/home_connect/select.py
+++ b/homeassistant/components/home_connect/select.py
@@ -2,36 +2,73 @@
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
+import logging
from typing import Any, cast
from aiohomeconnect.client import Client as HomeConnectClient
-from aiohomeconnect.model import EventKey, ProgramKey
+from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.program import Execution
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
-from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM
+from .const import (
+ AVAILABLE_MAPS_ENUM,
+ BEAN_AMOUNT_OPTIONS,
+ BEAN_CONTAINER_OPTIONS,
+ CLEANING_MODE_OPTIONS,
+ COFFEE_MILK_RATIO_OPTIONS,
+ COFFEE_TEMPERATURE_OPTIONS,
+ DOMAIN,
+ DRYING_TARGET_OPTIONS,
+ FLOW_RATE_OPTIONS,
+ HOT_WATER_TEMPERATURE_OPTIONS,
+ INTENSIVE_LEVEL_OPTIONS,
+ PROGRAMS_TRANSLATION_KEYS_MAP,
+ SPIN_SPEED_OPTIONS,
+ TEMPERATURE_OPTIONS,
+ TRANSLATION_KEYS_PROGRAMS_MAP,
+ VARIO_PERFECT_OPTIONS,
+ VENTING_LEVEL_OPTIONS,
+ WARMING_LEVEL_OPTIONS,
+)
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
-from .entity import HomeConnectEntity
+from .entity import HomeConnectEntity, HomeConnectOptionEntity, constraint_fetcher
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
-TRANSLATION_KEYS_PROGRAMS_MAP = {
- bsh_key_to_translation_key(program.value): cast(ProgramKey, program)
- for program in ProgramKey
- if program != ProgramKey.UNKNOWN
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 1
+
+FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = {
+ bsh_key_to_translation_key(option): option
+ for option in (
+ "Cooking.Hood.EnumType.ColorTemperature.custom",
+ "Cooking.Hood.EnumType.ColorTemperature.warm",
+ "Cooking.Hood.EnumType.ColorTemperature.warmToNeutral",
+ "Cooking.Hood.EnumType.ColorTemperature.neutral",
+ "Cooking.Hood.EnumType.ColorTemperature.neutralToCold",
+ "Cooking.Hood.EnumType.ColorTemperature.cold",
+ )
}
-PROGRAMS_TRANSLATION_KEYS_MAP = {
- value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
+AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM = {
+ **{
+ bsh_key_to_translation_key(option): option
+ for option in ("BSH.Common.EnumType.AmbientLightColor.CustomColor",)
+ },
+ **{
+ str(option): f"BSH.Common.EnumType.AmbientLightColor.Color{option}"
+ for option in range(1, 100)
+ },
}
@@ -48,6 +85,14 @@ class HomeConnectProgramSelectEntityDescription(
error_translation_key: str
+@dataclass(frozen=True, kw_only=True)
+class HomeConnectSelectEntityDescription(SelectEntityDescription):
+ """Entity Description class for settings and options that have enumeration values."""
+
+ translation_key_values: dict[str, str]
+ values_translation_key: dict[str, str]
+
+
PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
HomeConnectProgramSelectEntityDescription(
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
@@ -69,32 +114,238 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
),
)
+SELECT_ENTITY_DESCRIPTIONS = (
+ HomeConnectSelectEntityDescription(
+ key=SettingKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CURRENT_MAP,
+ translation_key="current_map",
+ options=list(AVAILABLE_MAPS_ENUM),
+ translation_key_values=AVAILABLE_MAPS_ENUM,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in AVAILABLE_MAPS_ENUM.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE,
+ translation_key="functional_light_color_temperature",
+ options=list(FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM),
+ translation_key_values=FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR,
+ translation_key="ambient_light_color",
+ options=list(AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM),
+ translation_key_values=AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM.items()
+ },
+ ),
+)
+
+PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
+ translation_key="reference_map_id",
+ options=list(AVAILABLE_MAPS_ENUM),
+ translation_key_values=AVAILABLE_MAPS_ENUM,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in AVAILABLE_MAPS_ENUM.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,
+ translation_key="cleaning_mode",
+ options=list(CLEANING_MODE_OPTIONS),
+ translation_key_values=CLEANING_MODE_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in CLEANING_MODE_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT,
+ translation_key="bean_amount",
+ options=list(BEAN_AMOUNT_OPTIONS),
+ translation_key_values=BEAN_AMOUNT_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in BEAN_AMOUNT_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE,
+ translation_key="coffee_temperature",
+ options=list(COFFEE_TEMPERATURE_OPTIONS),
+ translation_key_values=COFFEE_TEMPERATURE_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in COFFEE_TEMPERATURE_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION,
+ translation_key="bean_container",
+ options=list(BEAN_CONTAINER_OPTIONS),
+ translation_key_values=BEAN_CONTAINER_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in BEAN_CONTAINER_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE,
+ translation_key="flow_rate",
+ options=list(FLOW_RATE_OPTIONS),
+ translation_key_values=FLOW_RATE_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in FLOW_RATE_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO,
+ translation_key="coffee_milk_ratio",
+ options=list(COFFEE_MILK_RATIO_OPTIONS),
+ translation_key_values=COFFEE_MILK_RATIO_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in FLOW_RATE_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE,
+ translation_key="hot_water_temperature",
+ options=list(HOT_WATER_TEMPERATURE_OPTIONS),
+ translation_key_values=HOT_WATER_TEMPERATURE_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in HOT_WATER_TEMPERATURE_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET,
+ translation_key="drying_target",
+ options=list(DRYING_TARGET_OPTIONS),
+ translation_key_values=DRYING_TARGET_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in DRYING_TARGET_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL,
+ translation_key="venting_level",
+ options=list(VENTING_LEVEL_OPTIONS),
+ translation_key_values=VENTING_LEVEL_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in VENTING_LEVEL_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL,
+ translation_key="intensive_level",
+ options=list(INTENSIVE_LEVEL_OPTIONS),
+ translation_key_values=INTENSIVE_LEVEL_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in INTENSIVE_LEVEL_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.COOKING_OVEN_WARMING_LEVEL,
+ translation_key="warming_level",
+ options=list(WARMING_LEVEL_OPTIONS),
+ translation_key_values=WARMING_LEVEL_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in WARMING_LEVEL_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE,
+ translation_key="washer_temperature",
+ options=list(TEMPERATURE_OPTIONS),
+ translation_key_values=TEMPERATURE_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in TEMPERATURE_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED,
+ translation_key="spin_speed",
+ options=list(SPIN_SPEED_OPTIONS),
+ translation_key_values=SPIN_SPEED_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in SPIN_SPEED_OPTIONS.items()
+ },
+ ),
+ HomeConnectSelectEntityDescription(
+ key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT,
+ translation_key="vario_perfect",
+ options=list(VARIO_PERFECT_OPTIONS),
+ translation_key_values=VARIO_PERFECT_OPTIONS,
+ values_translation_key={
+ value: translation_key
+ for translation_key, value in VARIO_PERFECT_OPTIONS.items()
+ },
+ ),
+)
+
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
- return (
- [
- HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
- for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
- ]
- if appliance.info.type in APPLIANCES_WITH_PROGRAMS
- else []
- )
+ return [
+ *(
+ [
+ HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
+ for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
+ ]
+ if appliance.programs
+ else []
+ ),
+ *[
+ HomeConnectSelectEntity(entry.runtime_data, appliance, desc)
+ for desc in SELECT_ENTITY_DESCRIPTIONS
+ if desc.key in appliance.settings
+ ],
+ ]
+
+
+def _get_option_entities_for_appliance(
+ entry: HomeConnectConfigEntry,
+ appliance: HomeConnectApplianceData,
+) -> list[HomeConnectOptionEntity]:
+ """Get a list of entities."""
+ return [
+ HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc)
+ for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS
+ if desc.key in appliance.options
+ ]
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect select entities."""
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
+ _get_option_entities_for_appliance,
)
@@ -149,6 +400,133 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
translation_key=self.entity_description.error_translation_key,
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value,
+ "program": program_key.value,
},
) from err
+
+
+class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
+ """Select setting class for Home Connect."""
+
+ entity_description: HomeConnectSelectEntityDescription
+ _original_option_keys: set[str | None]
+
+ def __init__(
+ self,
+ coordinator: HomeConnectCoordinator,
+ appliance: HomeConnectApplianceData,
+ desc: HomeConnectSelectEntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+ self._original_option_keys = set(desc.values_translation_key)
+ super().__init__(
+ coordinator,
+ appliance,
+ desc,
+ )
+
+ async def async_select_option(self, option: str) -> None:
+ """Select new option."""
+ value = self.entity_description.translation_key_values[option]
+ try:
+ await self.coordinator.client.set_setting(
+ self.appliance.info.ha_id,
+ setting_key=cast(SettingKey, self.bsh_key),
+ value=value,
+ )
+ except HomeConnectError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_setting_entity",
+ translation_placeholders={
+ **get_dict_from_home_connect_error(err),
+ "entity_id": self.entity_id,
+ "key": self.bsh_key,
+ "value": value,
+ },
+ ) from err
+
+ def update_native_value(self) -> None:
+ """Set the value of the entity."""
+ data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
+ self._attr_current_option = self.entity_description.values_translation_key.get(
+ data.value
+ )
+
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ await self.async_fetch_options()
+
+ @constraint_fetcher
+ async def async_fetch_options(self) -> None:
+ """Fetch options from the API."""
+ setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key))
+ if (
+ not setting
+ or not setting.constraints
+ or not setting.constraints.allowed_values
+ ):
+ setting = await self.coordinator.client.get_setting(
+ self.appliance.info.ha_id,
+ setting_key=cast(SettingKey, self.bsh_key),
+ )
+
+ if setting and setting.constraints and setting.constraints.allowed_values:
+ self._original_option_keys = set(setting.constraints.allowed_values)
+ self._attr_options = [
+ self.entity_description.values_translation_key[option]
+ for option in self._original_option_keys
+ if option is not None
+ and option in self.entity_description.values_translation_key
+ ]
+
+
+class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity):
+ """Select option class for Home Connect."""
+
+ entity_description: HomeConnectSelectEntityDescription
+ _original_option_keys: set[str | None]
+
+ def __init__(
+ self,
+ coordinator: HomeConnectCoordinator,
+ appliance: HomeConnectApplianceData,
+ desc: HomeConnectSelectEntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+ self._original_option_keys = set(desc.values_translation_key)
+ super().__init__(
+ coordinator,
+ appliance,
+ desc,
+ )
+
+ async def async_select_option(self, option: str) -> None:
+ """Select new option."""
+ await self.async_set_option(
+ self.entity_description.translation_key_values[option]
+ )
+
+ def update_native_value(self) -> None:
+ """Set the value of the entity."""
+ self._attr_current_option = (
+ self.entity_description.values_translation_key.get(
+ cast(str, self.option_value), None
+ )
+ if self.option_value is not None
+ else None
+ )
+ if (
+ (option_definition := self.appliance.options.get(self.bsh_key))
+ and (option_constraints := option_definition.constraints)
+ and option_constraints.allowed_values
+ and self._original_option_keys != set(option_constraints.allowed_values)
+ ):
+ self._original_option_keys = set(option_constraints.allowed_values)
+ self._attr_options = [
+ self.entity_description.values_translation_key[option]
+ for option in self._original_option_keys
+ if option is not None
+ and option in self.entity_description.values_translation_key
+ ]
diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py
index 545df1d68b6..0f0161971a2 100644
--- a/homeassistant/components/home_connect/sensor.py
+++ b/homeassistant/components/home_connect/sensor.py
@@ -1,7 +1,11 @@
"""Provides a sensor for Home Connect."""
+from collections import defaultdict
+from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
+from functools import partial
+import logging
from typing import cast
from aiohomeconnect.model import EventKey, StatusKey
@@ -12,9 +16,9 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume
+from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
from .common import setup_home_connect_entry
@@ -23,9 +27,14 @@ from .const import (
BSH_OPERATION_STATE_FINISHED,
BSH_OPERATION_STATE_PAUSE,
BSH_OPERATION_STATE_RUN,
+ UNIT_MAP,
)
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
-from .entity import HomeConnectEntity
+from .entity import HomeConnectEntity, constraint_fetcher
+
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 0
EVENT_OPTIONS = ["confirmed", "off", "present"]
@@ -36,8 +45,8 @@ class HomeConnectSensorEntityDescription(
):
"""Entity Description class for sensors."""
- default_value: str | None = None
appliance_types: tuple[str, ...] | None = None
+ fetch_unit: bool = False
BSH_PROGRAM_SENSORS = (
@@ -46,7 +55,7 @@ BSH_PROGRAM_SENSORS = (
device_class=SensorDeviceClass.TIMESTAMP,
translation_key="program_finish_time",
appliance_types=(
- "CoffeMaker",
+ "CoffeeMaker",
"CookProcessor",
"Dishwasher",
"Dryer",
@@ -56,12 +65,6 @@ BSH_PROGRAM_SENSORS = (
"WasherDryer",
),
),
- HomeConnectSensorEntityDescription(
- key=EventKey.BSH_COMMON_OPTION_DURATION,
- device_class=SensorDeviceClass.DURATION,
- native_unit_of_measurement=UnitOfTime.SECONDS,
- appliance_types=("Oven",),
- ),
HomeConnectSensorEntityDescription(
key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS,
native_unit_of_measurement=PERCENTAGE,
@@ -99,16 +102,19 @@ SENSORS = (
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE,
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="coffee_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_POWDER_COFFEE,
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="powder_coffee_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER,
+ entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
device_class=SensorDeviceClass.VOLUME,
state_class=SensorStateClass.TOTAL_INCREASING,
@@ -116,31 +122,37 @@ SENSORS = (
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER_CUPS,
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="hot_water_cups_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_MILK,
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="hot_milk_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_FROTHY_MILK,
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="frothy_milk_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_MILK,
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="milk_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE_AND_MILK,
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="coffee_and_milk_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_RISTRETTO_ESPRESSO,
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="ristretto_espresso_counter",
),
@@ -174,62 +186,70 @@ SENSORS = (
],
translation_key="last_selected_map",
),
+ HomeConnectSensorEntityDescription(
+ key=StatusKey.COOKING_OVEN_CURRENT_CAVITY_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key="oven_current_cavity_temperature",
+ fetch_unit=True,
+ ),
)
EVENT_SENSORS = (
HomeConnectSensorEntityDescription(
- key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER,
+ key=EventKey.BSH_COMMON_EVENT_PROGRAM_ABORTED,
device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS,
- default_value="off",
- translation_key="freezer_door_alarm",
- appliance_types=("FridgeFreezer", "Freezer"),
+ translation_key="program_aborted",
+ appliance_types=("Dishwasher", "CleaningRobot", "CookProcessor"),
),
HomeConnectSensorEntityDescription(
- key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR,
+ key=EventKey.BSH_COMMON_EVENT_PROGRAM_FINISHED,
device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS,
- default_value="off",
- translation_key="refrigerator_door_alarm",
- appliance_types=("FridgeFreezer", "Refrigerator"),
+ translation_key="program_finished",
+ appliance_types=(
+ "Oven",
+ "Dishwasher",
+ "Washer",
+ "Dryer",
+ "WasherDryer",
+ "CleaningRobot",
+ "CookProcessor",
+ ),
),
HomeConnectSensorEntityDescription(
- key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER,
+ key=EventKey.BSH_COMMON_EVENT_ALARM_CLOCK_ELAPSED,
device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS,
- default_value="off",
- translation_key="freezer_temperature_alarm",
- appliance_types=("FridgeFreezer", "Freezer"),
+ translation_key="alarm_clock_elapsed",
+ appliance_types=("Oven", "Cooktop"),
),
HomeConnectSensorEntityDescription(
- key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY,
+ key=EventKey.COOKING_OVEN_EVENT_PREHEAT_FINISHED,
device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS,
- default_value="off",
- translation_key="bean_container_empty",
- appliance_types=("CoffeeMaker",),
+ translation_key="preheat_finished",
+ appliance_types=("Oven", "Cooktop"),
),
HomeConnectSensorEntityDescription(
- key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY,
+ key=EventKey.COOKING_OVEN_EVENT_REGULAR_PREHEAT_FINISHED,
device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS,
- default_value="off",
- translation_key="water_tank_empty",
- appliance_types=("CoffeeMaker",),
+ translation_key="regular_preheat_finished",
+ appliance_types=("Oven",),
),
HomeConnectSensorEntityDescription(
- key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL,
+ key=EventKey.LAUNDRY_CARE_DRYER_EVENT_DRYING_PROCESS_FINISHED,
device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS,
- default_value="off",
- translation_key="drip_tray_full",
- appliance_types=("CoffeeMaker",),
+ translation_key="drying_process_finished",
+ appliance_types=("Dryer",),
),
HomeConnectSensorEntityDescription(
key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY,
device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS,
- default_value="off",
translation_key="salt_nearly_empty",
appliance_types=("Dishwasher",),
),
@@ -237,10 +257,219 @@ EVENT_SENSORS = (
key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY,
device_class=SensorDeviceClass.ENUM,
options=EVENT_OPTIONS,
- default_value="off",
translation_key="rinse_aid_nearly_empty",
appliance_types=("Dishwasher",),
),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="bean_container_empty",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="water_tank_empty",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="drip_tray_full",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_KEEP_MILK_TANK_COOL,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="keep_milk_tank_cool",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_20_CUPS,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="descaling_in_20_cups",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_15_CUPS,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="descaling_in_15_cups",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_10_CUPS,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="descaling_in_10_cups",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DESCALING_IN_5_CUPS,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="descaling_in_5_cups",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_DESCALED,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="device_should_be_descaled",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_OVERDUE,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="device_descaling_overdue",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_DESCALING_BLOCKAGE,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="device_descaling_blockage",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CLEANED,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="device_should_be_cleaned",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CLEANING_OVERDUE,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="device_cleaning_overdue",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN20CUPS,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="calc_n_clean_in20cups",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN15CUPS,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="calc_n_clean_in15cups",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN10CUPS,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="calc_n_clean_in10cups",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_CALC_N_CLEAN_IN5CUPS,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="calc_n_clean_in5cups",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_SHOULD_BE_CALC_N_CLEANED,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="device_should_be_calc_n_cleaned",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_OVERDUE,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="device_calc_n_clean_overdue",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DEVICE_CALC_N_CLEAN_BLOCKAGE,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="device_calc_n_clean_blockage",
+ appliance_types=("CoffeeMaker",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="freezer_door_alarm",
+ appliance_types=("FridgeFreezer", "Freezer"),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="refrigerator_door_alarm",
+ appliance_types=("FridgeFreezer", "Refrigerator"),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="freezer_temperature_alarm",
+ appliance_types=("FridgeFreezer", "Freezer"),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_EMPTY_DUST_BOX_AND_CLEAN_FILTER,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="empty_dust_box_and_clean_filter",
+ appliance_types=("CleaningRobot",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_ROBOT_IS_STUCK,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="robot_is_stuck",
+ appliance_types=("CleaningRobot",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_EVENT_DOCKING_STATION_NOT_FOUND,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="docking_station_not_found",
+ appliance_types=("CleaningRobot",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_1_FILL_LEVEL_POOR,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="poor_i_dos_1_fill_level",
+ appliance_types=("Washer", "WasherDryer"),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.LAUNDRY_CARE_WASHER_EVENT_I_DOS_2_FILL_LEVEL_POOR,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="poor_i_dos_2_fill_level",
+ appliance_types=("Washer", "WasherDryer"),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_NEARLY_REACHED,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="grease_filter_max_saturation_nearly_reached",
+ appliance_types=("Hood",),
+ ),
+ HomeConnectSensorEntityDescription(
+ key=EventKey.COOKING_COMMON_EVENT_HOOD_GREASE_FILTER_MAX_SATURATION_REACHED,
+ device_class=SensorDeviceClass.ENUM,
+ options=EVENT_OPTIONS,
+ translation_key="grease_filter_max_saturation_reached",
+ appliance_types=("Hood",),
+ ),
)
@@ -250,12 +479,6 @@ def _get_entities_for_appliance(
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
- *[
- HomeConnectEventSensor(entry.runtime_data, appliance, description)
- for description in EVENT_SENSORS
- if description.appliance_types
- and appliance.info.type in description.appliance_types
- ],
*[
HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
for desc in BSH_PROGRAM_SENSORS
@@ -269,10 +492,76 @@ def _get_entities_for_appliance(
]
+def _add_event_sensor_entity(
+ entry: HomeConnectConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ appliance: HomeConnectApplianceData,
+ description: HomeConnectSensorEntityDescription,
+ remove_event_sensor_listener_list: list[Callable[[], None]],
+) -> None:
+ """Add an event sensor entity."""
+ if (
+ (appliance_data := entry.runtime_data.data.get(appliance.info.ha_id)) is None
+ ) or description.key not in appliance_data.events:
+ return
+
+ for remove_listener in remove_event_sensor_listener_list:
+ remove_listener()
+ async_add_entities(
+ [
+ HomeConnectEventSensor(entry.runtime_data, appliance, description),
+ ]
+ )
+
+
+def _add_event_sensor_listeners(
+ entry: HomeConnectConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]],
+) -> None:
+ for appliance in entry.runtime_data.data.values():
+ if appliance.info.ha_id in remove_event_sensor_listener_dict:
+ continue
+ for event_sensor_description in EVENT_SENSORS:
+ if appliance.info.type not in cast(
+ tuple[str, ...], event_sensor_description.appliance_types
+ ):
+ continue
+ # We use a list as a kind of lazy initializer, as we can use the
+ # remove_listener while we are initializing it.
+ remove_event_sensor_listener_list = remove_event_sensor_listener_dict[
+ appliance.info.ha_id
+ ]
+ remove_listener = entry.runtime_data.async_add_listener(
+ partial(
+ _add_event_sensor_entity,
+ entry,
+ async_add_entities,
+ appliance,
+ event_sensor_description,
+ remove_event_sensor_listener_list,
+ ),
+ (appliance.info.ha_id, event_sensor_description.key),
+ )
+ remove_event_sensor_listener_list.append(remove_listener)
+ entry.async_on_unload(remove_listener)
+
+
+def _remove_event_sensor_listeners_on_depaired(
+ entry: HomeConnectConfigEntry,
+ remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]],
+) -> None:
+ registered_listeners_ha_id = set(remove_event_sensor_listener_dict)
+ actual_appliances = set(entry.runtime_data.data)
+ for appliance_ha_id in registered_listeners_ha_id - actual_appliances:
+ for listener in remove_event_sensor_listener_dict.pop(appliance_ha_id):
+ listener()
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect sensor."""
setup_home_connect_entry(
@@ -281,6 +570,32 @@ async def async_setup_entry(
async_add_entities,
)
+ remove_event_sensor_listener_dict: dict[str, list[CALLBACK_TYPE]] = defaultdict(
+ list
+ )
+
+ entry.async_on_unload(
+ entry.runtime_data.async_add_special_listener(
+ partial(
+ _add_event_sensor_listeners,
+ entry,
+ async_add_entities,
+ remove_event_sensor_listener_dict,
+ ),
+ (EventKey.BSH_COMMON_APPLIANCE_PAIRED,),
+ )
+ )
+ entry.async_on_unload(
+ entry.runtime_data.async_add_special_listener(
+ partial(
+ _remove_event_sensor_listeners_on_depaired,
+ entry,
+ remove_event_sensor_listener_dict,
+ ),
+ (EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,),
+ )
+ )
+
class HomeConnectSensor(HomeConnectEntity, SensorEntity):
"""Sensor class for Home Connect."""
@@ -307,6 +622,27 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
case _:
self._attr_native_value = status
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ if self.entity_description.fetch_unit:
+ data = self.appliance.status[cast(StatusKey, self.bsh_key)]
+ if data.unit:
+ self._attr_native_unit_of_measurement = UNIT_MAP.get(
+ data.unit, data.unit
+ )
+ else:
+ await self.fetch_unit()
+
+ @constraint_fetcher
+ async def fetch_unit(self) -> None:
+ """Fetch the unit of measurement."""
+ data = await self.coordinator.client.get_status_value(
+ self.appliance.info.ha_id, status_key=cast(StatusKey, self.bsh_key)
+ )
+ if data.unit:
+ self._attr_native_unit_of_measurement = UNIT_MAP.get(data.unit, data.unit)
+
class HomeConnectProgramSensor(HomeConnectSensor):
"""Sensor class for Home Connect sensors that reports information related to the running program."""
@@ -347,6 +683,13 @@ class HomeConnectProgramSensor(HomeConnectSensor):
def update_native_value(self) -> None:
"""Update the program sensor's status."""
+ self.program_running = (
+ status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE)
+ ) is not None and status.value in [
+ BSH_OPERATION_STATE_RUN,
+ BSH_OPERATION_STATE_PAUSE,
+ BSH_OPERATION_STATE_FINISHED,
+ ]
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
if event:
self._update_native_value(event.value)
@@ -357,8 +700,5 @@ class HomeConnectEventSensor(HomeConnectSensor):
def update_native_value(self) -> None:
"""Update the sensor's status."""
- event = self.appliance.events.get(cast(EventKey, self.bsh_key))
- if event:
- self._update_native_value(event.value)
- elif not self._attr_native_value:
- self._attr_native_value = self.entity_description.default_value
+ event = self.appliance.events[cast(EventKey, self.bsh_key)]
+ self._update_native_value(event.value)
diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py
new file mode 100644
index 00000000000..fac1c5fe1a9
--- /dev/null
+++ b/homeassistant/components/home_connect/services.py
@@ -0,0 +1,572 @@
+"""Custom actions (previously known as services) for the Home Connect integration."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable
+from typing import Any, cast
+
+from aiohomeconnect.client import Client as HomeConnectClient
+from aiohomeconnect.model import (
+ ArrayOfOptions,
+ CommandKey,
+ Option,
+ OptionKey,
+ ProgramKey,
+ SettingKey,
+)
+from aiohomeconnect.model.error import HomeConnectError
+import voluptuous as vol
+
+from homeassistant.const import ATTR_DEVICE_ID
+from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
+
+from .const import (
+ AFFECTS_TO_ACTIVE_PROGRAM,
+ AFFECTS_TO_SELECTED_PROGRAM,
+ ATTR_AFFECTS_TO,
+ ATTR_KEY,
+ ATTR_PROGRAM,
+ ATTR_UNIT,
+ ATTR_VALUE,
+ DOMAIN,
+ PROGRAM_ENUM_OPTIONS,
+ SERVICE_OPTION_ACTIVE,
+ SERVICE_OPTION_SELECTED,
+ SERVICE_PAUSE_PROGRAM,
+ SERVICE_RESUME_PROGRAM,
+ SERVICE_SELECT_PROGRAM,
+ SERVICE_SET_PROGRAM_AND_OPTIONS,
+ SERVICE_SETTING,
+ SERVICE_START_PROGRAM,
+ TRANSLATION_KEYS_PROGRAMS_MAP,
+)
+from .coordinator import HomeConnectConfigEntry
+from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
+
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+PROGRAM_OPTIONS = {
+ bsh_key_to_translation_key(key): (
+ key,
+ value,
+ )
+ for key, value in {
+ OptionKey.BSH_COMMON_DURATION: int,
+ OptionKey.BSH_COMMON_START_IN_RELATIVE: int,
+ OptionKey.BSH_COMMON_FINISH_IN_RELATIVE: int,
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY: int,
+ OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES: bool,
+ OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE: bool,
+ OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY: bool,
+ OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS: bool,
+ OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND: bool,
+ OptionKey.DISHCARE_DISHWASHER_HALF_LOAD: bool,
+ OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY: bool,
+ OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS: bool,
+ OptionKey.DISHCARE_DISHWASHER_ECO_DRY: bool,
+ OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY: bool,
+ OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: int,
+ OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool,
+ OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool,
+ OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool,
+ }.items()
+}
+
+
+SERVICE_SETTING_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_DEVICE_ID): str,
+ vol.Required(ATTR_KEY): vol.All(
+ vol.Coerce(SettingKey),
+ vol.NotIn([SettingKey.UNKNOWN]),
+ ),
+ vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
+ }
+)
+
+# DEPRECATED: Remove in 2025.9.0
+SERVICE_OPTION_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_DEVICE_ID): str,
+ vol.Required(ATTR_KEY): vol.All(
+ vol.Coerce(OptionKey),
+ vol.NotIn([OptionKey.UNKNOWN]),
+ ),
+ vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
+ vol.Optional(ATTR_UNIT): str,
+ }
+)
+
+# DEPRECATED: Remove in 2025.9.0
+SERVICE_PROGRAM_SCHEMA = vol.Any(
+ {
+ vol.Required(ATTR_DEVICE_ID): str,
+ vol.Required(ATTR_PROGRAM): vol.All(
+ vol.Coerce(ProgramKey),
+ vol.NotIn([ProgramKey.UNKNOWN]),
+ ),
+ vol.Required(ATTR_KEY): vol.All(
+ vol.Coerce(OptionKey),
+ vol.NotIn([OptionKey.UNKNOWN]),
+ ),
+ vol.Required(ATTR_VALUE): vol.Any(int, str),
+ vol.Optional(ATTR_UNIT): str,
+ },
+ {
+ vol.Required(ATTR_DEVICE_ID): str,
+ vol.Required(ATTR_PROGRAM): vol.All(
+ vol.Coerce(ProgramKey),
+ vol.NotIn([ProgramKey.UNKNOWN]),
+ ),
+ },
+)
+
+
+def _require_program_or_at_least_one_option(data: dict) -> dict:
+ if ATTR_PROGRAM not in data and not any(
+ option_key in data for option_key in (PROGRAM_ENUM_OPTIONS | PROGRAM_OPTIONS)
+ ):
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="required_program_or_one_option_at_least",
+ )
+ return data
+
+
+SERVICE_PROGRAM_AND_OPTIONS_SCHEMA = vol.All(
+ vol.Schema(
+ {
+ vol.Required(ATTR_DEVICE_ID): str,
+ vol.Required(ATTR_AFFECTS_TO): vol.In(
+ [AFFECTS_TO_ACTIVE_PROGRAM, AFFECTS_TO_SELECTED_PROGRAM]
+ ),
+ vol.Optional(ATTR_PROGRAM): vol.In(TRANSLATION_KEYS_PROGRAMS_MAP.keys()),
+ }
+ )
+ .extend(
+ {
+ vol.Optional(translation_key): vol.In(allowed_values.keys())
+ for translation_key, (
+ key,
+ allowed_values,
+ ) in PROGRAM_ENUM_OPTIONS.items()
+ }
+ )
+ .extend(
+ {
+ vol.Optional(translation_key): schema
+ for translation_key, (key, schema) in PROGRAM_OPTIONS.items()
+ }
+ ),
+ _require_program_or_at_least_one_option,
+)
+
+SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str})
+
+
+async def _get_client_and_ha_id(
+ hass: HomeAssistant, device_id: str
+) -> tuple[HomeConnectClient, str]:
+ device_registry = dr.async_get(hass)
+ device_entry = device_registry.async_get(device_id)
+ if device_entry is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="device_entry_not_found",
+ translation_placeholders={
+ "device_id": device_id,
+ },
+ )
+ entry: HomeConnectConfigEntry | None = None
+ for entry_id in device_entry.config_entries:
+ _entry = hass.config_entries.async_get_entry(entry_id)
+ assert _entry
+ if _entry.domain == DOMAIN:
+ entry = cast(HomeConnectConfigEntry, _entry)
+ break
+ if entry is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_found",
+ translation_placeholders={
+ "device_id": device_id,
+ },
+ )
+
+ ha_id = next(
+ (
+ identifier[1]
+ for identifier in device_entry.identifiers
+ if identifier[0] == DOMAIN
+ ),
+ None,
+ )
+ if ha_id is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="appliance_not_found",
+ translation_placeholders={
+ "device_id": device_id,
+ },
+ )
+ return entry.runtime_data.client, ha_id
+
+
+async def _async_service_program(call: ServiceCall, start: bool) -> None:
+ """Execute calls to services taking a program."""
+ program = call.data[ATTR_PROGRAM]
+ client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID])
+
+ option_key = call.data.get(ATTR_KEY)
+ options = (
+ [
+ Option(
+ option_key,
+ call.data[ATTR_VALUE],
+ unit=call.data.get(ATTR_UNIT),
+ )
+ ]
+ if option_key is not None
+ else None
+ )
+
+ async_create_issue(
+ call.hass,
+ DOMAIN,
+ "deprecated_set_program_and_option_actions",
+ breaks_in_ha_version="2025.9.0",
+ is_fixable=True,
+ is_persistent=True,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_set_program_and_option_actions",
+ translation_placeholders={
+ "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS,
+ "remove_release": "2025.9.0",
+ "deprecated_action_yaml": "\n".join(
+ [
+ "```yaml",
+ f"action: {DOMAIN}.{SERVICE_START_PROGRAM if start else SERVICE_SELECT_PROGRAM}",
+ "data:",
+ f" {ATTR_DEVICE_ID}: DEVICE_ID",
+ f" {ATTR_PROGRAM}: {program}",
+ *([f" {ATTR_KEY}: {options[0].key}"] if options else []),
+ *([f" {ATTR_VALUE}: {options[0].value}"] if options else []),
+ *(
+ [f" {ATTR_UNIT}: {options[0].unit}"]
+ if options and options[0].unit
+ else []
+ ),
+ "```",
+ ]
+ ),
+ "new_action_yaml": "\n ".join(
+ [
+ "```yaml",
+ f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}",
+ "data:",
+ f" {ATTR_DEVICE_ID}: DEVICE_ID",
+ f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if start else AFFECTS_TO_SELECTED_PROGRAM}",
+ f" {ATTR_PROGRAM}: {bsh_key_to_translation_key(program.value)}",
+ *(
+ [
+ f" {bsh_key_to_translation_key(options[0].key)}: {options[0].value}"
+ ]
+ if options
+ else []
+ ),
+ "```",
+ ]
+ ),
+ "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)",
+ },
+ )
+
+ try:
+ if start:
+ await client.start_program(ha_id, program_key=program, options=options)
+ else:
+ await client.set_selected_program(
+ ha_id, program_key=program, options=options
+ )
+ except HomeConnectError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="start_program" if start else "select_program",
+ translation_placeholders={
+ **get_dict_from_home_connect_error(err),
+ "program": program,
+ },
+ ) from err
+
+
+async def _async_service_set_program_options(call: ServiceCall, active: bool) -> None:
+ """Execute calls to services taking a program."""
+ option_key = call.data[ATTR_KEY]
+ value = call.data[ATTR_VALUE]
+ unit = call.data.get(ATTR_UNIT)
+ client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID])
+
+ async_create_issue(
+ call.hass,
+ DOMAIN,
+ "deprecated_set_program_and_option_actions",
+ breaks_in_ha_version="2025.9.0",
+ is_fixable=True,
+ is_persistent=True,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_set_program_and_option_actions",
+ translation_placeholders={
+ "new_action_key": SERVICE_SET_PROGRAM_AND_OPTIONS,
+ "remove_release": "2025.9.0",
+ "deprecated_action_yaml": "\n".join(
+ [
+ "```yaml",
+ f"action: {DOMAIN}.{SERVICE_OPTION_ACTIVE if active else SERVICE_OPTION_SELECTED}",
+ "data:",
+ f" {ATTR_DEVICE_ID}: DEVICE_ID",
+ f" {ATTR_KEY}: {option_key}",
+ f" {ATTR_VALUE}: {value}",
+ *([f" {ATTR_UNIT}: {unit}"] if unit else []),
+ "```",
+ ]
+ ),
+ "new_action_yaml": "\n ".join(
+ [
+ "```yaml",
+ f"action: {DOMAIN}.{SERVICE_SET_PROGRAM_AND_OPTIONS}",
+ "data:",
+ f" {ATTR_DEVICE_ID}: DEVICE_ID",
+ f" {ATTR_AFFECTS_TO}: {AFFECTS_TO_ACTIVE_PROGRAM if active else AFFECTS_TO_SELECTED_PROGRAM}",
+ f" {bsh_key_to_translation_key(option_key)}: {value}",
+ "```",
+ ]
+ ),
+ "repo_link": "[aiohomeconnect](https://github.com/MartinHjelmare/aiohomeconnect)",
+ },
+ )
+ try:
+ if active:
+ await client.set_active_program_option(
+ ha_id,
+ option_key=option_key,
+ value=value,
+ unit=unit,
+ )
+ else:
+ await client.set_selected_program_option(
+ ha_id,
+ option_key=option_key,
+ value=value,
+ unit=unit,
+ )
+ except HomeConnectError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_options_active_program"
+ if active
+ else "set_options_selected_program",
+ translation_placeholders={
+ **get_dict_from_home_connect_error(err),
+ "key": option_key,
+ "value": str(value),
+ },
+ ) from err
+
+
+async def _async_service_command(call: ServiceCall, command_key: CommandKey) -> None:
+ """Execute calls to services executing a command."""
+ client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID])
+
+ async_create_issue(
+ call.hass,
+ DOMAIN,
+ "deprecated_command_actions",
+ breaks_in_ha_version="2025.9.0",
+ is_fixable=True,
+ is_persistent=True,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_command_actions",
+ )
+
+ try:
+ await client.put_command(ha_id, command_key=command_key, value=True)
+ except HomeConnectError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="execute_command",
+ translation_placeholders={
+ **get_dict_from_home_connect_error(err),
+ "command": command_key.value,
+ },
+ ) from err
+
+
+async def async_service_option_active(call: ServiceCall) -> None:
+ """Service for setting an option for an active program."""
+ await _async_service_set_program_options(call, True)
+
+
+async def async_service_option_selected(call: ServiceCall) -> None:
+ """Service for setting an option for a selected program."""
+ await _async_service_set_program_options(call, False)
+
+
+async def async_service_setting(call: ServiceCall) -> None:
+ """Service for changing a setting."""
+ key = call.data[ATTR_KEY]
+ value = call.data[ATTR_VALUE]
+ client, ha_id = await _get_client_and_ha_id(call.hass, call.data[ATTR_DEVICE_ID])
+
+ try:
+ await client.set_setting(ha_id, setting_key=key, value=value)
+ except HomeConnectError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="set_setting",
+ translation_placeholders={
+ **get_dict_from_home_connect_error(err),
+ "key": key,
+ "value": str(value),
+ },
+ ) from err
+
+
+async def async_service_pause_program(call: ServiceCall) -> None:
+ """Service for pausing a program."""
+ await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM)
+
+
+async def async_service_resume_program(call: ServiceCall) -> None:
+ """Service for resuming a paused program."""
+ await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM)
+
+
+async def async_service_select_program(call: ServiceCall) -> None:
+ """Service for selecting a program."""
+ await _async_service_program(call, False)
+
+
+async def async_service_set_program_and_options(call: ServiceCall) -> None:
+ """Service for setting a program and options."""
+ data = dict(call.data)
+ program = data.pop(ATTR_PROGRAM, None)
+ affects_to = data.pop(ATTR_AFFECTS_TO)
+ client, ha_id = await _get_client_and_ha_id(call.hass, data.pop(ATTR_DEVICE_ID))
+
+ options: list[Option] = []
+
+ for option, value in data.items():
+ if option in PROGRAM_ENUM_OPTIONS:
+ options.append(
+ Option(
+ PROGRAM_ENUM_OPTIONS[option][0],
+ PROGRAM_ENUM_OPTIONS[option][1][value],
+ )
+ )
+ elif option in PROGRAM_OPTIONS:
+ option_key = PROGRAM_OPTIONS[option][0]
+ options.append(Option(option_key, value))
+
+ method_call: Awaitable[Any]
+ exception_translation_key: str
+ if program:
+ program = (
+ program
+ if isinstance(program, ProgramKey)
+ else TRANSLATION_KEYS_PROGRAMS_MAP[program]
+ )
+
+ if affects_to == AFFECTS_TO_ACTIVE_PROGRAM:
+ method_call = client.start_program(
+ ha_id, program_key=program, options=options
+ )
+ exception_translation_key = "start_program"
+ elif affects_to == AFFECTS_TO_SELECTED_PROGRAM:
+ method_call = client.set_selected_program(
+ ha_id, program_key=program, options=options
+ )
+ exception_translation_key = "select_program"
+ else:
+ array_of_options = ArrayOfOptions(options)
+ if affects_to == AFFECTS_TO_ACTIVE_PROGRAM:
+ method_call = client.set_active_program_options(
+ ha_id, array_of_options=array_of_options
+ )
+ exception_translation_key = "set_options_active_program"
+ else:
+ # affects_to is AFFECTS_TO_SELECTED_PROGRAM
+ method_call = client.set_selected_program_options(
+ ha_id, array_of_options=array_of_options
+ )
+ exception_translation_key = "set_options_selected_program"
+
+ try:
+ await method_call
+ except HomeConnectError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=exception_translation_key,
+ translation_placeholders={
+ **get_dict_from_home_connect_error(err),
+ **({"program": program} if program else {}),
+ },
+ ) from err
+
+
+async def async_service_start_program(call: ServiceCall) -> None:
+ """Service for starting a program."""
+ await _async_service_program(call, True)
+
+
+def register_actions(hass: HomeAssistant) -> None:
+ """Register custom actions."""
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_OPTION_ACTIVE,
+ async_service_option_active,
+ schema=SERVICE_OPTION_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_OPTION_SELECTED,
+ async_service_option_selected,
+ schema=SERVICE_OPTION_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_PAUSE_PROGRAM,
+ async_service_pause_program,
+ schema=SERVICE_COMMAND_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_RESUME_PROGRAM,
+ async_service_resume_program,
+ schema=SERVICE_COMMAND_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SELECT_PROGRAM,
+ async_service_select_program,
+ schema=SERVICE_PROGRAM_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_START_PROGRAM,
+ async_service_start_program,
+ schema=SERVICE_PROGRAM_SCHEMA,
+ )
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SET_PROGRAM_AND_OPTIONS,
+ async_service_set_program_and_options,
+ schema=SERVICE_PROGRAM_AND_OPTIONS_SCHEMA,
+ )
diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml
index 0738b58595a..e07e8e91457 100644
--- a/homeassistant/components/home_connect/services.yaml
+++ b/homeassistant/components/home_connect/services.yaml
@@ -46,6 +46,559 @@ select_program:
example: "seconds"
selector:
text:
+set_program_and_options:
+ fields:
+ device_id:
+ required: true
+ selector:
+ device:
+ integration: home_connect
+ affects_to:
+ example: active_program
+ required: true
+ selector:
+ select:
+ translation_key: affects_to
+ options:
+ - active_program
+ - selected_program
+ program:
+ example: dishcare_dishwasher_program_auto2
+ selector:
+ select:
+ mode: dropdown
+ custom_value: false
+ translation_key: programs
+ options:
+ - consumer_products_cleaning_robot_program_cleaning_clean_all
+ - consumer_products_cleaning_robot_program_cleaning_clean_map
+ - consumer_products_cleaning_robot_program_basic_go_home
+ - consumer_products_coffee_maker_program_beverage_ristretto
+ - consumer_products_coffee_maker_program_beverage_espresso
+ - consumer_products_coffee_maker_program_beverage_espresso_doppio
+ - consumer_products_coffee_maker_program_beverage_coffee
+ - consumer_products_coffee_maker_program_beverage_x_l_coffee
+ - consumer_products_coffee_maker_program_beverage_caffe_grande
+ - consumer_products_coffee_maker_program_beverage_espresso_macchiato
+ - consumer_products_coffee_maker_program_beverage_cappuccino
+ - consumer_products_coffee_maker_program_beverage_latte_macchiato
+ - consumer_products_coffee_maker_program_beverage_caffe_latte
+ - consumer_products_coffee_maker_program_beverage_milk_froth
+ - consumer_products_coffee_maker_program_beverage_warm_milk
+ - consumer_products_coffee_maker_program_coffee_world_kleiner_brauner
+ - consumer_products_coffee_maker_program_coffee_world_grosser_brauner
+ - consumer_products_coffee_maker_program_coffee_world_verlaengerter
+ - consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun
+ - consumer_products_coffee_maker_program_coffee_world_wiener_melange
+ - consumer_products_coffee_maker_program_coffee_world_flat_white
+ - consumer_products_coffee_maker_program_coffee_world_cortado
+ - consumer_products_coffee_maker_program_coffee_world_cafe_cortado
+ - consumer_products_coffee_maker_program_coffee_world_cafe_con_leche
+ - consumer_products_coffee_maker_program_coffee_world_cafe_au_lait
+ - consumer_products_coffee_maker_program_coffee_world_doppio
+ - consumer_products_coffee_maker_program_coffee_world_kaapi
+ - consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd
+ - consumer_products_coffee_maker_program_coffee_world_galao
+ - consumer_products_coffee_maker_program_coffee_world_garoto
+ - consumer_products_coffee_maker_program_coffee_world_americano
+ - consumer_products_coffee_maker_program_coffee_world_red_eye
+ - consumer_products_coffee_maker_program_coffee_world_black_eye
+ - consumer_products_coffee_maker_program_coffee_world_dead_eye
+ - consumer_products_coffee_maker_program_beverage_hot_water
+ - dishcare_dishwasher_program_pre_rinse
+ - dishcare_dishwasher_program_auto_1
+ - dishcare_dishwasher_program_auto_2
+ - dishcare_dishwasher_program_auto_3
+ - dishcare_dishwasher_program_eco_50
+ - dishcare_dishwasher_program_quick_45
+ - dishcare_dishwasher_program_intensiv_70
+ - dishcare_dishwasher_program_normal_65
+ - dishcare_dishwasher_program_glas_40
+ - dishcare_dishwasher_program_glass_care
+ - dishcare_dishwasher_program_night_wash
+ - dishcare_dishwasher_program_quick_65
+ - dishcare_dishwasher_program_normal_45
+ - dishcare_dishwasher_program_intensiv_45
+ - dishcare_dishwasher_program_auto_half_load
+ - dishcare_dishwasher_program_intensiv_power
+ - dishcare_dishwasher_program_magic_daily
+ - dishcare_dishwasher_program_super_60
+ - dishcare_dishwasher_program_kurz_60
+ - dishcare_dishwasher_program_express_sparkle_65
+ - dishcare_dishwasher_program_machine_care
+ - dishcare_dishwasher_program_steam_fresh
+ - dishcare_dishwasher_program_maximum_cleaning
+ - dishcare_dishwasher_program_mixed_load
+ - laundry_care_dryer_program_cotton
+ - laundry_care_dryer_program_synthetic
+ - laundry_care_dryer_program_mix
+ - laundry_care_dryer_program_blankets
+ - laundry_care_dryer_program_business_shirts
+ - laundry_care_dryer_program_down_feathers
+ - laundry_care_dryer_program_hygiene
+ - laundry_care_dryer_program_jeans
+ - laundry_care_dryer_program_outdoor
+ - laundry_care_dryer_program_synthetic_refresh
+ - laundry_care_dryer_program_towels
+ - laundry_care_dryer_program_delicates
+ - laundry_care_dryer_program_super_40
+ - laundry_care_dryer_program_shirts_15
+ - laundry_care_dryer_program_pillow
+ - laundry_care_dryer_program_anti_shrink
+ - laundry_care_dryer_program_my_time_my_drying_time
+ - laundry_care_dryer_program_time_cold
+ - laundry_care_dryer_program_time_warm
+ - laundry_care_dryer_program_in_basket
+ - laundry_care_dryer_program_time_cold_fix_time_cold_20
+ - laundry_care_dryer_program_time_cold_fix_time_cold_30
+ - laundry_care_dryer_program_time_cold_fix_time_cold_60
+ - laundry_care_dryer_program_time_warm_fix_time_warm_30
+ - laundry_care_dryer_program_time_warm_fix_time_warm_40
+ - laundry_care_dryer_program_time_warm_fix_time_warm_60
+ - laundry_care_dryer_program_dessous
+ - cooking_common_program_hood_automatic
+ - cooking_common_program_hood_venting
+ - cooking_common_program_hood_delayed_shut_off
+ - cooking_oven_program_heating_mode_pre_heating
+ - cooking_oven_program_heating_mode_hot_air
+ - cooking_oven_program_heating_mode_hot_air_eco
+ - cooking_oven_program_heating_mode_hot_air_grilling
+ - cooking_oven_program_heating_mode_top_bottom_heating
+ - cooking_oven_program_heating_mode_top_bottom_heating_eco
+ - cooking_oven_program_heating_mode_bottom_heating
+ - cooking_oven_program_heating_mode_pizza_setting
+ - cooking_oven_program_heating_mode_slow_cook
+ - cooking_oven_program_heating_mode_intensive_heat
+ - cooking_oven_program_heating_mode_keep_warm
+ - cooking_oven_program_heating_mode_preheat_ovenware
+ - cooking_oven_program_heating_mode_frozen_heatup_special
+ - cooking_oven_program_heating_mode_desiccation
+ - cooking_oven_program_heating_mode_defrost
+ - cooking_oven_program_heating_mode_proof
+ - cooking_oven_program_heating_mode_hot_air_30_steam
+ - cooking_oven_program_heating_mode_hot_air_60_steam
+ - cooking_oven_program_heating_mode_hot_air_80_steam
+ - cooking_oven_program_heating_mode_hot_air_100_steam
+ - cooking_oven_program_heating_mode_sabbath_programme
+ - cooking_oven_program_microwave_90_watt
+ - cooking_oven_program_microwave_180_watt
+ - cooking_oven_program_microwave_360_watt
+ - cooking_oven_program_microwave_600_watt
+ - cooking_oven_program_microwave_900_watt
+ - cooking_oven_program_microwave_1000_watt
+ - cooking_oven_program_microwave_max
+ - cooking_oven_program_heating_mode_warming_drawer
+ - laundry_care_washer_program_cotton
+ - laundry_care_washer_program_cotton_cotton_eco
+ - laundry_care_washer_program_cotton_eco_4060
+ - laundry_care_washer_program_cotton_colour
+ - laundry_care_washer_program_easy_care
+ - laundry_care_washer_program_mix
+ - laundry_care_washer_program_mix_night_wash
+ - laundry_care_washer_program_delicates_silk
+ - laundry_care_washer_program_wool
+ - laundry_care_washer_program_sensitive
+ - laundry_care_washer_program_auto_30
+ - laundry_care_washer_program_auto_40
+ - laundry_care_washer_program_auto_60
+ - laundry_care_washer_program_chiffon
+ - laundry_care_washer_program_curtains
+ - laundry_care_washer_program_dark_wash
+ - laundry_care_washer_program_dessous
+ - laundry_care_washer_program_monsoon
+ - laundry_care_washer_program_outdoor
+ - laundry_care_washer_program_plush_toy
+ - laundry_care_washer_program_shirts_blouses
+ - laundry_care_washer_program_sport_fitness
+ - laundry_care_washer_program_towels
+ - laundry_care_washer_program_water_proof
+ - laundry_care_washer_program_power_speed_59
+ - laundry_care_washer_program_super_153045_super_15
+ - laundry_care_washer_program_super_153045_super_1530
+ - laundry_care_washer_program_down_duvet_duvet
+ - laundry_care_washer_program_rinse_rinse_spin_drain
+ - laundry_care_washer_program_drum_clean
+ - laundry_care_washer_dryer_program_cotton
+ - laundry_care_washer_dryer_program_cotton_eco_4060
+ - laundry_care_washer_dryer_program_mix
+ - laundry_care_washer_dryer_program_easy_care
+ - laundry_care_washer_dryer_program_wash_and_dry_60
+ - laundry_care_washer_dryer_program_wash_and_dry_90
+ cleaning_robot_options:
+ collapsed: true
+ fields:
+ consumer_products_cleaning_robot_option_reference_map_id:
+ example: consumer_products_cleaning_robot_enum_type_available_maps_map1
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: available_maps
+ options:
+ - consumer_products_cleaning_robot_enum_type_available_maps_temp_map
+ - consumer_products_cleaning_robot_enum_type_available_maps_map1
+ - consumer_products_cleaning_robot_enum_type_available_maps_map2
+ - consumer_products_cleaning_robot_enum_type_available_maps_map3
+ consumer_products_cleaning_robot_option_cleaning_mode:
+ example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: cleaning_mode
+ options:
+ - consumer_products_cleaning_robot_enum_type_cleaning_modes_silent
+ - consumer_products_cleaning_robot_enum_type_cleaning_modes_standard
+ - consumer_products_cleaning_robot_enum_type_cleaning_modes_power
+ coffee_maker_options:
+ collapsed: true
+ fields:
+ consumer_products_coffee_maker_option_bean_amount:
+ example: consumer_products_coffee_maker_enum_type_bean_amount_normal
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: bean_amount
+ options:
+ - consumer_products_coffee_maker_enum_type_bean_amount_very_mild
+ - consumer_products_coffee_maker_enum_type_bean_amount_mild
+ - consumer_products_coffee_maker_enum_type_bean_amount_mild_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_normal
+ - consumer_products_coffee_maker_enum_type_bean_amount_normal_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_strong
+ - consumer_products_coffee_maker_enum_type_bean_amount_strong_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_very_strong
+ - consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_extra_strong
+ - consumer_products_coffee_maker_enum_type_bean_amount_double_shot
+ - consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_triple_shot
+ - consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus
+ - consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground
+ consumer_products_coffee_maker_option_fill_quantity:
+ example: 60
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ mode: box
+ unit_of_measurement: ml
+ consumer_products_coffee_maker_option_coffee_temperature:
+ example: consumer_products_coffee_maker_enum_type_coffee_temperature_88_c
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: coffee_temperature
+ options:
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_88_c
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_90_c
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_92_c
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_94_c
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_95_c
+ - consumer_products_coffee_maker_enum_type_coffee_temperature_96_c
+ consumer_products_coffee_maker_option_bean_container:
+ example: consumer_products_coffee_maker_enum_type_bean_container_selection_right
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: bean_container
+ options:
+ - consumer_products_coffee_maker_enum_type_bean_container_selection_right
+ - consumer_products_coffee_maker_enum_type_bean_container_selection_left
+ consumer_products_coffee_maker_option_flow_rate:
+ example: consumer_products_coffee_maker_enum_type_flow_rate_normal
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: flow_rate
+ options:
+ - consumer_products_coffee_maker_enum_type_flow_rate_normal
+ - consumer_products_coffee_maker_enum_type_flow_rate_intense
+ - consumer_products_coffee_maker_enum_type_flow_rate_intense_plus
+ consumer_products_coffee_maker_option_multiple_beverages:
+ example: false
+ required: false
+ selector:
+ boolean:
+ consumer_products_coffee_maker_option_coffee_milk_ratio:
+ example: consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: coffee_milk_ratio
+ options:
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent
+ - consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent
+ consumer_products_coffee_maker_option_hot_water_temperature:
+ example: consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: hot_water_temperature
+ options:
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f
+ - consumer_products_coffee_maker_enum_type_hot_water_temperature_max
+ dish_washer_options:
+ collapsed: true
+ fields:
+ b_s_h_common_option_start_in_relative:
+ example: 3600
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ mode: box
+ unit_of_measurement: s
+ dishcare_dishwasher_option_intensiv_zone:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_brilliance_dry:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_vario_speed_plus:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_silence_on_demand:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_half_load:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_extra_dry:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_hygiene_plus:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_eco_dry:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dishcare_dishwasher_option_zeolite_dry:
+ example: false
+ required: false
+ selector:
+ boolean:
+ dryer_options:
+ collapsed: true
+ fields:
+ laundry_care_dryer_option_drying_target:
+ example: laundry_care_dryer_enum_type_drying_target_iron_dry
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: drying_target
+ options:
+ - laundry_care_dryer_enum_type_drying_target_iron_dry
+ - laundry_care_dryer_enum_type_drying_target_gentle_dry
+ - laundry_care_dryer_enum_type_drying_target_cupboard_dry
+ - laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus
+ - laundry_care_dryer_enum_type_drying_target_extra_dry
+ hood_options:
+ collapsed: true
+ fields:
+ cooking_hood_option_venting_level:
+ example: cooking_hood_enum_type_stage_fan_stage01
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: venting_level
+ options:
+ - cooking_hood_enum_type_stage_fan_off
+ - cooking_hood_enum_type_stage_fan_stage_01
+ - cooking_hood_enum_type_stage_fan_stage_02
+ - cooking_hood_enum_type_stage_fan_stage_03
+ - cooking_hood_enum_type_stage_fan_stage_04
+ - cooking_hood_enum_type_stage_fan_stage_05
+ cooking_hood_option_intensive_level:
+ example: cooking_hood_enum_type_intensive_stage_intensive_stage1
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: intensive_level
+ options:
+ - cooking_hood_enum_type_intensive_stage_intensive_stage_off
+ - cooking_hood_enum_type_intensive_stage_intensive_stage1
+ - cooking_hood_enum_type_intensive_stage_intensive_stage2
+ oven_options:
+ collapsed: true
+ fields:
+ cooking_oven_option_setpoint_temperature:
+ example: 180
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ mode: box
+ unit_of_measurement: °C/°F
+ b_s_h_common_option_duration:
+ example: 900
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ mode: box
+ unit_of_measurement: s
+ cooking_oven_option_fast_pre_heat:
+ example: false
+ required: false
+ selector:
+ boolean:
+ warming_drawer_options:
+ collapsed: true
+ fields:
+ cooking_oven_option_warming_level:
+ example: cooking_oven_enum_type_warming_level_medium
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: warming_level
+ options:
+ - cooking_oven_enum_type_warming_level_low
+ - cooking_oven_enum_type_warming_level_medium
+ - cooking_oven_enum_type_warming_level_high
+ washer_options:
+ collapsed: true
+ fields:
+ laundry_care_washer_option_temperature:
+ example: laundry_care_washer_enum_type_temperature_g_c_40
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: washer_temperature
+ options:
+ - laundry_care_washer_enum_type_temperature_cold
+ - laundry_care_washer_enum_type_temperature_g_c_20
+ - laundry_care_washer_enum_type_temperature_g_c_30
+ - laundry_care_washer_enum_type_temperature_g_c_40
+ - laundry_care_washer_enum_type_temperature_g_c_50
+ - laundry_care_washer_enum_type_temperature_g_c_60
+ - laundry_care_washer_enum_type_temperature_g_c_70
+ - laundry_care_washer_enum_type_temperature_g_c_80
+ - laundry_care_washer_enum_type_temperature_g_c_90
+ - laundry_care_washer_enum_type_temperature_ul_cold
+ - laundry_care_washer_enum_type_temperature_ul_warm
+ - laundry_care_washer_enum_type_temperature_ul_hot
+ - laundry_care_washer_enum_type_temperature_ul_extra_hot
+ laundry_care_washer_option_spin_speed:
+ example: laundry_care_washer_enum_type_spin_speed_r_p_m800
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: spin_speed
+ options:
+ - laundry_care_washer_enum_type_spin_speed_off
+ - laundry_care_washer_enum_type_spin_speed_r_p_m_400
+ - laundry_care_washer_enum_type_spin_speed_r_p_m_600
+ - laundry_care_washer_enum_type_spin_speed_r_p_m_700
+ - laundry_care_washer_enum_type_spin_speed_r_p_m_800
+ - laundry_care_washer_enum_type_spin_speed_r_p_m_900
+ - laundry_care_washer_enum_type_spin_speed_r_p_m_1000
+ - laundry_care_washer_enum_type_spin_speed_r_p_m_1200
+ - laundry_care_washer_enum_type_spin_speed_r_p_m_1400
+ - laundry_care_washer_enum_type_spin_speed_r_p_m_1600
+ - laundry_care_washer_enum_type_spin_speed_ul_off
+ - laundry_care_washer_enum_type_spin_speed_ul_low
+ - laundry_care_washer_enum_type_spin_speed_ul_medium
+ - laundry_care_washer_enum_type_spin_speed_ul_high
+ b_s_h_common_option_finish_in_relative:
+ example: 3600
+ required: false
+ selector:
+ number:
+ min: 0
+ step: 1
+ mode: box
+ unit_of_measurement: s
+ laundry_care_washer_option_i_dos1_active:
+ example: false
+ required: false
+ selector:
+ boolean:
+ laundry_care_washer_option_i_dos2_active:
+ example: false
+ required: false
+ selector:
+ boolean:
+ laundry_care_washer_option_vario_perfect:
+ example: laundry_care_common_enum_type_vario_perfect_eco_perfect
+ required: false
+ selector:
+ select:
+ mode: dropdown
+ translation_key: vario_perfect
+ options:
+ - laundry_care_common_enum_type_vario_perfect_off
+ - laundry_care_common_enum_type_vario_perfect_eco_perfect
+ - laundry_care_common_enum_type_vario_perfect_speed_perfect
pause_program:
fields:
device_id:
diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json
index d07cfcdf854..d16459bc594 100644
--- a/homeassistant/components/home_connect/strings.json
+++ b/homeassistant/components/home_connect/strings.json
@@ -33,6 +33,12 @@
"appliance_not_found": {
"message": "Appliance for device ID {device_id} not found"
},
+ "device_entry_not_found": {
+ "message": "Device entry for device ID {device_id} not found"
+ },
+ "config_entry_not_found": {
+ "message": "Config entry for device ID {device_id} not found"
+ },
"turn_on_light": {
"message": "Error turning on {entity_id}: {error}"
},
@@ -95,16 +101,438 @@
},
"fetch_api_error": {
"message": "Error obtaining data from the API: {error}"
+ },
+ "required_program_or_one_option_at_least": {
+ "message": "A program or at least one of the possible options for a program should be specified"
+ },
+ "set_option": {
+ "message": "Error setting the option for the program: {error}"
}
},
"issues": {
- "deprecated_binary_common_door_sensor": {
- "title": "Deprecated binary door sensor detected in some automations or scripts",
- "description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue."
+ "home_connect_too_many_connected_paired_events": {
+ "title": "{appliance_name} sent too many connected or paired events",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]",
+ "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})."
+ }
+ }
+ }
+ },
+ "deprecated_time_alarm_clock_in_automations_scripts": {
+ "title": "Deprecated alarm clock entity detected in some automations or scripts",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::home_connect::issues::deprecated_time_alarm_clock_in_automations_scripts::title%]",
+ "description": "The alarm clock entity `{entity_id}`, which is deprecated because it's being moved to the `number` platform, is used in the following automations or scripts:\n{items}\n\nPlease, fix this issue by updating your automations or scripts to use the new `number` entity."
+ }
+ }
+ }
+ },
+ "deprecated_time_alarm_clock": {
+ "title": "Deprecated alarm clock entity",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::home_connect::issues::deprecated_time_alarm_clock::title%]",
+ "description": "The alarm clock entity `{entity_id}` is deprecated because it's being moved to the `number` platform.\n\nPlease use the new `number` entity."
+ }
+ }
+ }
+ },
+ "deprecated_command_actions": {
+ "title": "The command related actions are deprecated in favor of the new buttons",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::home_connect::issues::deprecated_command_actions::title%]",
+ "description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue."
+ }
+ }
+ }
+ },
+ "deprecated_program_switch_in_automations_scripts": {
+ "title": "Deprecated program switch detected in some automations or scripts",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::home_connect::issues::deprecated_program_switch_in_automations_scripts::title%]",
+ "description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue."
+ }
+ }
+ }
},
"deprecated_program_switch": {
- "title": "Deprecated program switch detected in some automations or scripts",
- "description": "Program switch are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use active program select entity to run the program without any additional option and get the current running program on the above automations or scripts to fix this issue."
+ "title": "Deprecated program switch entities",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::home_connect::issues::deprecated_program_switch::title%]",
+ "description": "The switch entity `{entity_id}` and all the other program switches are deprecated.\n\nPlease use the active program select entity instead."
+ }
+ }
+ }
+ },
+ "deprecated_set_program_and_option_actions": {
+ "title": "The executed action is deprecated",
+ "fix_flow": {
+ "step": {
+ "confirm": {
+ "title": "[%key:component::home_connect::issues::deprecated_set_program_and_option_actions::title%]",
+ "description": "`start_program`, `select_program`, `set_option_active`, and `set_option_selected` actions are deprecated and will be removed in the {remove_release} release, please use the `{new_action_key}` action instead. For the executed action:\n{deprecated_action_yaml}\nyou can do the following transformation using the recognized options:\n {new_action_yaml}\nIf the option is not in the recognized options, please submit an issue or a pull request requesting the addition of the option at {repo_link}."
+ }
+ }
+ }
+ }
+ },
+ "selector": {
+ "affects_to": {
+ "options": {
+ "active_program": "Active program",
+ "selected_program": "Selected program"
+ }
+ },
+ "programs": {
+ "options": {
+ "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all",
+ "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map",
+ "consumer_products_cleaning_robot_program_basic_go_home": "Go home",
+ "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto",
+ "consumer_products_coffee_maker_program_beverage_espresso": "Espresso",
+ "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio",
+ "consumer_products_coffee_maker_program_beverage_coffee": "Coffee",
+ "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee",
+ "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande",
+ "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato",
+ "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino",
+ "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato",
+ "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte",
+ "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth",
+ "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk",
+ "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner",
+ "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun",
+ "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange",
+ "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white",
+ "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait",
+ "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio",
+ "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi",
+ "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd",
+ "consumer_products_coffee_maker_program_coffee_world_galao": "Galao",
+ "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto",
+ "consumer_products_coffee_maker_program_coffee_world_americano": "Americano",
+ "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye",
+ "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye",
+ "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye",
+ "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water",
+ "dishcare_dishwasher_program_pre_rinse": "Pre_rinse",
+ "dishcare_dishwasher_program_auto_1": "Auto 1",
+ "dishcare_dishwasher_program_auto_2": "Auto 2",
+ "dishcare_dishwasher_program_auto_3": "Auto 3",
+ "dishcare_dishwasher_program_eco_50": "Eco 50ºC",
+ "dishcare_dishwasher_program_quick_45": "Quick 45ºC",
+ "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC",
+ "dishcare_dishwasher_program_normal_65": "Normal 65ºC",
+ "dishcare_dishwasher_program_glas_40": "Glass 40ºC",
+ "dishcare_dishwasher_program_glass_care": "Glass care",
+ "dishcare_dishwasher_program_night_wash": "Night wash",
+ "dishcare_dishwasher_program_quick_65": "Quick 65ºC",
+ "dishcare_dishwasher_program_normal_45": "Normal 45ºC",
+ "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC",
+ "dishcare_dishwasher_program_auto_half_load": "Auto half load",
+ "dishcare_dishwasher_program_intensiv_power": "Intensive power",
+ "dishcare_dishwasher_program_magic_daily": "Magic daily",
+ "dishcare_dishwasher_program_super_60": "Super 60ºC",
+ "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC",
+ "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC",
+ "dishcare_dishwasher_program_machine_care": "Machine care",
+ "dishcare_dishwasher_program_steam_fresh": "Steam fresh",
+ "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning",
+ "dishcare_dishwasher_program_mixed_load": "Mixed load",
+ "laundry_care_dryer_program_cotton": "Cotton",
+ "laundry_care_dryer_program_synthetic": "Synthetic",
+ "laundry_care_dryer_program_mix": "Mix",
+ "laundry_care_dryer_program_blankets": "Blankets",
+ "laundry_care_dryer_program_business_shirts": "Business shirts",
+ "laundry_care_dryer_program_down_feathers": "Down feathers",
+ "laundry_care_dryer_program_hygiene": "Hygiene",
+ "laundry_care_dryer_program_jeans": "Jeans",
+ "laundry_care_dryer_program_outdoor": "Outdoor",
+ "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh",
+ "laundry_care_dryer_program_towels": "Towels",
+ "laundry_care_dryer_program_delicates": "Delicates",
+ "laundry_care_dryer_program_super_40": "Super 40ºC",
+ "laundry_care_dryer_program_shirts_15": "Shirts 15ºC",
+ "laundry_care_dryer_program_pillow": "Pillow",
+ "laundry_care_dryer_program_anti_shrink": "Anti shrink",
+ "laundry_care_dryer_program_my_time_my_drying_time": "My drying time",
+ "laundry_care_dryer_program_time_cold": "Cold (variable time)",
+ "laundry_care_dryer_program_time_warm": "Warm (variable time)",
+ "laundry_care_dryer_program_in_basket": "In basket",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)",
+ "laundry_care_dryer_program_dessous": "Dessous",
+ "cooking_common_program_hood_automatic": "Automatic",
+ "cooking_common_program_hood_venting": "Venting",
+ "cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
+ "cooking_oven_program_heating_mode_pre_heating": "Pre-heating",
+ "cooking_oven_program_heating_mode_hot_air": "Hot air",
+ "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco",
+ "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling",
+ "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating",
+ "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco",
+ "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
+ "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting",
+ "cooking_oven_program_heating_mode_slow_cook": "Slow cook",
+ "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
+ "cooking_oven_program_heating_mode_keep_warm": "Keep warm",
+ "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware",
+ "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products",
+ "cooking_oven_program_heating_mode_desiccation": "Desiccation",
+ "cooking_oven_program_heating_mode_defrost": "Defrost",
+ "cooking_oven_program_heating_mode_proof": "Proof",
+ "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH",
+ "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH",
+ "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH",
+ "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
+ "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme",
+ "cooking_oven_program_microwave_90_watt": "90 Watt",
+ "cooking_oven_program_microwave_180_watt": "180 Watt",
+ "cooking_oven_program_microwave_360_watt": "360 Watt",
+ "cooking_oven_program_microwave_600_watt": "600 Watt",
+ "cooking_oven_program_microwave_900_watt": "900 Watt",
+ "cooking_oven_program_microwave_1000_watt": "1000 Watt",
+ "cooking_oven_program_microwave_max": "Max",
+ "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer",
+ "laundry_care_washer_program_cotton": "Cotton",
+ "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco",
+ "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC",
+ "laundry_care_washer_program_cotton_colour": "Cotton color",
+ "laundry_care_washer_program_easy_care": "Easy care",
+ "laundry_care_washer_program_mix": "Mix",
+ "laundry_care_washer_program_mix_night_wash": "Mix night wash",
+ "laundry_care_washer_program_delicates_silk": "Delicates silk",
+ "laundry_care_washer_program_wool": "Wool",
+ "laundry_care_washer_program_sensitive": "Sensitive",
+ "laundry_care_washer_program_auto_30": "Auto 30ºC",
+ "laundry_care_washer_program_auto_40": "Auto 40ºC",
+ "laundry_care_washer_program_auto_60": "Auto 60ºC",
+ "laundry_care_washer_program_chiffon": "Chiffon",
+ "laundry_care_washer_program_curtains": "Curtains",
+ "laundry_care_washer_program_dark_wash": "Dark wash",
+ "laundry_care_washer_program_dessous": "Dessous",
+ "laundry_care_washer_program_monsoon": "Monsoon",
+ "laundry_care_washer_program_outdoor": "Outdoor",
+ "laundry_care_washer_program_plush_toy": "Plush toy",
+ "laundry_care_washer_program_shirts_blouses": "Shirts blouses",
+ "laundry_care_washer_program_sport_fitness": "Sport fitness",
+ "laundry_care_washer_program_towels": "Towels",
+ "laundry_care_washer_program_water_proof": "Water proof",
+ "laundry_care_washer_program_power_speed_59": "Power speed <59 min",
+ "laundry_care_washer_program_super_153045_super_15": "Super 15 min",
+ "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min",
+ "laundry_care_washer_program_down_duvet_duvet": "Down duvet",
+ "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain",
+ "laundry_care_washer_program_drum_clean": "Drum clean",
+ "laundry_care_washer_dryer_program_cotton": "Cotton",
+ "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60ºC",
+ "laundry_care_washer_dryer_program_mix": "Mix",
+ "laundry_care_washer_dryer_program_easy_care": "Easy care",
+ "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)",
+ "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)"
+ }
+ },
+ "available_maps": {
+ "options": {
+ "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "Temporary map",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map1": "Map 1",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map2": "Map 2",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map3": "Map 3"
+ }
+ },
+ "cleaning_mode": {
+ "options": {
+ "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "Silent",
+ "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "Standard",
+ "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "Power"
+ }
+ },
+ "bean_amount": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "Very mild",
+ "consumer_products_coffee_maker_enum_type_bean_amount_mild": "Mild",
+ "consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "Mild +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_normal": "Normal",
+ "consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "Normal +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_strong": "Strong",
+ "consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "Strong +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "Very strong",
+ "consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "Very strong +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "Extra strong",
+ "consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "Double shot",
+ "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "Double shot +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "Double shot ++",
+ "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "Triple shot",
+ "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "Triple shot +",
+ "consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "Coffee ground"
+ }
+ },
+ "coffee_temperature": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "88ºC",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "90ºC",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "92ºC",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "94ºC",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "95ºC",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "96ºC"
+ }
+ },
+ "bean_container": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "Right",
+ "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "Left"
+ }
+ },
+ "flow_rate": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_flow_rate_normal": "Normal",
+ "consumer_products_coffee_maker_enum_type_flow_rate_intense": "Intense",
+ "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "Intense +"
+ }
+ },
+ "coffee_milk_ratio": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "10%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "20%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "25%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "30%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "40%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "50%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "55%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "60%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "65%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "67%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "70%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "75%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "80%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "85%",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "90%"
+ }
+ },
+ "hot_water_temperature": {
+ "options": {
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "White tea",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "Green tea",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "Black tea",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "50ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "55ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "60ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "65ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "70ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "75ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "80ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "85ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "90ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "95ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "97ºC",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "122ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "131ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "140ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "149ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "158ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "167ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "176ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "185ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "194ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "203ºF",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "Max"
+ }
+ },
+ "drying_target": {
+ "options": {
+ "laundry_care_dryer_enum_type_drying_target_iron_dry": "Iron dry",
+ "laundry_care_dryer_enum_type_drying_target_gentle_dry": "Gentle dry",
+ "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "Cupboard dry",
+ "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "Cupboard dry +",
+ "laundry_care_dryer_enum_type_drying_target_extra_dry": "Extra dry"
+ }
+ },
+ "venting_level": {
+ "options": {
+ "cooking_hood_enum_type_stage_fan_off": "Fan off",
+ "cooking_hood_enum_type_stage_fan_stage_01": "Fan stage 1",
+ "cooking_hood_enum_type_stage_fan_stage_02": "Fan stage 2",
+ "cooking_hood_enum_type_stage_fan_stage_03": "Fan stage 3",
+ "cooking_hood_enum_type_stage_fan_stage_04": "Fan stage 4",
+ "cooking_hood_enum_type_stage_fan_stage_05": "Fan stage 5"
+ }
+ },
+ "intensive_level": {
+ "options": {
+ "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "Intensive stage off",
+ "cooking_hood_enum_type_intensive_stage_intensive_stage1": "Intensive stage 1",
+ "cooking_hood_enum_type_intensive_stage_intensive_stage2": "Intensive stage 2"
+ }
+ },
+ "warming_level": {
+ "options": {
+ "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]",
+ "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]",
+ "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]"
+ }
+ },
+ "washer_temperature": {
+ "options": {
+ "laundry_care_washer_enum_type_temperature_cold": "Cold",
+ "laundry_care_washer_enum_type_temperature_g_c_20": "20ºC clothes",
+ "laundry_care_washer_enum_type_temperature_g_c_30": "30ºC clothes",
+ "laundry_care_washer_enum_type_temperature_g_c_40": "40ºC clothes",
+ "laundry_care_washer_enum_type_temperature_g_c_50": "50ºC clothes",
+ "laundry_care_washer_enum_type_temperature_g_c_60": "60ºC clothes",
+ "laundry_care_washer_enum_type_temperature_g_c_70": "70ºC clothes",
+ "laundry_care_washer_enum_type_temperature_g_c_80": "80ºC clothes",
+ "laundry_care_washer_enum_type_temperature_g_c_90": "90ºC clothes",
+ "laundry_care_washer_enum_type_temperature_ul_cold": "Cold",
+ "laundry_care_washer_enum_type_temperature_ul_warm": "Warm",
+ "laundry_care_washer_enum_type_temperature_ul_hot": "Hot",
+ "laundry_care_washer_enum_type_temperature_ul_extra_hot": "Extra hot"
+ }
+ },
+ "spin_speed": {
+ "options": {
+ "laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "400 rpm",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "600 rpm",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "700 rpm",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_800": "800 rpm",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_900": "900 rpm",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "1000 rpm",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "1200 rpm",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "1400 rpm",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "1600 rpm",
+ "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]",
+ "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]",
+ "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]",
+ "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]"
+ }
+ },
+ "vario_perfect": {
+ "options": {
+ "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]",
+ "laundry_care_common_enum_type_vario_perfect_eco_perfect": "Eco perfect",
+ "laundry_care_common_enum_type_vario_perfect_speed_perfect": "Speed perfect"
+ }
}
},
"services": {
@@ -113,8 +541,8 @@
"description": "Selects a program and starts it.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "ID of the device."
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
},
"program": { "name": "Program", "description": "Program to select." },
"key": { "name": "Option key", "description": "Key of the option." },
@@ -130,8 +558,8 @@
"description": "Selects a program without starting it.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
},
"program": {
"name": "[%key:component::home_connect::services::start_program::fields::program::name%]",
@@ -151,13 +579,197 @@
}
}
},
+ "set_program_and_options": {
+ "name": "Set program and options",
+ "description": "Starts or selects a program with options or sets the options for the active or the selected program.",
+ "fields": {
+ "device_id": {
+ "name": "Device ID",
+ "description": "ID of the device."
+ },
+ "affects_to": {
+ "name": "Affects to",
+ "description": "Selects if the program affected by the action should be the active or the selected program."
+ },
+ "program": {
+ "name": "Program",
+ "description": "Program to select"
+ },
+ "consumer_products_cleaning_robot_option_reference_map_id": {
+ "name": "Reference map ID",
+ "description": "Defines which reference map is to be used."
+ },
+ "consumer_products_cleaning_robot_option_cleaning_mode": {
+ "name": "Cleaning mode",
+ "description": "Defines the favoured cleaning mode."
+ },
+ "consumer_products_coffee_maker_option_bean_amount": {
+ "name": "Bean amount",
+ "description": "Describes the amount of coffee beans used in a coffee machine program."
+ },
+ "consumer_products_coffee_maker_option_fill_quantity": {
+ "name": "Fill quantity",
+ "description": "Describes the amount of water (in ml) used in a coffee machine program."
+ },
+ "consumer_products_coffee_maker_option_coffee_temperature": {
+ "name": "Coffee Temperature",
+ "description": "Describes the coffee temperature used in a coffee machine program."
+ },
+ "consumer_products_coffee_maker_option_bean_container": {
+ "name": "Bean container",
+ "description": "Defines the preferred bean container."
+ },
+ "consumer_products_coffee_maker_option_flow_rate": {
+ "name": "Flow rate",
+ "description": "Defines the water-coffee contact time. The duration extends to coffee intensity."
+ },
+ "consumer_products_coffee_maker_option_multiple_beverages": {
+ "name": "Multiple beverages",
+ "description": "Defines if double dispensing is enabled."
+ },
+ "consumer_products_coffee_maker_option_coffee_milk_ratio": {
+ "name": "Coffee milk ratio",
+ "description": "Defines the amount of milk."
+ },
+ "consumer_products_coffee_maker_option_hot_water_temperature": {
+ "name": "Hot water temperature",
+ "description": "Defines the temperature suitable for the type of tea."
+ },
+ "b_s_h_common_option_start_in_relative": {
+ "name": "Start in relative",
+ "description": "Defines in how many time the program should start."
+ },
+ "dishcare_dishwasher_option_intensiv_zone": {
+ "name": "Intensive zone",
+ "description": "Defines if the cleaning is done with higher spray pressure on the lower basket for very dirty pots and pans."
+ },
+ "dishcare_dishwasher_option_brilliance_dry": {
+ "name": "Brilliance dry",
+ "description": "Defines if the program sequence is optimized with a special drying cycle to ensure more shine on glasses and plastic items."
+ },
+ "dishcare_dishwasher_option_vario_speed_plus": {
+ "name": "Vario speed +",
+ "description": "Defines if the program run time is reduced by up to 66% with the usual optimum cleaning and drying."
+ },
+ "dishcare_dishwasher_option_silence_on_demand": {
+ "name": "Silence on demand",
+ "description": "Defines if the extra silent mode is activated for a selected period of time."
+ },
+ "dishcare_dishwasher_option_half_load": {
+ "name": "Half load",
+ "description": "Defines if economical cleaning is enabled for smaller loads. This reduces energy and water consumption and also saves time. The utensils can be placed in the upper and lower baskets."
+ },
+ "dishcare_dishwasher_option_extra_dry": {
+ "name": "Extra dry",
+ "description": "Defines if improved drying for glasses and plasticware is enabled."
+ },
+ "dishcare_dishwasher_option_hygiene_plus": {
+ "name": "Hygiene +",
+ "description": "Defines if the cleaning is done with increased temperature. This ensures maximum hygienic cleanliness for regular use."
+ },
+ "dishcare_dishwasher_option_eco_dry": {
+ "name": "Eco dry",
+ "description": "Defines if the door is opened automatically for extra energy efficient and effective drying."
+ },
+ "dishcare_dishwasher_option_zeolite_dry": {
+ "name": "Zeolite dry",
+ "description": "Defines if the program sequence is optimized with special drying cycle ensures improved drying for glasses, plates and plasticware."
+ },
+ "laundry_care_dryer_option_drying_target": {
+ "name": "Drying target",
+ "description": "Describes the drying target for a dryer program."
+ },
+ "cooking_hood_option_venting_level": {
+ "name": "Venting level",
+ "description": "Defines the required fan setting."
+ },
+ "cooking_hood_option_intensive_level": {
+ "name": "Intensive level",
+ "description": "Defines the intensive setting."
+ },
+ "cooking_oven_option_setpoint_temperature": {
+ "name": "Setpoint temperature",
+ "description": "Defines the target cavity temperature, which will be hold by the oven."
+ },
+ "b_s_h_common_option_duration": {
+ "name": "Duration",
+ "description": "Defines the run-time of the program. Afterwards, the appliance is stopped."
+ },
+ "cooking_oven_option_fast_pre_heat": {
+ "name": "Fast pre-heat",
+ "description": "Defines if the cooking compartment is heated up quickly. Please note that the setpoint temperature has to be equal to or higher than 100 °C or 212 °F. Otherwise, the fast pre-heat option is not activated."
+ },
+ "cooking_oven_option_warming_level": {
+ "name": "Warming level",
+ "description": "Defines the level of the warming drawer."
+ },
+ "laundry_care_washer_option_temperature": {
+ "name": "Temperature",
+ "description": "Defines the temperature of the washing program."
+ },
+ "laundry_care_washer_option_spin_speed": {
+ "name": "Spin speed",
+ "description": "Defines the spin speed of a washer program."
+ },
+ "b_s_h_common_option_finish_in_relative": {
+ "name": "Finish in relative",
+ "description": "Defines when the program should end in seconds."
+ },
+ "laundry_care_washer_option_i_dos1_active": {
+ "name": "i-Dos 1 Active",
+ "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 1)"
+ },
+ "laundry_care_washer_option_i_dos2_active": {
+ "name": "i-Dos 2 Active",
+ "description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)"
+ },
+ "laundry_care_washer_option_vario_perfect": {
+ "name": "Vario perfect",
+ "description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect)."
+ }
+ },
+ "sections": {
+ "cleaning_robot_options": {
+ "name": "Cleaning robot options",
+ "description": "Options for cleaning robots."
+ },
+ "coffee_maker_options": {
+ "name": "Coffee maker options",
+ "description": "Options for coffee makers."
+ },
+ "dish_washer_options": {
+ "name": "Dishwasher options",
+ "description": "Options for dishwashers."
+ },
+ "dryer_options": {
+ "name": "Dryer options",
+ "description": "Options for dryers (and washer dryers)."
+ },
+ "hood_options": {
+ "name": "Hood options",
+ "description": "Options for hoods."
+ },
+ "oven_options": {
+ "name": "Oven options",
+ "description": "Options for ovens."
+ },
+ "warming_drawer_options": {
+ "name": "Warming drawer options",
+ "description": "Options for warming drawers."
+ },
+ "washer_options": {
+ "name": "Washer options",
+ "description": "Options for washers (and washer dryers)."
+ }
+ }
+ },
"pause_program": {
"name": "Pause program",
"description": "Pauses the current running program.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
}
}
},
@@ -166,8 +778,8 @@
"description": "Resumes a paused program.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
}
}
},
@@ -176,8 +788,8 @@
"description": "Sets an option for the active program.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
},
"key": {
"name": "Key",
@@ -191,18 +803,18 @@
},
"set_option_selected": {
"name": "Set selected program option",
- "description": "Sets an option for the selected program.",
+ "description": "Sets options for the selected program.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
},
"key": {
- "name": "Key",
+ "name": "[%key:component::home_connect::services::start_program::fields::key::name%]",
"description": "[%key:component::home_connect::services::start_program::fields::key::description%]"
},
"value": {
- "name": "Value",
+ "name": "[%key:component::home_connect::services::start_program::fields::value::name%]",
"description": "[%key:component::home_connect::services::start_program::fields::value::description%]"
}
}
@@ -212,8 +824,8 @@
"description": "Changes a setting.",
"fields": {
"device_id": {
- "name": "Device ID",
- "description": "[%key:component::home_connect::services::start_program::fields::device_id::description%]"
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::name%]",
+ "description": "[%key:component::home_connect::services::set_program_and_options::fields::device_id::description%]"
},
"key": { "name": "Key", "description": "Key of the setting." },
"value": { "name": "Value", "description": "Value of the setting." }
@@ -250,14 +862,49 @@
"lost": {
"name": "Lost"
},
+ "bottle_cooler_door": {
+ "name": "Bottle cooler door"
+ },
+ "common_chiller_door": {
+ "name": "Common chiller door"
+ },
"chiller_door": {
"name": "Chiller door"
},
+ "left_chiller_door": {
+ "name": "Left chiller door"
+ },
+ "right_chiller_door": {
+ "name": "Right chiller door"
+ },
+ "flex_compartment_door": {
+ "name": "Flex compartment door"
+ },
"freezer_door": {
"name": "Freezer door"
},
"refrigerator_door": {
"name": "Refrigerator door"
+ },
+ "wine_compartment_door": {
+ "name": "Wine compartment door"
+ }
+ },
+ "button": {
+ "open_door": {
+ "name": "Open door"
+ },
+ "partly_open_door": {
+ "name": "Partly open door"
+ },
+ "pause_program": {
+ "name": "Pause program"
+ },
+ "resume_program": {
+ "name": "Resume program"
+ },
+ "stop_program": {
+ "name": "Stop program"
}
},
"light": {
@@ -275,6 +922,9 @@
}
},
"number": {
+ "alarm_clock": {
+ "name": "Alarm clock"
+ },
"refrigerator_setpoint_temperature": {
"name": "Refrigerator temperature"
},
@@ -301,325 +951,571 @@
},
"wine_compartment_3_setpoint_temperature": {
"name": "Wine compartment 3 temperature"
+ },
+ "color_temperature_percent": {
+ "name": "Functional light color temperature percent"
+ },
+ "washer_i_dos_1_base_level": {
+ "name": "i-Dos 1 base level"
+ },
+ "washer_i_dos_2_base_level": {
+ "name": "i-Dos 2 base level"
+ },
+ "duration": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_duration::name%]"
+ },
+ "start_in_relative": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]"
+ },
+ "finish_in_relative": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]"
+ },
+ "fill_quantity": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_fill_quantity::name%]"
+ },
+ "setpoint_temperature": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_setpoint_temperature::name%]"
}
},
"select": {
"selected_program": {
"name": "Selected program",
"state": {
- "consumer_products_cleaning_robot_program_cleaning_clean_all": "Clean all",
- "consumer_products_cleaning_robot_program_cleaning_clean_map": "Clean map",
- "consumer_products_cleaning_robot_program_basic_go_home": "Go home",
- "consumer_products_coffee_maker_program_beverage_ristretto": "Ristretto",
- "consumer_products_coffee_maker_program_beverage_espresso": "Espresso",
- "consumer_products_coffee_maker_program_beverage_espresso_doppio": "Espresso doppio",
- "consumer_products_coffee_maker_program_beverage_coffee": "Coffee",
- "consumer_products_coffee_maker_program_beverage_x_l_coffee": "XL coffee",
- "consumer_products_coffee_maker_program_beverage_caffe_grande": "Caffe grande",
- "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "Espresso macchiato",
- "consumer_products_coffee_maker_program_beverage_cappuccino": "Cappuccino",
- "consumer_products_coffee_maker_program_beverage_latte_macchiato": "Latte macchiato",
- "consumer_products_coffee_maker_program_beverage_caffe_latte": "Caffe latte",
- "consumer_products_coffee_maker_program_beverage_milk_froth": "Milk froth",
- "consumer_products_coffee_maker_program_beverage_warm_milk": "Warm milk",
- "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "Kleiner brauner",
- "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "Grosser brauner",
- "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "Verlaengerter",
- "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "Verlaengerter braun",
- "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "Wiener melange",
- "consumer_products_coffee_maker_program_coffee_world_flat_white": "Flat white",
- "consumer_products_coffee_maker_program_coffee_world_cortado": "Cortado",
- "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "Cafe cortado",
- "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "Cafe con leche",
- "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "Cafe au lait",
- "consumer_products_coffee_maker_program_coffee_world_doppio": "Doppio",
- "consumer_products_coffee_maker_program_coffee_world_kaapi": "Kaapi",
- "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "Koffie verkeerd",
- "consumer_products_coffee_maker_program_coffee_world_galao": "Galao",
- "consumer_products_coffee_maker_program_coffee_world_garoto": "Garoto",
- "consumer_products_coffee_maker_program_coffee_world_americano": "Americano",
- "consumer_products_coffee_maker_program_coffee_world_red_eye": "Red eye",
- "consumer_products_coffee_maker_program_coffee_world_black_eye": "Black eye",
- "consumer_products_coffee_maker_program_coffee_world_dead_eye": "Dead eye",
- "consumer_products_coffee_maker_program_beverage_hot_water": "Hot water",
- "dishcare_dishwasher_program_pre_rinse": "Pre_rinse",
- "dishcare_dishwasher_program_auto_1": "Auto 1",
- "dishcare_dishwasher_program_auto_2": "Auto 2",
- "dishcare_dishwasher_program_auto_3": "Auto 3",
- "dishcare_dishwasher_program_eco_50": "Eco 50ºC",
- "dishcare_dishwasher_program_quick_45": "Quick 45ºC",
- "dishcare_dishwasher_program_intensiv_70": "Intensive 70ºC",
- "dishcare_dishwasher_program_normal_65": "Normal 65ºC",
- "dishcare_dishwasher_program_glas_40": "Glass 40ºC",
- "dishcare_dishwasher_program_glass_care": "Glass care",
- "dishcare_dishwasher_program_night_wash": "Night wash",
- "dishcare_dishwasher_program_quick_65": "Quick 65ºC",
- "dishcare_dishwasher_program_normal_45": "Normal 45ºC",
- "dishcare_dishwasher_program_intensiv_45": "Intensive 45ºC",
- "dishcare_dishwasher_program_auto_half_load": "Auto half load",
- "dishcare_dishwasher_program_intensiv_power": "Intensive power",
- "dishcare_dishwasher_program_magic_daily": "Magic daily",
- "dishcare_dishwasher_program_super_60": "Super 60ºC",
- "dishcare_dishwasher_program_kurz_60": "Kurz 60ºC",
- "dishcare_dishwasher_program_express_sparkle_65": "Express sparkle 65ºC",
- "dishcare_dishwasher_program_machine_care": "Machine care",
- "dishcare_dishwasher_program_steam_fresh": "Steam fresh",
- "dishcare_dishwasher_program_maximum_cleaning": "Maximum cleaning",
- "dishcare_dishwasher_program_mixed_load": "Mixed load",
- "laundry_care_dryer_program_cotton": "Cotton",
- "laundry_care_dryer_program_synthetic": "Synthetic",
- "laundry_care_dryer_program_mix": "Mix",
- "laundry_care_dryer_program_blankets": "Blankets",
- "laundry_care_dryer_program_business_shirts": "Business shirts",
- "laundry_care_dryer_program_down_feathers": "Down feathers",
- "laundry_care_dryer_program_hygiene": "Hygiene",
- "laundry_care_dryer_program_jeans": "Jeans",
- "laundry_care_dryer_program_outdoor": "Outdoor",
- "laundry_care_dryer_program_synthetic_refresh": "Synthetic refresh",
- "laundry_care_dryer_program_towels": "Towels",
- "laundry_care_dryer_program_delicates": "Delicates",
- "laundry_care_dryer_program_super_40": "Super 40ºC",
- "laundry_care_dryer_program_shirts_15": "Shirts 15ºC",
- "laundry_care_dryer_program_pillow": "Pillow",
- "laundry_care_dryer_program_anti_shrink": "Anti shrink",
- "laundry_care_dryer_program_my_time_my_drying_time": "My drying time",
- "laundry_care_dryer_program_time_cold": "Cold (variable time)",
- "laundry_care_dryer_program_time_warm": "Warm (variable time)",
- "laundry_care_dryer_program_in_basket": "In basket",
- "laundry_care_dryer_program_time_cold_fix_time_cold_20": "Cold (20 min)",
- "laundry_care_dryer_program_time_cold_fix_time_cold_30": "Cold (30 min)",
- "laundry_care_dryer_program_time_cold_fix_time_cold_60": "Cold (60 min)",
- "laundry_care_dryer_program_time_warm_fix_time_warm_30": "Warm (30 min)",
- "laundry_care_dryer_program_time_warm_fix_time_warm_40": "Warm (40 min)",
- "laundry_care_dryer_program_time_warm_fix_time_warm_60": "Warm (60 min)",
- "laundry_care_dryer_program_dessous": "Dessous",
- "cooking_common_program_hood_automatic": "Automatic",
- "cooking_common_program_hood_venting": "Venting",
- "cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
- "cooking_oven_program_heating_mode_pre_heating": "Pre-heating",
- "cooking_oven_program_heating_mode_hot_air": "Hot air",
- "cooking_oven_program_heating_mode_hot_air_eco": "Hot air eco",
- "cooking_oven_program_heating_mode_hot_air_grilling": "Hot air grilling",
- "cooking_oven_program_heating_mode_top_bottom_heating": "Top bottom heating",
- "cooking_oven_program_heating_mode_top_bottom_heating_eco": "Top bottom heating eco",
- "cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
- "cooking_oven_program_heating_mode_pizza_setting": "Pizza setting",
- "cooking_oven_program_heating_mode_slow_cook": "Slow cook",
- "cooking_oven_program_heating_mode_intensive_heat": "Intensive heat",
- "cooking_oven_program_heating_mode_keep_warm": "Keep warm",
- "cooking_oven_program_heating_mode_preheat_ovenware": "Preheat ovenware",
- "cooking_oven_program_heating_mode_frozen_heatup_special": "Special Heat-Up for frozen products",
- "cooking_oven_program_heating_mode_desiccation": "Desiccation",
- "cooking_oven_program_heating_mode_defrost": "Defrost",
- "cooking_oven_program_heating_mode_proof": "Proof",
- "cooking_oven_program_heating_mode_hot_air_30_steam": "Hot air + 30 RH",
- "cooking_oven_program_heating_mode_hot_air_60_steam": "Hot air + 60 RH",
- "cooking_oven_program_heating_mode_hot_air_80_steam": "Hot air + 80 RH",
- "cooking_oven_program_heating_mode_hot_air_100_steam": "Hot air + 100 RH",
- "cooking_oven_program_heating_mode_sabbath_programme": "Sabbath programme",
- "cooking_oven_program_microwave_90_watt": "90 Watt",
- "cooking_oven_program_microwave_180_watt": "180 Watt",
- "cooking_oven_program_microwave_360_watt": "360 Watt",
- "cooking_oven_program_microwave_600_watt": "600 Watt",
- "cooking_oven_program_microwave_900_watt": "900 Watt",
- "cooking_oven_program_microwave_1000_watt": "1000 Watt",
- "cooking_oven_program_microwave_max": "Max",
- "cooking_oven_program_heating_mode_warming_drawer": "Warming drawer",
- "laundry_care_washer_program_cotton": "Cotton",
- "laundry_care_washer_program_cotton_cotton_eco": "Cotton eco",
- "laundry_care_washer_program_cotton_eco_4060": "Cotton eco 40/60ºC",
- "laundry_care_washer_program_cotton_colour": "Cotton color",
- "laundry_care_washer_program_easy_care": "Easy care",
- "laundry_care_washer_program_mix": "Mix",
- "laundry_care_washer_program_mix_night_wash": "Mix night wash",
- "laundry_care_washer_program_delicates_silk": "Delicates silk",
- "laundry_care_washer_program_wool": "Wool",
- "laundry_care_washer_program_sensitive": "Sensitive",
- "laundry_care_washer_program_auto_30": "Auto 30ºC",
- "laundry_care_washer_program_auto_40": "Auto 40ºC",
- "laundry_care_washer_program_auto_60": "Auto 60ºC",
- "laundry_care_washer_program_chiffon": "Chiffon",
- "laundry_care_washer_program_curtains": "Curtains",
- "laundry_care_washer_program_dark_wash": "Dark wash",
- "laundry_care_washer_program_dessous": "Dessous",
- "laundry_care_washer_program_monsoon": "Monsoon",
- "laundry_care_washer_program_outdoor": "Outdoor",
- "laundry_care_washer_program_plush_toy": "Plush toy",
- "laundry_care_washer_program_shirts_blouses": "Shirts blouses",
- "laundry_care_washer_program_sport_fitness": "Sport fitness",
- "laundry_care_washer_program_towels": "Towels",
- "laundry_care_washer_program_water_proof": "Water proof",
- "laundry_care_washer_program_power_speed_59": "Power speed <60 min",
- "laundry_care_washer_program_super_153045_super_15": "Super 15 min",
- "laundry_care_washer_program_super_153045_super_1530": "Super 15/30 min",
- "laundry_care_washer_program_down_duvet_duvet": "Down duvet",
- "laundry_care_washer_program_rinse_rinse_spin_drain": "Rinse spin drain",
- "laundry_care_washer_program_drum_clean": "Drum clean",
- "laundry_care_washer_dryer_program_cotton": "Cotton",
- "laundry_care_washer_dryer_program_cotton_eco_4060": "Cotton eco 40/60 ºC",
- "laundry_care_washer_dryer_program_mix": "Mix",
- "laundry_care_washer_dryer_program_easy_care": "Easy care",
- "laundry_care_washer_dryer_program_wash_and_dry_60": "Wash and dry (60 min)",
- "laundry_care_washer_dryer_program_wash_and_dry_90": "Wash and dry (90 min)"
+ "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_all%]",
+ "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_map%]",
+ "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_basic_go_home%]",
+ "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_ristretto%]",
+ "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_doppio%]",
+ "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_x_l_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_grande%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_cappuccino%]",
+ "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_latte_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_latte%]",
+ "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_milk_froth%]",
+ "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_warm_milk%]",
+ "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]",
+ "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]",
+ "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_flat_white%]",
+ "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]",
+ "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_doppio%]",
+ "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kaapi%]",
+ "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]",
+ "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_galao%]",
+ "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_garoto%]",
+ "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_americano%]",
+ "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_red_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_black_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_dead_eye%]",
+ "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_hot_water%]",
+ "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_pre_rinse%]",
+ "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]",
+ "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]",
+ "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]",
+ "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_eco_50%]",
+ "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_45%]",
+ "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]",
+ "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_65%]",
+ "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glas_40%]",
+ "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glass_care%]",
+ "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_night_wash%]",
+ "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]",
+ "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_45%]",
+ "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]",
+ "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_half_load%]",
+ "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]",
+ "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_magic_daily%]",
+ "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]",
+ "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]",
+ "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_express_sparkle_65%]",
+ "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]",
+ "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]",
+ "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_maximum_cleaning%]",
+ "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_mixed_load%]",
+ "laundry_care_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_cotton%]",
+ "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic%]",
+ "laundry_care_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_mix%]",
+ "laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]",
+ "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]",
+ "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_down_feathers%]",
+ "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_hygiene%]",
+ "laundry_care_dryer_program_jeans": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_jeans%]",
+ "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_outdoor%]",
+ "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic_refresh%]",
+ "laundry_care_dryer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_towels%]",
+ "laundry_care_dryer_program_delicates": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_delicates%]",
+ "laundry_care_dryer_program_super_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_super_40%]",
+ "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_shirts_15%]",
+ "laundry_care_dryer_program_pillow": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_pillow%]",
+ "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]",
+ "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_my_time_my_drying_time%]",
+ "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold%]",
+ "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm%]",
+ "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_in_basket%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_20%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_30%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_60%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_30%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_40%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_60%]",
+ "laundry_care_dryer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_dessous%]",
+ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
+ "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
+ "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
+ "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pre_heating%]",
+ "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
+ "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
+ "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating_eco%]",
+ "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
+ "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pizza_setting%]",
+ "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_slow_cook%]",
+ "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
+ "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
+ "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_preheat_ovenware%]",
+ "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
+ "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
+ "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
+ "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_proof%]",
+ "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
+ "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_sabbath_programme%]",
+ "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
+ "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]",
+ "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]",
+ "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]",
+ "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
+ "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]",
+ "cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]",
+ "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_warming_drawer%]",
+ "laundry_care_washer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton%]",
+ "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_cotton_eco%]",
+ "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_eco_4060%]",
+ "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_colour%]",
+ "laundry_care_washer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_easy_care%]",
+ "laundry_care_washer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix%]",
+ "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix_night_wash%]",
+ "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_delicates_silk%]",
+ "laundry_care_washer_program_wool": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_wool%]",
+ "laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
+ "laundry_care_washer_program_auto_30": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_30%]",
+ "laundry_care_washer_program_auto_40": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_40%]",
+ "laundry_care_washer_program_auto_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_60%]",
+ "laundry_care_washer_program_chiffon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_chiffon%]",
+ "laundry_care_washer_program_curtains": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_curtains%]",
+ "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dark_wash%]",
+ "laundry_care_washer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dessous%]",
+ "laundry_care_washer_program_monsoon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_monsoon%]",
+ "laundry_care_washer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_outdoor%]",
+ "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_plush_toy%]",
+ "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
+ "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
+ "laundry_care_washer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_towels%]",
+ "laundry_care_washer_program_water_proof": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_water_proof%]",
+ "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_power_speed_59%]",
+ "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
+ "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
+ "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_down_duvet_duvet%]",
+ "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
+ "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_drum_clean%]",
+ "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton%]",
+ "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton_eco_4060%]",
+ "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_mix%]",
+ "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_easy_care%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]"
}
},
"active_program": {
"name": "Active program",
"state": {
- "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_all%]",
- "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_cleaning_clean_map%]",
- "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_cleaning_robot_program_basic_go_home%]",
- "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_ristretto%]",
- "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso%]",
- "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_doppio%]",
- "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_coffee%]",
- "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_x_l_coffee%]",
- "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_grande%]",
- "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]",
- "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_cappuccino%]",
- "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_latte_macchiato%]",
- "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_caffe_latte%]",
- "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_milk_froth%]",
- "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_warm_milk%]",
- "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]",
- "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]",
- "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]",
- "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]",
- "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]",
- "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_flat_white%]",
- "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cortado%]",
- "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]",
- "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]",
- "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]",
- "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_doppio%]",
- "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_kaapi%]",
- "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]",
- "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_galao%]",
- "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_garoto%]",
- "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_americano%]",
- "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_red_eye%]",
- "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_black_eye%]",
- "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_coffee_world_dead_eye%]",
- "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::entity::select::selected_program::state::consumer_products_coffee_maker_program_beverage_hot_water%]",
- "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_pre_rinse%]",
- "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_1%]",
- "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_2%]",
- "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_3%]",
- "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_eco_50%]",
- "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_45%]",
- "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_70%]",
- "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_65%]",
- "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glas_40%]",
- "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_glass_care%]",
- "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_night_wash%]",
- "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_quick_65%]",
- "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_normal_45%]",
- "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_45%]",
- "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_auto_half_load%]",
- "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_intensiv_power%]",
- "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_magic_daily%]",
- "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_super_60%]",
- "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_kurz_60%]",
- "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_express_sparkle_65%]",
- "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_machine_care%]",
- "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_steam_fresh%]",
- "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_maximum_cleaning%]",
- "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::entity::select::selected_program::state::dishcare_dishwasher_program_mixed_load%]",
- "laundry_care_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_cotton%]",
- "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic%]",
- "laundry_care_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_mix%]",
- "laundry_care_dryer_program_blankets": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_blankets%]",
- "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_business_shirts%]",
- "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_down_feathers%]",
- "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_hygiene%]",
- "laundry_care_dryer_program_jeans": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_jeans%]",
- "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_outdoor%]",
- "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_synthetic_refresh%]",
- "laundry_care_dryer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_towels%]",
- "laundry_care_dryer_program_delicates": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_delicates%]",
- "laundry_care_dryer_program_super_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_super_40%]",
- "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_shirts_15%]",
- "laundry_care_dryer_program_pillow": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_pillow%]",
- "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_anti_shrink%]",
- "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_my_time_my_drying_time%]",
- "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold%]",
- "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm%]",
- "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_in_basket%]",
- "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_20%]",
- "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_30%]",
- "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_cold_fix_time_cold_60%]",
- "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_30%]",
- "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_40%]",
- "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_time_warm_fix_time_warm_60%]",
- "laundry_care_dryer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_dryer_program_dessous%]",
- "cooking_common_program_hood_automatic": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_automatic%]",
- "cooking_common_program_hood_venting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_venting%]",
- "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::entity::select::selected_program::state::cooking_common_program_hood_delayed_shut_off%]",
- "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pre_heating%]",
- "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air%]",
- "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_eco%]",
- "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_grilling%]",
- "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating%]",
- "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_top_bottom_heating_eco%]",
- "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_bottom_heating%]",
- "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_pizza_setting%]",
- "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_slow_cook%]",
- "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_intensive_heat%]",
- "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_keep_warm%]",
- "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_preheat_ovenware%]",
- "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_frozen_heatup_special%]",
- "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_desiccation%]",
- "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_defrost%]",
- "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_proof%]",
- "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_30_steam%]",
- "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_60_steam%]",
- "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_80_steam%]",
- "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_hot_air_100_steam%]",
- "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_sabbath_programme%]",
- "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_90_watt%]",
- "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_180_watt%]",
- "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_360_watt%]",
- "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_600_watt%]",
- "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_900_watt%]",
- "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_1000_watt%]",
- "cooking_oven_program_microwave_max": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_microwave_max%]",
- "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::entity::select::selected_program::state::cooking_oven_program_heating_mode_warming_drawer%]",
- "laundry_care_washer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton%]",
- "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_cotton_eco%]",
- "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_eco_4060%]",
- "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_cotton_colour%]",
- "laundry_care_washer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_easy_care%]",
- "laundry_care_washer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix%]",
- "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_mix_night_wash%]",
- "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_delicates_silk%]",
- "laundry_care_washer_program_wool": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_wool%]",
- "laundry_care_washer_program_sensitive": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sensitive%]",
- "laundry_care_washer_program_auto_30": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_30%]",
- "laundry_care_washer_program_auto_40": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_40%]",
- "laundry_care_washer_program_auto_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_auto_60%]",
- "laundry_care_washer_program_chiffon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_chiffon%]",
- "laundry_care_washer_program_curtains": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_curtains%]",
- "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dark_wash%]",
- "laundry_care_washer_program_dessous": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_dessous%]",
- "laundry_care_washer_program_monsoon": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_monsoon%]",
- "laundry_care_washer_program_outdoor": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_outdoor%]",
- "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_plush_toy%]",
- "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_shirts_blouses%]",
- "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_sport_fitness%]",
- "laundry_care_washer_program_towels": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_towels%]",
- "laundry_care_washer_program_water_proof": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_water_proof%]",
- "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_power_speed_59%]",
- "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_15%]",
- "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_super_153045_super_1530%]",
- "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_down_duvet_duvet%]",
- "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_rinse_rinse_spin_drain%]",
- "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_program_drum_clean%]",
- "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton%]",
- "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_cotton_eco_4060%]",
- "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_mix%]",
- "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_easy_care%]",
- "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_60%]",
- "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::entity::select::selected_program::state::laundry_care_washer_dryer_program_wash_and_dry_90%]"
+ "consumer_products_cleaning_robot_program_cleaning_clean_all": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_all%]",
+ "consumer_products_cleaning_robot_program_cleaning_clean_map": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_cleaning_clean_map%]",
+ "consumer_products_cleaning_robot_program_basic_go_home": "[%key:component::home_connect::selector::programs::options::consumer_products_cleaning_robot_program_basic_go_home%]",
+ "consumer_products_coffee_maker_program_beverage_ristretto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_ristretto%]",
+ "consumer_products_coffee_maker_program_beverage_espresso": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_doppio%]",
+ "consumer_products_coffee_maker_program_beverage_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_x_l_coffee": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_x_l_coffee%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_grande": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_grande%]",
+ "consumer_products_coffee_maker_program_beverage_espresso_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_espresso_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_cappuccino": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_cappuccino%]",
+ "consumer_products_coffee_maker_program_beverage_latte_macchiato": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_latte_macchiato%]",
+ "consumer_products_coffee_maker_program_beverage_caffe_latte": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_caffe_latte%]",
+ "consumer_products_coffee_maker_program_beverage_milk_froth": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_milk_froth%]",
+ "consumer_products_coffee_maker_program_beverage_warm_milk": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_warm_milk%]",
+ "consumer_products_coffee_maker_program_coffee_world_kleiner_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kleiner_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_grosser_brauner": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_grosser_brauner%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter%]",
+ "consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_verlaengerter_braun%]",
+ "consumer_products_coffee_maker_program_coffee_world_wiener_melange": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_wiener_melange%]",
+ "consumer_products_coffee_maker_program_coffee_world_flat_white": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_flat_white%]",
+ "consumer_products_coffee_maker_program_coffee_world_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_cortado": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_cortado%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_con_leche": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_con_leche%]",
+ "consumer_products_coffee_maker_program_coffee_world_cafe_au_lait": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_cafe_au_lait%]",
+ "consumer_products_coffee_maker_program_coffee_world_doppio": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_doppio%]",
+ "consumer_products_coffee_maker_program_coffee_world_kaapi": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_kaapi%]",
+ "consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_koffie_verkeerd%]",
+ "consumer_products_coffee_maker_program_coffee_world_galao": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_galao%]",
+ "consumer_products_coffee_maker_program_coffee_world_garoto": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_garoto%]",
+ "consumer_products_coffee_maker_program_coffee_world_americano": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_americano%]",
+ "consumer_products_coffee_maker_program_coffee_world_red_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_red_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_black_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_black_eye%]",
+ "consumer_products_coffee_maker_program_coffee_world_dead_eye": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_coffee_world_dead_eye%]",
+ "consumer_products_coffee_maker_program_beverage_hot_water": "[%key:component::home_connect::selector::programs::options::consumer_products_coffee_maker_program_beverage_hot_water%]",
+ "dishcare_dishwasher_program_pre_rinse": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_pre_rinse%]",
+ "dishcare_dishwasher_program_auto_1": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_1%]",
+ "dishcare_dishwasher_program_auto_2": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_2%]",
+ "dishcare_dishwasher_program_auto_3": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_3%]",
+ "dishcare_dishwasher_program_eco_50": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_eco_50%]",
+ "dishcare_dishwasher_program_quick_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_45%]",
+ "dishcare_dishwasher_program_intensiv_70": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_70%]",
+ "dishcare_dishwasher_program_normal_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_65%]",
+ "dishcare_dishwasher_program_glas_40": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glas_40%]",
+ "dishcare_dishwasher_program_glass_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_glass_care%]",
+ "dishcare_dishwasher_program_night_wash": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_night_wash%]",
+ "dishcare_dishwasher_program_quick_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_quick_65%]",
+ "dishcare_dishwasher_program_normal_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_normal_45%]",
+ "dishcare_dishwasher_program_intensiv_45": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_45%]",
+ "dishcare_dishwasher_program_auto_half_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_auto_half_load%]",
+ "dishcare_dishwasher_program_intensiv_power": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_intensiv_power%]",
+ "dishcare_dishwasher_program_magic_daily": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_magic_daily%]",
+ "dishcare_dishwasher_program_super_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_super_60%]",
+ "dishcare_dishwasher_program_kurz_60": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_kurz_60%]",
+ "dishcare_dishwasher_program_express_sparkle_65": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_express_sparkle_65%]",
+ "dishcare_dishwasher_program_machine_care": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_machine_care%]",
+ "dishcare_dishwasher_program_steam_fresh": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_steam_fresh%]",
+ "dishcare_dishwasher_program_maximum_cleaning": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_maximum_cleaning%]",
+ "dishcare_dishwasher_program_mixed_load": "[%key:component::home_connect::selector::programs::options::dishcare_dishwasher_program_mixed_load%]",
+ "laundry_care_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_cotton%]",
+ "laundry_care_dryer_program_synthetic": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic%]",
+ "laundry_care_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_mix%]",
+ "laundry_care_dryer_program_blankets": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_blankets%]",
+ "laundry_care_dryer_program_business_shirts": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_business_shirts%]",
+ "laundry_care_dryer_program_down_feathers": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_down_feathers%]",
+ "laundry_care_dryer_program_hygiene": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_hygiene%]",
+ "laundry_care_dryer_program_jeans": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_jeans%]",
+ "laundry_care_dryer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_outdoor%]",
+ "laundry_care_dryer_program_synthetic_refresh": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_synthetic_refresh%]",
+ "laundry_care_dryer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_towels%]",
+ "laundry_care_dryer_program_delicates": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_delicates%]",
+ "laundry_care_dryer_program_super_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_super_40%]",
+ "laundry_care_dryer_program_shirts_15": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_shirts_15%]",
+ "laundry_care_dryer_program_pillow": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_pillow%]",
+ "laundry_care_dryer_program_anti_shrink": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_anti_shrink%]",
+ "laundry_care_dryer_program_my_time_my_drying_time": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_my_time_my_drying_time%]",
+ "laundry_care_dryer_program_time_cold": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold%]",
+ "laundry_care_dryer_program_time_warm": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm%]",
+ "laundry_care_dryer_program_in_basket": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_in_basket%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_20": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_20%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_30%]",
+ "laundry_care_dryer_program_time_cold_fix_time_cold_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_cold_fix_time_cold_60%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_30": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_30%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_40": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_40%]",
+ "laundry_care_dryer_program_time_warm_fix_time_warm_60": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_time_warm_fix_time_warm_60%]",
+ "laundry_care_dryer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_dryer_program_dessous%]",
+ "cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
+ "cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
+ "cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
+ "cooking_oven_program_heating_mode_pre_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pre_heating%]",
+ "cooking_oven_program_heating_mode_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air%]",
+ "cooking_oven_program_heating_mode_hot_air_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_eco%]",
+ "cooking_oven_program_heating_mode_hot_air_grilling": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_grilling%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating%]",
+ "cooking_oven_program_heating_mode_top_bottom_heating_eco": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_top_bottom_heating_eco%]",
+ "cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
+ "cooking_oven_program_heating_mode_pizza_setting": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_pizza_setting%]",
+ "cooking_oven_program_heating_mode_slow_cook": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_slow_cook%]",
+ "cooking_oven_program_heating_mode_intensive_heat": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_intensive_heat%]",
+ "cooking_oven_program_heating_mode_keep_warm": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_keep_warm%]",
+ "cooking_oven_program_heating_mode_preheat_ovenware": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_preheat_ovenware%]",
+ "cooking_oven_program_heating_mode_frozen_heatup_special": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_frozen_heatup_special%]",
+ "cooking_oven_program_heating_mode_desiccation": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_desiccation%]",
+ "cooking_oven_program_heating_mode_defrost": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_defrost%]",
+ "cooking_oven_program_heating_mode_proof": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_proof%]",
+ "cooking_oven_program_heating_mode_hot_air_30_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_30_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_60_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_60_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_80_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_80_steam%]",
+ "cooking_oven_program_heating_mode_hot_air_100_steam": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_hot_air_100_steam%]",
+ "cooking_oven_program_heating_mode_sabbath_programme": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_sabbath_programme%]",
+ "cooking_oven_program_microwave_90_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_90_watt%]",
+ "cooking_oven_program_microwave_180_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_180_watt%]",
+ "cooking_oven_program_microwave_360_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_360_watt%]",
+ "cooking_oven_program_microwave_600_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_600_watt%]",
+ "cooking_oven_program_microwave_900_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_900_watt%]",
+ "cooking_oven_program_microwave_1000_watt": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_1000_watt%]",
+ "cooking_oven_program_microwave_max": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_microwave_max%]",
+ "cooking_oven_program_heating_mode_warming_drawer": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_warming_drawer%]",
+ "laundry_care_washer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton%]",
+ "laundry_care_washer_program_cotton_cotton_eco": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_cotton_eco%]",
+ "laundry_care_washer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_eco_4060%]",
+ "laundry_care_washer_program_cotton_colour": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_cotton_colour%]",
+ "laundry_care_washer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_easy_care%]",
+ "laundry_care_washer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix%]",
+ "laundry_care_washer_program_mix_night_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_mix_night_wash%]",
+ "laundry_care_washer_program_delicates_silk": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_delicates_silk%]",
+ "laundry_care_washer_program_wool": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_wool%]",
+ "laundry_care_washer_program_sensitive": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sensitive%]",
+ "laundry_care_washer_program_auto_30": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_30%]",
+ "laundry_care_washer_program_auto_40": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_40%]",
+ "laundry_care_washer_program_auto_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_auto_60%]",
+ "laundry_care_washer_program_chiffon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_chiffon%]",
+ "laundry_care_washer_program_curtains": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_curtains%]",
+ "laundry_care_washer_program_dark_wash": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dark_wash%]",
+ "laundry_care_washer_program_dessous": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_dessous%]",
+ "laundry_care_washer_program_monsoon": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_monsoon%]",
+ "laundry_care_washer_program_outdoor": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_outdoor%]",
+ "laundry_care_washer_program_plush_toy": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_plush_toy%]",
+ "laundry_care_washer_program_shirts_blouses": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_shirts_blouses%]",
+ "laundry_care_washer_program_sport_fitness": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_sport_fitness%]",
+ "laundry_care_washer_program_towels": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_towels%]",
+ "laundry_care_washer_program_water_proof": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_water_proof%]",
+ "laundry_care_washer_program_power_speed_59": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_power_speed_59%]",
+ "laundry_care_washer_program_super_153045_super_15": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_15%]",
+ "laundry_care_washer_program_super_153045_super_1530": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_super_153045_super_1530%]",
+ "laundry_care_washer_program_down_duvet_duvet": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_down_duvet_duvet%]",
+ "laundry_care_washer_program_rinse_rinse_spin_drain": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_rinse_rinse_spin_drain%]",
+ "laundry_care_washer_program_drum_clean": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_program_drum_clean%]",
+ "laundry_care_washer_dryer_program_cotton": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton%]",
+ "laundry_care_washer_dryer_program_cotton_eco_4060": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_cotton_eco_4060%]",
+ "laundry_care_washer_dryer_program_mix": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_mix%]",
+ "laundry_care_washer_dryer_program_easy_care": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_easy_care%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]",
+ "laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]"
+ }
+ },
+ "current_map": {
+ "name": "Current map",
+ "state": {
+ "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]"
+ }
+ },
+ "functional_light_color_temperature": {
+ "name": "Functional light color temperature",
+ "state": {
+ "cooking_hood_enum_type_color_temperature_custom": "Custom",
+ "cooking_hood_enum_type_color_temperature_warm": "Warm",
+ "cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral",
+ "cooking_hood_enum_type_color_temperature_neutral": "Neutral",
+ "cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold",
+ "cooking_hood_enum_type_color_temperature_cold": "Cold"
+ }
+ },
+ "ambient_light_color": {
+ "name": "Ambient light color",
+ "state": {
+ "b_s_h_common_enum_type_ambient_light_color_custom_color": "Custom"
+ }
+ },
+ "reference_map_id": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]",
+ "state": {
+ "consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
+ "consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]"
+ }
+ },
+ "cleaning_mode": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_cleaning_mode::name%]",
+ "state": {
+ "consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]",
+ "consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]",
+ "consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]"
+ }
+ },
+ "bean_amount": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_amount::name%]",
+ "state": {
+ "consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_mild%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild_plus%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_normal": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal_plus%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong_plus%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_extra_strong%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus%]",
+ "consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground%]"
+ }
+ },
+ "coffee_temperature": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_temperature::name%]",
+ "state": {
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_88_c%]",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_90_c%]",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_92_c%]",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_94_c%]",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_95_c%]",
+ "consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_96_c%]"
+ }
+ },
+ "bean_container": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]",
+ "state": {
+ "consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]",
+ "consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]"
+ }
+ },
+ "flow_rate": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_flow_rate::name%]",
+ "state": {
+ "consumer_products_coffee_maker_enum_type_flow_rate_normal": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_normal%]",
+ "consumer_products_coffee_maker_enum_type_flow_rate_intense": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense%]",
+ "consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense_plus%]"
+ }
+ },
+ "coffee_milk_ratio": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_milk_ratio::name%]",
+ "state": {
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent%]",
+ "consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent%]"
+ }
+ },
+ "hot_water_temperature": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_hot_water_temperature::name%]",
+ "state": {
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f%]",
+ "consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_max%]"
+ }
+ },
+ "drying_target": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_dryer_option_drying_target::name%]",
+ "state": {
+ "laundry_care_dryer_enum_type_drying_target_iron_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_iron_dry%]",
+ "laundry_care_dryer_enum_type_drying_target_gentle_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_gentle_dry%]",
+ "laundry_care_dryer_enum_type_drying_target_cupboard_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry%]",
+ "laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus%]",
+ "laundry_care_dryer_enum_type_drying_target_extra_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_extra_dry%]"
+ }
+ },
+ "venting_level": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]",
+ "state": {
+ "cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]",
+ "cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]",
+ "cooking_hood_enum_type_stage_fan_stage_02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_02%]",
+ "cooking_hood_enum_type_stage_fan_stage_03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_03%]",
+ "cooking_hood_enum_type_stage_fan_stage_04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_04%]",
+ "cooking_hood_enum_type_stage_fan_stage_05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_05%]"
+ }
+ },
+ "intensive_level": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]",
+ "state": {
+ "cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]",
+ "cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]",
+ "cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]"
+ }
+ },
+ "warming_level": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]",
+ "state": {
+ "cooking_oven_enum_type_warming_level_low": "[%key:common::state::low%]",
+ "cooking_oven_enum_type_warming_level_medium": "[%key:common::state::medium%]",
+ "cooking_oven_enum_type_warming_level_high": "[%key:common::state::high%]"
+ }
+ },
+ "washer_temperature": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]",
+ "state": {
+ "laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]",
+ "laundry_care_washer_enum_type_temperature_g_c_20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_20%]",
+ "laundry_care_washer_enum_type_temperature_g_c_30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_30%]",
+ "laundry_care_washer_enum_type_temperature_g_c_40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_40%]",
+ "laundry_care_washer_enum_type_temperature_g_c_50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_50%]",
+ "laundry_care_washer_enum_type_temperature_g_c_60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_60%]",
+ "laundry_care_washer_enum_type_temperature_g_c_70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_70%]",
+ "laundry_care_washer_enum_type_temperature_g_c_80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_80%]",
+ "laundry_care_washer_enum_type_temperature_g_c_90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c_90%]",
+ "laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]",
+ "laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]",
+ "laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]",
+ "laundry_care_washer_enum_type_temperature_ul_extra_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_extra_hot%]"
+ }
+ },
+ "spin_speed": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]",
+ "state": {
+ "laundry_care_washer_enum_type_spin_speed_off": "[%key:common::state::off%]",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_400%]",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_600%]",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_700": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_700%]",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_800%]",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_900": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_900%]",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1000%]",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1200%]",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1400%]",
+ "laundry_care_washer_enum_type_spin_speed_r_p_m_1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m_1600%]",
+ "laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:common::state::off%]",
+ "laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:common::state::low%]",
+ "laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:common::state::medium%]",
+ "laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:common::state::high%]"
+ }
+ },
+ "vario_perfect": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]",
+ "state": {
+ "laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]",
+ "laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]",
+ "laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]"
}
}
},
@@ -636,11 +1532,11 @@
"inactive": "Inactive",
"ready": "Ready",
"delayedstart": "Delayed start",
- "run": "Run",
+ "run": "Running",
"pause": "[%key:common::state::paused%]",
"actionrequired": "Action required",
"finished": "Finished",
- "error": "Error",
+ "error": "[%key:common::state::error%]",
"aborting": "Aborting"
}
},
@@ -691,7 +1587,7 @@
"streaminglocal": "Streaming local",
"streamingcloud": "Streaming cloud",
"streaminglocal_and_cloud": "Streaming local and cloud",
- "error": "Error"
+ "error": "[%key:common::state::error%]"
}
},
"last_selected_map": {
@@ -703,23 +1599,67 @@
"map3": "Map 3"
}
},
- "freezer_door_alarm": {
- "name": "Freezer door alarm",
- "state": {
- "confirmed": "[%key:component::home_connect::common::confirmed%]",
- "present": "[%key:component::home_connect::common::present%]"
- }
+ "oven_current_cavity_temperature": {
+ "name": "Current oven cavity temperature"
},
- "refrigerator_door_alarm": {
- "name": "Refrigerator door alarm",
+ "program_aborted": {
+ "name": "Program aborted",
"state": {
"off": "[%key:common::state::off%]",
"confirmed": "[%key:component::home_connect::common::confirmed%]",
"present": "[%key:component::home_connect::common::present%]"
}
},
- "freezer_temperature_alarm": {
- "name": "Freezer temperature alarm",
+ "program_finished": {
+ "name": "Program finished",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "alarm_clock_elapsed": {
+ "name": "Alarm clock elapsed",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "preheat_finished": {
+ "name": "Pre-heat finished",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "regular_preheat_finished": {
+ "name": "Regular pre-heat finished",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "drying_process_finished": {
+ "name": "Drying process finished",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "salt_nearly_empty": {
+ "name": "Salt nearly empty",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "rinse_aid_nearly_empty": {
+ "name": "Rinse aid nearly empty",
"state": {
"off": "[%key:common::state::off%]",
"confirmed": "[%key:component::home_connect::common::confirmed%]",
@@ -750,16 +1690,216 @@
"present": "[%key:component::home_connect::common::present%]"
}
},
- "salt_nearly_empty": {
- "name": "Salt nearly empty",
+ "keep_milk_tank_cool": {
+ "name": "Keep milk tank cool",
"state": {
"off": "[%key:common::state::off%]",
"confirmed": "[%key:component::home_connect::common::confirmed%]",
"present": "[%key:component::home_connect::common::present%]"
}
},
- "rinse_aid_nearly_empty": {
- "name": "Rinse aid nearly empty",
+ "descaling_in_20_cups": {
+ "name": "Descaling in 20 cups",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "descaling_in_15_cups": {
+ "name": "Descaling in 15 cups",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "descaling_in_10_cups": {
+ "name": "Descaling in 10 cups",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "descaling_in_5_cups": {
+ "name": "Descaling in 5 cups",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "device_should_be_descaled": {
+ "name": "Device should be descaled",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "device_descaling_overdue": {
+ "name": "Device descaling overdue",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "device_descaling_blockage": {
+ "name": "Device descaling blockage",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "device_should_be_cleaned": {
+ "name": "Device should be cleaned",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "device_cleaning_overdue": {
+ "name": "Device cleaning overdue",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "calc_n_clean_in20cups": {
+ "name": "Calc'N'Clean in 20 cups",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "calc_n_clean_in15cups": {
+ "name": "Calc'N'Clean in 15 cups",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "calc_n_clean_in10cups": {
+ "name": "Calc'N'Clean in 10 cups",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "calc_n_clean_in5cups": {
+ "name": "Calc'N'Clean in 5 cups",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "device_should_be_calc_n_cleaned": {
+ "name": "Device should be Calc'N'Cleaned",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "device_calc_n_clean_overdue": {
+ "name": "Device Calc'N'Clean overdue",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "device_calc_n_clean_blockage": {
+ "name": "Device Calc'N'Clean blockage",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "freezer_door_alarm": {
+ "name": "Freezer door alarm",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "refrigerator_door_alarm": {
+ "name": "Refrigerator door alarm",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "freezer_temperature_alarm": {
+ "name": "Freezer temperature alarm",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "empty_dust_box_and_clean_filter": {
+ "name": "Empty dust box and clean filter",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "robot_is_stuck": {
+ "name": "Robot is stuck",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "docking_station_not_found": {
+ "name": "Docking station not found",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "poor_i_dos_1_fill_level": {
+ "name": "Poor i-Dos 1 fill level",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "poor_i_dos_2_fill_level": {
+ "name": "Poor i-Dos 2 fill level",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "grease_filter_max_saturation_nearly_reached": {
+ "name": "Grease filter max saturation nearly reached",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "confirmed": "[%key:component::home_connect::common::confirmed%]",
+ "present": "[%key:component::home_connect::common::present%]"
+ }
+ },
+ "grease_filter_max_saturation_reached": {
+ "name": "Grease filter max saturation reached",
"state": {
"off": "[%key:common::state::off%]",
"confirmed": "[%key:component::home_connect::common::confirmed%]",
@@ -807,6 +1947,45 @@
},
"door_assistant_freezer": {
"name": "Freezer door assistant"
+ },
+ "multiple_beverages": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_multiple_beverages::name%]"
+ },
+ "intensiv_zone": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]"
+ },
+ "brilliance_dry": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_brilliance_dry::name%]"
+ },
+ "vario_speed_plus": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_vario_speed_plus::name%]"
+ },
+ "silence_on_demand": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_silence_on_demand::name%]"
+ },
+ "half_load": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_half_load::name%]"
+ },
+ "extra_dry": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_extra_dry::name%]"
+ },
+ "hygiene_plus": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]"
+ },
+ "eco_dry": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_eco_dry::name%]"
+ },
+ "zeolite_dry": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_zeolite_dry::name%]"
+ },
+ "fast_pre_heat": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_fast_pre_heat::name%]"
+ },
+ "i_dos1_active": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]"
+ },
+ "i_dos2_active": {
+ "name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]"
}
},
"time": {
diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py
index e7fcd29e191..05f0ed2ddc3 100644
--- a/homeassistant/components/home_connect/switch.py
+++ b/homeassistant/components/home_connect/switch.py
@@ -3,7 +3,7 @@
import logging
from typing import Any, cast
-from aiohomeconnect.model import EventKey, ProgramKey, SettingKey
+from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.program import EnumerateProgram
@@ -13,7 +13,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
@@ -22,26 +22,18 @@ from homeassistant.helpers.issue_registry import (
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from .common import setup_home_connect_entry
-from .const import (
- BSH_POWER_OFF,
- BSH_POWER_ON,
- BSH_POWER_STANDBY,
- DOMAIN,
- SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME,
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
- SVE_TRANSLATION_PLACEHOLDER_KEY,
- SVE_TRANSLATION_PLACEHOLDER_VALUE,
-)
+from .const import BSH_POWER_OFF, BSH_POWER_ON, BSH_POWER_STANDBY, DOMAIN
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
-from .entity import HomeConnectEntity
+from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 1
SWITCHES = (
SwitchEntityDescription(
@@ -100,6 +92,61 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription(
translation_key="power",
)
+SWITCH_OPTIONS = (
+ SwitchEntityDescription(
+ key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES,
+ translation_key="multiple_beverages",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE,
+ translation_key="intensiv_zone",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY,
+ translation_key="brilliance_dry",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS,
+ translation_key="vario_speed_plus",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND,
+ translation_key="silence_on_demand",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.DISHCARE_DISHWASHER_HALF_LOAD,
+ translation_key="half_load",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY,
+ translation_key="extra_dry",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS,
+ translation_key="hygiene_plus",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.DISHCARE_DISHWASHER_ECO_DRY,
+ translation_key="eco_dry",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY,
+ translation_key="zeolite_dry",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.COOKING_OVEN_FAST_PRE_HEAT,
+ translation_key="fast_pre_heat",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE,
+ translation_key="i_dos1_active",
+ ),
+ SwitchEntityDescription(
+ key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE,
+ translation_key="i_dos2_active",
+ ),
+)
+
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
@@ -123,20 +170,32 @@ def _get_entities_for_appliance(
for description in SWITCHES
if description.key in appliance.settings
)
-
return entities
+def _get_option_entities_for_appliance(
+ entry: HomeConnectConfigEntry,
+ appliance: HomeConnectApplianceData,
+) -> list[HomeConnectOptionEntity]:
+ """Get a list of currently available option entities."""
+ return [
+ HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description)
+ for description in SWITCH_OPTIONS
+ if description.key in appliance.options
+ ]
+
+
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect switch."""
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
+ _get_option_entities_for_appliance,
)
@@ -158,8 +217,8 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
translation_key="turn_on",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
- SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key,
+ "entity_id": self.entity_id,
+ "key": self.bsh_key,
},
) from err
@@ -178,8 +237,8 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
translation_key="turn_off",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
- SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key,
+ "entity_id": self.entity_id,
+ "key": self.bsh_key,
},
) from err
@@ -207,7 +266,10 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
super().__init__(
coordinator,
appliance,
- SwitchEntityDescription(key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM),
+ SwitchEntityDescription(
+ key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
+ entity_registry_enabled_default=False,
+ ),
)
self._attr_name = f"{appliance.info.name} {desc}"
self._attr_unique_id = f"{appliance.info.ha_id}-{desc}"
@@ -245,11 +307,12 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
async_create_issue(
self.hass,
DOMAIN,
- f"deprecated_program_switch_{self.entity_id}",
+ f"deprecated_program_switch_in_automations_scripts_{self.entity_id}",
breaks_in_ha_version="2025.6.0",
- is_fixable=False,
+ is_fixable=True,
+ is_persistent=True,
severity=IssueSeverity.WARNING,
- translation_key="deprecated_program_switch",
+ translation_key="deprecated_program_switch_in_automations_scripts",
translation_placeholders={
"entity_id": self.entity_id,
"items": "\n".join(items_list),
@@ -258,12 +321,34 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
async def async_will_remove_from_hass(self) -> None:
"""Call when entity will be removed from hass."""
+ async_delete_issue(
+ self.hass,
+ DOMAIN,
+ f"deprecated_program_switch_in_automations_scripts_{self.entity_id}",
+ )
async_delete_issue(
self.hass, DOMAIN, f"deprecated_program_switch_{self.entity_id}"
)
+ def create_action_handler_issue(self) -> None:
+ """Create deprecation issue."""
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ f"deprecated_program_switch_{self.entity_id}",
+ breaks_in_ha_version="2025.6.0",
+ is_fixable=True,
+ is_persistent=True,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_program_switch",
+ translation_placeholders={
+ "entity_id": self.entity_id,
+ },
+ )
+
async def async_turn_on(self, **kwargs: Any) -> None:
"""Start the program."""
+ self.create_action_handler_issue()
try:
await self.coordinator.client.start_program(
self.appliance.info.ha_id, program_key=self.program.key
@@ -280,6 +365,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Stop the program."""
+ self.create_action_handler_issue()
try:
await self.coordinator.client.stop_program(self.appliance.info.ha_id)
except HomeConnectError as err:
@@ -317,7 +403,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
translation_key="power_on",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name,
+ "appliance_name": self.appliance.info.name,
},
) from err
@@ -330,7 +416,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
translation_domain=DOMAIN,
translation_key="unable_to_retrieve_turn_off",
translation_placeholders={
- SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name
+ "appliance_name": self.appliance.info.name
},
)
@@ -338,9 +424,7 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="turn_off_not_supported",
- translation_placeholders={
- SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name
- },
+ translation_placeholders={"appliance_name": self.appliance.info.name},
)
try:
await self.coordinator.client.set_setting(
@@ -355,8 +439,8 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
translation_key="power_off",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name,
- SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state,
+ "appliance_name": self.appliance.info.name,
+ "value": self.power_off_state,
},
) from err
@@ -403,3 +487,19 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
self.power_off_state = BSH_POWER_STANDBY
else:
self.power_off_state = None
+
+
+class HomeConnectSwitchOptionEntity(HomeConnectOptionEntity, SwitchEntity):
+ """Switch option class for Home Connect."""
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the option."""
+ await self.async_set_option(True)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the option."""
+ await self.async_set_option(False)
+
+ def update_native_value(self) -> None:
+ """Set the value of the entity."""
+ self._attr_is_on = cast(bool | None, self.option_value)
diff --git a/homeassistant/components/home_connect/time.py b/homeassistant/components/home_connect/time.py
index 48f651857d2..adf26d2d973 100644
--- a/homeassistant/components/home_connect/time.py
+++ b/homeassistant/components/home_connect/time.py
@@ -1,4 +1,4 @@
-"""Provides time enties for Home Connect."""
+"""Provides time entities for Home Connect."""
from datetime import time
from typing import cast
@@ -6,27 +6,32 @@ from typing import cast
from aiohomeconnect.model import SettingKey
from aiohomeconnect.model.error import HomeConnectError
+from homeassistant.components.automation import automations_with_entity
+from homeassistant.components.script import scripts_with_entity
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.issue_registry import (
+ IssueSeverity,
+ async_create_issue,
+ async_delete_issue,
+)
from .common import setup_home_connect_entry
-from .const import (
- DOMAIN,
- SVE_TRANSLATION_KEY_SET_SETTING,
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
- SVE_TRANSLATION_PLACEHOLDER_KEY,
- SVE_TRANSLATION_PLACEHOLDER_VALUE,
-)
+from .const import DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
+PARALLEL_UPDATES = 1
+
TIME_ENTITIES = (
TimeEntityDescription(
key=SettingKey.BSH_COMMON_ALARM_CLOCK,
translation_key="alarm_clock",
+ entity_registry_enabled_default=False,
),
)
@@ -46,7 +51,7 @@ def _get_entities_for_appliance(
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect switch."""
setup_home_connect_entry(
@@ -71,8 +76,78 @@ def time_to_seconds(t: time) -> int:
class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity):
"""Time setting class for Home Connect."""
+ async def async_added_to_hass(self) -> None:
+ """Call when entity is added to hass."""
+ await super().async_added_to_hass()
+ if self.bsh_key == SettingKey.BSH_COMMON_ALARM_CLOCK:
+ automations = automations_with_entity(self.hass, self.entity_id)
+ scripts = scripts_with_entity(self.hass, self.entity_id)
+ items = automations + scripts
+ if not items:
+ return
+
+ entity_reg: er.EntityRegistry = er.async_get(self.hass)
+ entity_automations = [
+ automation_entity
+ for automation_id in automations
+ if (automation_entity := entity_reg.async_get(automation_id))
+ ]
+ entity_scripts = [
+ script_entity
+ for script_id in scripts
+ if (script_entity := entity_reg.async_get(script_id))
+ ]
+
+ items_list = [
+ f"- [{item.original_name}](/config/automation/edit/{item.unique_id})"
+ for item in entity_automations
+ ] + [
+ f"- [{item.original_name}](/config/script/edit/{item.unique_id})"
+ for item in entity_scripts
+ ]
+
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ f"deprecated_time_alarm_clock_in_automations_scripts_{self.entity_id}",
+ breaks_in_ha_version="2025.10.0",
+ is_fixable=True,
+ is_persistent=True,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_time_alarm_clock",
+ translation_placeholders={
+ "entity_id": self.entity_id,
+ "items": "\n".join(items_list),
+ },
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Call when entity will be removed from hass."""
+ if self.bsh_key == SettingKey.BSH_COMMON_ALARM_CLOCK:
+ async_delete_issue(
+ self.hass,
+ DOMAIN,
+ f"deprecated_time_alarm_clock_in_automations_scripts_{self.entity_id}",
+ )
+ async_delete_issue(
+ self.hass, DOMAIN, f"deprecated_time_alarm_clock_{self.entity_id}"
+ )
+
async def async_set_value(self, value: time) -> None:
"""Set the native value of the entity."""
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ f"deprecated_time_alarm_clock_{self.entity_id}",
+ breaks_in_ha_version="2025.10.0",
+ is_fixable=True,
+ is_persistent=True,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_time_alarm_clock",
+ translation_placeholders={
+ "entity_id": self.entity_id,
+ },
+ )
try:
await self.coordinator.client.set_setting(
self.appliance.info.ha_id,
@@ -82,12 +157,12 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity):
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key=SVE_TRANSLATION_KEY_SET_SETTING,
+ translation_key="set_setting_entity",
translation_placeholders={
**get_dict_from_home_connect_error(err),
- SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
- SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key,
- SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
+ "entity_id": self.entity_id,
+ "key": self.bsh_key,
+ "value": str(value),
},
) from err
diff --git a/homeassistant/components/home_connect/utils.py b/homeassistant/components/home_connect/utils.py
index 108465072e1..ee5febb3cf7 100644
--- a/homeassistant/components/home_connect/utils.py
+++ b/homeassistant/components/home_connect/utils.py
@@ -2,7 +2,7 @@
import re
-from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError
+from aiohomeconnect.model.error import HomeConnectError
RE_CAMEL_CASE = re.compile(r"(? dict[str, str]:
"""Return a translation string from a Home Connect error."""
- return {
- "error": str(err)
- if isinstance(err, HomeConnectApiError)
- else type(err).__name__
- }
+ return {"error": str(err)}
def bsh_key_to_translation_key(bsh_key: str) -> str:
diff --git a/homeassistant/components/homeassistant/exposed_entities.py b/homeassistant/components/homeassistant/exposed_entities.py
index 7bd9f9ab7bc..b7e420dedde 100644
--- a/homeassistant/components/homeassistant/exposed_entities.py
+++ b/homeassistant/components/homeassistant/exposed_entities.py
@@ -437,18 +437,21 @@ def ws_expose_entity(
def ws_list_exposed_entities(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
- """Expose an entity to an assistant."""
+ """List entities which are exposed to assistants."""
result: dict[str, Any] = {}
exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
entity_registry = er.async_get(hass)
for entity_id in chain(exposed_entities.entities, entity_registry.entities):
- result[entity_id] = {}
+ exposed_to = {}
entity_settings = async_get_entity_settings(hass, entity_id)
for assistant, settings in entity_settings.items():
- if "should_expose" not in settings:
+ if "should_expose" not in settings or not settings["should_expose"]:
continue
- result[entity_id][assistant] = settings["should_expose"]
+ exposed_to[assistant] = True
+ if not exposed_to:
+ continue
+ result[entity_id] = exposed_to
connection.send_result(msg["id"], {"exposed_entities": result})
diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json
index 3283d480fdd..b8b5f77cf52 100644
--- a/homeassistant/components/homeassistant/strings.json
+++ b/homeassistant/components/homeassistant/strings.json
@@ -12,7 +12,7 @@
},
"imperial_unit_system": {
"title": "The imperial unit system is deprecated",
- "description": "The imperial unit system is deprecated and your system is currently using us customary. Please update your configuration to use the us customary unit system and reload the core configuration to fix this issue."
+ "description": "The imperial unit system is deprecated and your system is currently using US customary. Please update your configuration to use the US customary unit system and reload the Core configuration to fix this issue."
},
"deprecated_yaml": {
"title": "The {integration_title} YAML configuration is being removed",
@@ -44,7 +44,7 @@
},
"no_platform_setup": {
"title": "Unused YAML configuration for the {platform} integration",
- "description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}"
+ "description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurrences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}"
},
"storage_corruption": {
"title": "Storage corruption detected for {storage_key}",
@@ -111,8 +111,8 @@
"description": "Checks the Home Assistant YAML-configuration files for errors. Errors will be shown in the Home Assistant logs."
},
"reload_core_config": {
- "name": "Reload core configuration",
- "description": "Reloads the core configuration from the YAML-configuration."
+ "name": "Reload Core configuration",
+ "description": "Reloads the Core configuration from the YAML-configuration."
},
"restart": {
"name": "[%key:common::action::restart%]",
@@ -160,7 +160,7 @@
},
"update_entity": {
"name": "Update entity",
- "description": "Forces one or more entities to update its data.",
+ "description": "Forces one or more entities to update their data.",
"fields": {
"entity_id": {
"name": "Entities to update",
@@ -188,7 +188,7 @@
},
"reload_all": {
"name": "Reload all",
- "description": "Reload all YAML configuration that can be reloaded without restarting Home Assistant."
+ "description": "Reloads all YAML configuration that can be reloaded without restarting Home Assistant."
}
},
"exceptions": {
diff --git a/homeassistant/components/homeassistant/triggers/time_pattern.py b/homeassistant/components/homeassistant/triggers/time_pattern.py
index df49a79bcb6..14096d87277 100644
--- a/homeassistant/components/homeassistant/triggers/time_pattern.py
+++ b/homeassistant/components/homeassistant/triggers/time_pattern.py
@@ -37,6 +37,8 @@ class TimePattern:
if isinstance(value, str) and value.startswith("/"):
number = int(value[1:])
+ if number == 0:
+ raise vol.Invalid(f"must be a value between 1 and {self.maximum}")
else:
value = number = int(value)
diff --git a/homeassistant/components/homeassistant_alerts/coordinator.py b/homeassistant/components/homeassistant_alerts/coordinator.py
index a81824d2376..542ebf857df 100644
--- a/homeassistant/components/homeassistant_alerts/coordinator.py
+++ b/homeassistant/components/homeassistant_alerts/coordinator.py
@@ -40,6 +40,7 @@ class AlertUpdateCoordinator(DataUpdateCoordinator[dict[str, IntegrationAlert]])
super().__init__(
hass,
_LOGGER,
+ config_entry=None,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
)
diff --git a/homeassistant/components/homeassistant_hardware/coordinator.py b/homeassistant/components/homeassistant_hardware/coordinator.py
new file mode 100644
index 00000000000..c9a5c891328
--- /dev/null
+++ b/homeassistant/components/homeassistant_hardware/coordinator.py
@@ -0,0 +1,46 @@
+"""Home Assistant hardware firmware update coordinator."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+
+from aiohttp import ClientSession
+from ha_silabs_firmware_client import (
+ FirmwareManifest,
+ FirmwareUpdateClient,
+ ManifestMissing,
+)
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+_LOGGER = logging.getLogger(__name__)
+
+
+FIRMWARE_REFRESH_INTERVAL = timedelta(hours=8)
+
+
+class FirmwareUpdateCoordinator(DataUpdateCoordinator[FirmwareManifest]):
+ """Coordinator to manage firmware updates."""
+
+ def __init__(self, hass: HomeAssistant, session: ClientSession, url: str) -> None:
+ """Initialize the firmware update coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name="firmware update coordinator",
+ update_interval=FIRMWARE_REFRESH_INTERVAL,
+ )
+ self.hass = hass
+ self.session = session
+
+ self.client = FirmwareUpdateClient(url, session)
+
+ async def _async_update_data(self) -> FirmwareManifest:
+ try:
+ return await self.client.async_update_data()
+ except ManifestMissing as err:
+ raise UpdateFailed(
+ "GitHub release assets haven't been uploaded yet"
+ ) from err
diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py
index 8d7a302e786..1b4840e5a98 100644
--- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py
+++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py
@@ -28,12 +28,14 @@ from . import silabs_multiprotocol_addon
from .const import OTBR_DOMAIN, ZHA_DOMAIN
from .util import (
ApplicationType,
+ FirmwareInfo,
OwningAddon,
OwningIntegration,
get_otbr_addon_manager,
get_zigbee_flasher_addon_manager,
+ guess_firmware_info,
guess_hardware_owners,
- probe_silabs_firmware_type,
+ probe_silabs_firmware_info,
)
_LOGGER = logging.getLogger(__name__)
@@ -52,7 +54,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Instantiate base flow."""
super().__init__(*args, **kwargs)
- self._probed_firmware_type: ApplicationType | None = None
+ self._probed_firmware_info: FirmwareInfo | None = None
self._device: str | None = None # To be set in a subclass
self._hardware_name: str = "unknown" # To be set in a subclass
@@ -64,8 +66,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Shared translation placeholders."""
placeholders = {
"firmware_type": (
- self._probed_firmware_type.value
- if self._probed_firmware_type is not None
+ self._probed_firmware_info.firmware_type.value
+ if self._probed_firmware_info is not None
else "unknown"
),
"model": self._hardware_name,
@@ -120,39 +122,49 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
description_placeholders=self._get_translation_placeholders(),
)
- async def _probe_firmware_type(self) -> bool:
- """Probe the firmware currently on the device."""
- assert self._device is not None
-
- self._probed_firmware_type = await probe_silabs_firmware_type(
- self._device,
- probe_methods=(
- # We probe in order of frequency: Zigbee, Thread, then multi-PAN
- ApplicationType.GECKO_BOOTLOADER,
- ApplicationType.EZSP,
- ApplicationType.SPINEL,
- ApplicationType.CPC,
- ),
- )
-
- return self._probed_firmware_type in (
+ async def _probe_firmware_info(
+ self,
+ probe_methods: tuple[ApplicationType, ...] = (
+ # We probe in order of frequency: Zigbee, Thread, then multi-PAN
+ ApplicationType.GECKO_BOOTLOADER,
ApplicationType.EZSP,
ApplicationType.SPINEL,
ApplicationType.CPC,
+ ),
+ ) -> bool:
+ """Probe the firmware currently on the device."""
+ assert self._device is not None
+
+ self._probed_firmware_info = await probe_silabs_firmware_info(
+ self._device,
+ probe_methods=probe_methods,
+ )
+
+ return (
+ self._probed_firmware_info is not None
+ and self._probed_firmware_info.firmware_type
+ in (
+ ApplicationType.EZSP,
+ ApplicationType.SPINEL,
+ ApplicationType.CPC,
+ )
)
async def async_step_pick_firmware_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Zigbee firmware."""
- if not await self._probe_firmware_type():
+ if not await self._probe_firmware_info():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
)
# Allow the stick to be used with ZHA without flashing
- if self._probed_firmware_type == ApplicationType.EZSP:
+ if (
+ self._probed_firmware_info is not None
+ and self._probed_firmware_info.firmware_type == ApplicationType.EZSP
+ ):
return await self.async_step_confirm_zigbee()
if not is_hassio(self.hass):
@@ -338,7 +350,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Confirm Zigbee setup."""
assert self._device is not None
assert self._hardware_name is not None
- self._probed_firmware_type = ApplicationType.EZSP
+
+ if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
+ return self.async_abort(
+ reason="unsupported_firmware",
+ description_placeholders=self._get_translation_placeholders(),
+ )
if user_input is not None:
await self.hass.config_entries.flow.async_init(
@@ -366,7 +383,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Pick Thread firmware."""
- if not await self._probe_firmware_type():
+ if not await self._probe_firmware_info():
return self.async_abort(
reason="unsupported_firmware",
description_placeholders=self._get_translation_placeholders(),
@@ -458,7 +475,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Confirm OTBR setup."""
assert self._device is not None
- self._probed_firmware_type = ApplicationType.SPINEL
+ if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
+ return self.async_abort(
+ reason="unsupported_firmware",
+ description_placeholders=self._get_translation_placeholders(),
+ )
if user_input is not None:
# OTBR discovery is done automatically via hassio
@@ -491,20 +512,30 @@ class BaseFirmwareConfigFlow(BaseFirmwareInstallFlow, ConfigFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm a discovery."""
+ assert self._device is not None
+ fw_info = await guess_firmware_info(self.hass, self._device)
+
+ # If our guess for the firmware type is actually running, we can save the user
+ # an unnecessary confirmation and silently confirm the flow
+ for owner in fw_info.owners:
+ if await owner.is_running(self.hass):
+ self._probed_firmware_info = fw_info
+ return self._async_flow_finished()
+
return await self.async_step_pick_firmware()
class BaseFirmwareOptionsFlow(BaseFirmwareInstallFlow, OptionsFlow):
"""Zigbee and Thread options flow handlers."""
+ _probed_firmware_info: FirmwareInfo
+
def __init__(self, config_entry: ConfigEntry, *args: Any, **kwargs: Any) -> None:
"""Instantiate options flow."""
super().__init__(*args, **kwargs)
self._config_entry = config_entry
- self._probed_firmware_type = ApplicationType(self.config_entry.data["firmware"])
-
# Make `context` a regular dictionary
self.context = {}
diff --git a/homeassistant/components/homeassistant_hardware/manifest.json b/homeassistant/components/homeassistant_hardware/manifest.json
index 2efa12ccfda..f3a02185b83 100644
--- a/homeassistant/components/homeassistant_hardware/manifest.json
+++ b/homeassistant/components/homeassistant_hardware/manifest.json
@@ -5,5 +5,8 @@
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
- "requirements": ["universal-silabs-flasher==0.0.25"]
+ "requirements": [
+ "universal-silabs-flasher==0.0.30",
+ "ha-silabs-firmware-client==0.2.0"
+ ]
}
diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json
index de328a54bb7..6dda01561f1 100644
--- a/homeassistant/components/homeassistant_hardware/strings.json
+++ b/homeassistant/components/homeassistant_hardware/strings.json
@@ -39,8 +39,8 @@
"description": "The OpenThread Border Router (OTBR) add-on is now starting."
},
"otbr_failed": {
- "title": "Failed to setup OpenThread Border Router",
- "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists."
+ "title": "Failed to set up OpenThread Border Router",
+ "description": "The OpenThread Border Router add-on installation was unsuccessful. Ensure no other software is trying to communicate with the {model}, you have access to the Internet and can install other add-ons, and try again. Check the Supervisor logs if the problem persists."
},
"confirm_otbr": {
"title": "OpenThread Border Router setup complete",
@@ -48,16 +48,16 @@
}
},
"abort": {
- "not_hassio_thread": "The OpenThread Border Router addon can only be installed with Home Assistant OS. If you would like to use the {model} as an Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.",
+ "not_hassio_thread": "The OpenThread Border Router add-on can only be installed with Home Assistant OS. If you would like to use the {model} as a Thread border router, please flash the firmware manually using the [web flasher]({docs_web_flasher_url}) and set up OpenThread Border Router to communicate with it.",
"otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.",
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.",
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",
- "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device."
+ "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device."
},
"progress": {
- "install_zigbee_flasher_addon": "The Silicon Labs Flasher addon is installed, this may take a few minutes.",
+ "install_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is installed, this may take a few minutes.",
"run_zigbee_flasher_addon": "Please wait while Zigbee firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes.",
- "uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher addon is being removed."
+ "uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is being removed."
}
}
},
diff --git a/homeassistant/components/homeassistant_hardware/update.py b/homeassistant/components/homeassistant_hardware/update.py
new file mode 100644
index 00000000000..1b0f15ca021
--- /dev/null
+++ b/homeassistant/components/homeassistant_hardware/update.py
@@ -0,0 +1,326 @@
+"""Home Assistant Hardware base firmware update entity."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable
+from contextlib import AsyncExitStack, asynccontextmanager
+from dataclasses import dataclass
+import logging
+from typing import Any, cast
+
+from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata
+from universal_silabs_flasher.firmware import parse_firmware_image
+from universal_silabs_flasher.flasher import Flasher
+from yarl import URL
+
+from homeassistant.components.update import (
+ UpdateEntity,
+ UpdateEntityDescription,
+ UpdateEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import CALLBACK_TYPE, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.restore_state import ExtraStoredData
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .coordinator import FirmwareUpdateCoordinator
+from .helpers import async_register_firmware_info_callback
+from .util import (
+ ApplicationType,
+ FirmwareInfo,
+ guess_firmware_info,
+ probe_silabs_firmware_info,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+type FirmwareChangeCallbackType = Callable[
+ [ApplicationType | None, ApplicationType | None], None
+]
+
+
+@dataclass(kw_only=True, frozen=True)
+class FirmwareUpdateEntityDescription(UpdateEntityDescription):
+ """Describes Home Assistant Hardware firmware update entity."""
+
+ version_parser: Callable[[str], str]
+ fw_type: str | None
+ version_key: str | None
+ expected_firmware_type: ApplicationType | None
+ firmware_name: str | None
+
+
+@dataclass
+class FirmwareUpdateExtraStoredData(ExtraStoredData):
+ """Extra stored data for Home Assistant Hardware firmware update entity."""
+
+ firmware_manifest: FirmwareManifest | None = None
+
+ def as_dict(self) -> dict[str, Any]:
+ """Return a dict representation of the extra data."""
+ return {
+ "firmware_manifest": (
+ self.firmware_manifest.as_dict()
+ if self.firmware_manifest is not None
+ else None
+ )
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> FirmwareUpdateExtraStoredData:
+ """Initialize the extra data from a dict."""
+ if data["firmware_manifest"] is None:
+ return cls(firmware_manifest=None)
+
+ return cls(
+ FirmwareManifest.from_json(
+ data["firmware_manifest"],
+ # This data is not technically part of the manifest and is loaded externally
+ url=URL(data["firmware_manifest"]["url"]),
+ html_url=URL(data["firmware_manifest"]["html_url"]),
+ )
+ )
+
+
+class BaseFirmwareUpdateEntity(
+ CoordinatorEntity[FirmwareUpdateCoordinator], UpdateEntity
+):
+ """Base Home Assistant Hardware firmware update entity."""
+
+ # Subclasses provide the mapping between firmware types and entity descriptions
+ entity_description: FirmwareUpdateEntityDescription
+ bootloader_reset_type: str | None = None
+
+ _attr_supported_features = (
+ UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
+ )
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ device: str,
+ config_entry: ConfigEntry,
+ update_coordinator: FirmwareUpdateCoordinator,
+ entity_description: FirmwareUpdateEntityDescription,
+ ) -> None:
+ """Initialize the Hardware firmware update entity."""
+ super().__init__(update_coordinator)
+
+ self.entity_description = entity_description
+ self._current_device = device
+ self._config_entry = config_entry
+ self._current_firmware_info: FirmwareInfo | None = None
+ self._firmware_type_change_callbacks: set[FirmwareChangeCallbackType] = set()
+
+ self._latest_manifest: FirmwareManifest | None = None
+ self._latest_firmware: FirmwareMetadata | None = None
+
+ def add_firmware_type_changed_callback(
+ self,
+ change_callback: FirmwareChangeCallbackType,
+ ) -> CALLBACK_TYPE:
+ """Add a callback for when the firmware type changes."""
+ self._firmware_type_change_callbacks.add(change_callback)
+
+ @callback
+ def remove_callback() -> None:
+ self._firmware_type_change_callbacks.discard(change_callback)
+
+ return remove_callback
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+
+ self.async_on_remove(
+ async_register_firmware_info_callback(
+ self.hass,
+ self._current_device,
+ self._firmware_info_callback,
+ )
+ )
+
+ self.async_on_remove(
+ self._config_entry.async_on_state_change(self._on_config_entry_change)
+ )
+
+ if (extra_data := await self.async_get_last_extra_data()) and (
+ hardware_extra_data := FirmwareUpdateExtraStoredData.from_dict(
+ extra_data.as_dict()
+ )
+ ):
+ self._latest_manifest = hardware_extra_data.firmware_manifest
+
+ self._update_attributes()
+
+ @property
+ def extra_restore_state_data(self) -> FirmwareUpdateExtraStoredData:
+ """Return state data to be restored."""
+ return FirmwareUpdateExtraStoredData(firmware_manifest=self._latest_manifest)
+
+ @callback
+ def _on_config_entry_change(self) -> None:
+ """Handle config entry changes."""
+ self._update_attributes()
+ self.async_write_ha_state()
+
+ @callback
+ def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None:
+ """Handle updated firmware info being pushed by an integration."""
+ self._current_firmware_info = firmware_info
+
+ # If the firmware type does not change, we can just update the attributes
+ if (
+ self._current_firmware_info.firmware_type
+ == self.entity_description.expected_firmware_type
+ ):
+ self._update_attributes()
+ self.async_write_ha_state()
+ return
+
+ # Otherwise, fire the firmware type change callbacks. They are expected to
+ # replace the entity so there is no purpose in firing other callbacks.
+ for change_callback in self._firmware_type_change_callbacks.copy():
+ try:
+ change_callback(
+ self.entity_description.expected_firmware_type,
+ self._current_firmware_info.firmware_type,
+ )
+ except Exception: # noqa: BLE001
+ _LOGGER.warning(
+ "Failed to call firmware type changed callback", exc_info=True
+ )
+
+ def _update_attributes(self) -> None:
+ """Recompute the attributes of the entity."""
+ self._attr_title = self.entity_description.firmware_name or "Unknown"
+
+ if (
+ self._current_firmware_info is None
+ or self._current_firmware_info.firmware_version is None
+ ):
+ self._attr_installed_version = None
+ else:
+ self._attr_installed_version = self.entity_description.version_parser(
+ self._current_firmware_info.firmware_version
+ )
+
+ self._latest_firmware = None
+ self._attr_latest_version = None
+ self._attr_release_summary = None
+ self._attr_release_url = None
+
+ if (
+ self._latest_manifest is None
+ or self.entity_description.fw_type is None
+ or self.entity_description.version_key is None
+ ):
+ return
+
+ try:
+ self._latest_firmware = next(
+ f
+ for f in self._latest_manifest.firmwares
+ if f.filename.startswith(self.entity_description.fw_type)
+ )
+ except StopIteration:
+ pass
+ else:
+ version = cast(
+ str, self._latest_firmware.metadata[self.entity_description.version_key]
+ )
+ self._attr_latest_version = self.entity_description.version_parser(version)
+ self._attr_release_summary = self._latest_firmware.release_notes
+ self._attr_release_url = str(self._latest_manifest.html_url)
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._latest_manifest = self.coordinator.data
+ self._update_attributes()
+ self.async_write_ha_state()
+
+ def _update_progress(self, offset: int, total_size: int) -> None:
+ """Handle update progress."""
+
+ # Firmware updates in ~30s so we still get responsive update progress even
+ # without decimal places
+ self._attr_update_percentage = round((offset * 100) / total_size)
+ self.async_write_ha_state()
+
+ @asynccontextmanager
+ async def _temporarily_stop_hardware_owners(
+ self, device: str
+ ) -> AsyncIterator[None]:
+ """Temporarily stop addons and integrations communicating with the device."""
+ firmware_info = await guess_firmware_info(self.hass, device)
+ _LOGGER.debug("Identified firmware info: %s", firmware_info)
+
+ async with AsyncExitStack() as stack:
+ for owner in firmware_info.owners:
+ await stack.enter_async_context(owner.temporarily_stop(self.hass))
+
+ yield
+
+ async def async_install(
+ self, version: str | None, backup: bool, **kwargs: Any
+ ) -> None:
+ """Install an update."""
+ assert self._latest_firmware is not None
+ assert self.entity_description.expected_firmware_type is not None
+
+ # Start off by setting the progress bar to an indeterminate state
+ self._attr_in_progress = True
+ self._attr_update_percentage = None
+ self.async_write_ha_state()
+
+ fw_data = await self.coordinator.client.async_fetch_firmware(
+ self._latest_firmware
+ )
+ fw_image = await self.hass.async_add_executor_job(parse_firmware_image, fw_data)
+
+ device = self._current_device
+
+ flasher = Flasher(
+ device=device,
+ probe_methods=(
+ ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
+ ApplicationType.EZSP.as_flasher_application_type(),
+ ApplicationType.SPINEL.as_flasher_application_type(),
+ ApplicationType.CPC.as_flasher_application_type(),
+ ),
+ bootloader_reset=self.bootloader_reset_type,
+ )
+
+ async with self._temporarily_stop_hardware_owners(device):
+ try:
+ try:
+ # Enter the bootloader with indeterminate progress
+ await flasher.enter_bootloader()
+
+ # Flash the firmware, with progress
+ await flasher.flash_firmware(
+ fw_image, progress_callback=self._update_progress
+ )
+ except Exception as err:
+ raise HomeAssistantError("Failed to flash firmware") from err
+
+ # Probe the running application type with indeterminate progress
+ self._attr_update_percentage = None
+ self.async_write_ha_state()
+
+ firmware_info = await probe_silabs_firmware_info(
+ device,
+ probe_methods=(self.entity_description.expected_firmware_type,),
+ )
+
+ if firmware_info is None:
+ raise HomeAssistantError(
+ "Failed to probe the firmware after flashing"
+ )
+
+ self._firmware_info_callback(firmware_info)
+ finally:
+ self._attr_in_progress = False
+ self.async_write_ha_state()
diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py
index 53cbcbae5d4..64f363e4f23 100644
--- a/homeassistant/components/homeassistant_hardware/util.py
+++ b/homeassistant/components/homeassistant_hardware/util.py
@@ -4,7 +4,8 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
-from collections.abc import Iterable
+from collections.abc import AsyncIterator, Iterable
+from contextlib import asynccontextmanager
from dataclasses import dataclass
from enum import StrEnum
import logging
@@ -12,7 +13,7 @@ import logging
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.flasher import Flasher
-from homeassistant.components.hassio import AddonError, AddonState
+from homeassistant.components.hassio import AddonError, AddonManager, AddonState
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.hassio import is_hassio
@@ -42,6 +43,7 @@ class ApplicationType(StrEnum):
CPC = "cpc"
EZSP = "ezsp"
SPINEL = "spinel"
+ ROUTER = "router"
@classmethod
def from_flasher_application_type(
@@ -104,6 +106,28 @@ class OwningAddon:
else:
return addon_info.state == AddonState.RUNNING
+ @asynccontextmanager
+ async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]:
+ """Temporarily stop the add-on, restarting it after completion."""
+ addon_manager = self._get_addon_manager(hass)
+
+ try:
+ addon_info = await addon_manager.async_get_addon_info()
+ except AddonError:
+ yield
+ return
+
+ if addon_info.state != AddonState.RUNNING:
+ yield
+ return
+
+ try:
+ await addon_manager.async_stop_addon()
+ await addon_manager.async_wait_until_addon_state(AddonState.NOT_RUNNING)
+ yield
+ finally:
+ await addon_manager.async_start_addon_waiting()
+
@dataclass(kw_only=True)
class OwningIntegration:
@@ -122,6 +146,23 @@ class OwningIntegration:
ConfigEntryState.SETUP_IN_PROGRESS,
)
+ @asynccontextmanager
+ async def temporarily_stop(self, hass: HomeAssistant) -> AsyncIterator[None]:
+ """Temporarily stop the integration, restarting it after completion."""
+ if (entry := hass.config_entries.async_get_entry(self.config_entry_id)) is None:
+ yield
+ return
+
+ if entry.state != ConfigEntryState.LOADED:
+ yield
+ return
+
+ try:
+ await hass.config_entries.async_unload(entry.entry_id)
+ yield
+ finally:
+ await hass.config_entries.async_setup(entry.entry_id)
+
@dataclass(kw_only=True)
class FirmwareInfo:
@@ -143,6 +184,31 @@ class FirmwareInfo:
return all(states)
+async def get_otbr_addon_firmware_info(
+ hass: HomeAssistant, otbr_addon_manager: AddonManager
+) -> FirmwareInfo | None:
+ """Get firmware info from the OTBR add-on."""
+ try:
+ otbr_addon_info = await otbr_addon_manager.async_get_addon_info()
+ except AddonError:
+ return None
+
+ if otbr_addon_info.state == AddonState.NOT_INSTALLED:
+ return None
+
+ if (otbr_path := otbr_addon_info.options.get("device")) is None:
+ return None
+
+ # Only create a new entry if there are no existing OTBR ones
+ return FirmwareInfo(
+ device=otbr_path,
+ firmware_type=ApplicationType.SPINEL,
+ firmware_version=None,
+ source="otbr",
+ owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)],
+ )
+
+
async def guess_hardware_owners(
hass: HomeAssistant, device_path: str
) -> list[FirmwareInfo]:
@@ -155,28 +221,19 @@ async def guess_hardware_owners(
# It may be possible for the OTBR addon to be present without the integration
if is_hassio(hass):
otbr_addon_manager = get_otbr_addon_manager(hass)
+ otbr_addon_fw_info = await get_otbr_addon_firmware_info(
+ hass, otbr_addon_manager
+ )
+ otbr_path = (
+ otbr_addon_fw_info.device if otbr_addon_fw_info is not None else None
+ )
- try:
- otbr_addon_info = await otbr_addon_manager.async_get_addon_info()
- except AddonError:
- pass
- else:
- if otbr_addon_info.state != AddonState.NOT_INSTALLED:
- otbr_path = otbr_addon_info.options.get("device")
-
- # Only create a new entry if there are no existing OTBR ones
- if otbr_path is not None and not any(
- info.source == "otbr" for info in device_guesses[otbr_path]
- ):
- device_guesses[otbr_path].append(
- FirmwareInfo(
- device=otbr_path,
- firmware_type=ApplicationType.SPINEL,
- firmware_version=None,
- source="otbr",
- owners=[OwningAddon(slug=otbr_addon_manager.addon_slug)],
- )
- )
+ # Only create a new entry if there are no existing OTBR ones
+ if otbr_path is not None and not any(
+ info.source == "otbr" for info in device_guesses[otbr_path]
+ ):
+ assert otbr_addon_fw_info is not None
+ device_guesses[otbr_path].append(otbr_addon_fw_info)
if is_hassio(hass):
multipan_addon_manager = await get_multiprotocol_addon_manager(hass)
@@ -232,10 +289,10 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
return guesses[-1][0]
-async def probe_silabs_firmware_type(
+async def probe_silabs_firmware_info(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
-) -> ApplicationType | None:
- """Probe the running firmware on a Silabs device."""
+) -> FirmwareInfo | None:
+ """Probe the running firmware on a SiLabs device."""
flasher = Flasher(
device=device,
**(
@@ -253,4 +310,26 @@ async def probe_silabs_firmware_type(
if flasher.app_type is None:
return None
- return ApplicationType.from_flasher_application_type(flasher.app_type)
+ return FirmwareInfo(
+ device=device,
+ firmware_type=ApplicationType.from_flasher_application_type(flasher.app_type),
+ firmware_version=(
+ flasher.app_version.orig_version
+ if flasher.app_version is not None
+ else None
+ ),
+ source="probe",
+ owners=[],
+ )
+
+
+async def probe_silabs_firmware_type(
+ device: str, *, probe_methods: Iterable[ApplicationType] | None = None
+) -> ApplicationType | None:
+ """Probe the running firmware type on a SiLabs device."""
+
+ fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
+ if fw_info is None:
+ return None
+
+ return fw_info.firmware_type
diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py
index 758f0c1e1ef..dfc129ddc75 100644
--- a/homeassistant/components/homeassistant_sky_connect/__init__.py
+++ b/homeassistant/components/homeassistant_sky_connect/__init__.py
@@ -3,21 +3,87 @@
from __future__ import annotations
import logging
+import os.path
from homeassistant.components.homeassistant_hardware.util import guess_firmware_info
+from homeassistant.components.usb import (
+ USBDevice,
+ async_register_port_event_callback,
+ scan_serial_ports,
+)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import ConfigType
+
+from .const import (
+ DESCRIPTION,
+ DEVICE,
+ DOMAIN,
+ FIRMWARE,
+ FIRMWARE_VERSION,
+ MANUFACTURER,
+ PID,
+ PRODUCT,
+ SERIAL_NUMBER,
+ VID,
+)
_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the ZBT-1 integration."""
+
+ @callback
+ def async_port_event_callback(
+ added: set[USBDevice], removed: set[USBDevice]
+ ) -> None:
+ """Handle USB port events."""
+ current_entries_by_path = {
+ entry.data[DEVICE]: entry
+ for entry in hass.config_entries.async_entries(DOMAIN)
+ }
+
+ for device in added | removed:
+ path = device.device
+ entry = current_entries_by_path.get(path)
+
+ if entry is not None:
+ _LOGGER.debug(
+ "Device %r has changed state, reloading config entry %s",
+ path,
+ entry,
+ )
+ hass.config_entries.async_schedule_reload(entry.entry_id)
+
+ async_register_port_event_callback(hass, async_port_event_callback)
+
+ return True
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a Home Assistant SkyConnect config entry."""
+
+ # Postpone loading the config entry if the device is missing
+ device_path = entry.data[DEVICE]
+ if not await hass.async_add_executor_job(os.path.exists, device_path):
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="device_disconnected",
+ )
+
+ await hass.config_entries.async_forward_entry_setups(entry, ["update"])
+
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
+ await hass.config_entries.async_unload_platforms(entry, ["update"])
return True
@@ -25,7 +91,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
"""Migrate old entry."""
_LOGGER.debug(
- "Migrating from version %s:%s", config_entry.version, config_entry.minor_version
+ "Migrating from version %s.%s", config_entry.version, config_entry.minor_version
)
if config_entry.version == 1:
@@ -33,15 +99,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
# Add-on startup with type service get started before Core, always (e.g. the
# Multi-Protocol add-on). Probing the firmware would interfere with the add-on,
# so we can't safely probe here. Instead, we must make an educated guess!
- firmware_guess = await guess_firmware_info(
- hass, config_entry.data["device"]
- )
+ firmware_guess = await guess_firmware_info(hass, config_entry.data[DEVICE])
new_data = {**config_entry.data}
- new_data["firmware"] = firmware_guess.firmware_type.value
+ new_data[FIRMWARE] = firmware_guess.firmware_type.value
# Copy `description` to `product`
- new_data["product"] = new_data["description"]
+ new_data[PRODUCT] = new_data[DESCRIPTION]
hass.config_entries.async_update_entry(
config_entry,
@@ -50,6 +114,55 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
minor_version=2,
)
+ if config_entry.minor_version == 2:
+ # Add a `firmware_version` key
+ hass.config_entries.async_update_entry(
+ config_entry,
+ data={
+ **config_entry.data,
+ FIRMWARE_VERSION: None,
+ },
+ version=1,
+ minor_version=3,
+ )
+
+ if config_entry.minor_version == 3:
+ # Old SkyConnect config entries were missing keys
+ if any(
+ key not in config_entry.data
+ for key in (VID, PID, MANUFACTURER, PRODUCT, SERIAL_NUMBER)
+ ):
+ serial_ports = await hass.async_add_executor_job(scan_serial_ports)
+ serial_ports_info = {port.device: port for port in serial_ports}
+ device = config_entry.data[DEVICE]
+
+ if not (usb_info := serial_ports_info.get(device)):
+ raise HomeAssistantError(
+ f"USB device {device} is missing, cannot migrate"
+ )
+
+ hass.config_entries.async_update_entry(
+ config_entry,
+ data={
+ **config_entry.data,
+ VID: usb_info.vid,
+ PID: usb_info.pid,
+ MANUFACTURER: usb_info.manufacturer,
+ PRODUCT: usb_info.description,
+ DESCRIPTION: usb_info.description,
+ SERIAL_NUMBER: usb_info.serial_number,
+ },
+ version=1,
+ minor_version=4,
+ )
+ else:
+ # Existing entries are migrated by just incrementing the version
+ hass.config_entries.async_update_entry(
+ config_entry,
+ version=1,
+ minor_version=4,
+ )
+
_LOGGER.debug(
"Migration to version %s.%s successful",
config_entry.version,
diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py
index b3b4f68ba96..eb5ea214b3e 100644
--- a/homeassistant/components/homeassistant_sky_connect/config_flow.py
+++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py
@@ -10,7 +10,10 @@ from homeassistant.components.homeassistant_hardware import (
firmware_config_flow,
silabs_multiprotocol_addon,
)
-from homeassistant.components.homeassistant_hardware.util import ApplicationType
+from homeassistant.components.homeassistant_hardware.util import (
+ ApplicationType,
+ FirmwareInfo,
+)
from homeassistant.config_entries import (
ConfigEntry,
ConfigEntryBaseFlow,
@@ -21,7 +24,20 @@ from homeassistant.config_entries import (
from homeassistant.core import callback
from homeassistant.helpers.service_info.usb import UsbServiceInfo
-from .const import DOCS_WEB_FLASHER_URL, DOMAIN, HardwareVariant
+from .const import (
+ DESCRIPTION,
+ DEVICE,
+ DOCS_WEB_FLASHER_URL,
+ DOMAIN,
+ FIRMWARE,
+ FIRMWARE_VERSION,
+ MANUFACTURER,
+ PID,
+ PRODUCT,
+ SERIAL_NUMBER,
+ VID,
+ HardwareVariant,
+)
from .util import get_hardware_variant, get_usb_service_info
_LOGGER = logging.getLogger(__name__)
@@ -34,6 +50,7 @@ if TYPE_CHECKING:
def _get_translation_placeholders(self) -> dict[str, str]:
return {}
+
else:
# Multiple inheritance with `Protocol` seems to break
TranslationPlaceholderProtocol = object
@@ -64,7 +81,7 @@ class HomeAssistantSkyConnectConfigFlow(
"""Handle a config flow for Home Assistant SkyConnect."""
VERSION = 1
- MINOR_VERSION = 2
+ MINOR_VERSION = 4
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the config flow."""
@@ -79,7 +96,7 @@ class HomeAssistantSkyConnectConfigFlow(
config_entry: ConfigEntry,
) -> OptionsFlow:
"""Return the options flow."""
- firmware_type = ApplicationType(config_entry.data["firmware"])
+ firmware_type = ApplicationType(config_entry.data[FIRMWARE])
if firmware_type is ApplicationType.CPC:
return HomeAssistantSkyConnectMultiPanOptionsFlowHandler(config_entry)
@@ -97,7 +114,7 @@ class HomeAssistantSkyConnectConfigFlow(
unique_id = f"{vid}:{pid}_{serial_number}_{manufacturer}_{description}"
if await self.async_set_unique_id(unique_id):
- self._abort_if_unique_id_configured(updates={"device": device})
+ self._abort_if_unique_id_configured(updates={DEVICE: device})
discovery_info.device = await self.hass.async_add_executor_job(
usb.get_serial_by_id, discovery_info.device
@@ -118,19 +135,20 @@ class HomeAssistantSkyConnectConfigFlow(
"""Create the config entry."""
assert self._usb_info is not None
assert self._hw_variant is not None
- assert self._probed_firmware_type is not None
+ assert self._probed_firmware_info is not None
return self.async_create_entry(
title=self._hw_variant.full_name,
data={
- "vid": self._usb_info.vid,
- "pid": self._usb_info.pid,
- "serial_number": self._usb_info.serial_number,
- "manufacturer": self._usb_info.manufacturer,
- "description": self._usb_info.description, # For backwards compatibility
- "product": self._usb_info.description,
- "device": self._usb_info.device,
- "firmware": self._probed_firmware_type.value,
+ VID: self._usb_info.vid,
+ PID: self._usb_info.pid,
+ SERIAL_NUMBER: self._usb_info.serial_number,
+ MANUFACTURER: self._usb_info.manufacturer,
+ DESCRIPTION: self._usb_info.description, # For backwards compatibility
+ PRODUCT: self._usb_info.description,
+ DEVICE: self._usb_info.device,
+ FIRMWARE: self._probed_firmware_info.firmware_type.value,
+ FIRMWARE_VERSION: self._probed_firmware_info.firmware_version,
},
)
@@ -145,7 +163,7 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
) -> silabs_multiprotocol_addon.SerialPortSettings:
"""Return the radio serial port settings."""
return silabs_multiprotocol_addon.SerialPortSettings(
- device=self.config_entry.data["device"],
+ device=self.config_entry.data[DEVICE],
baudrate="115200",
flow_control=True,
)
@@ -179,7 +197,8 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
entry=self.config_entry,
data={
**self.config_entry.data,
- "firmware": ApplicationType.EZSP.value,
+ FIRMWARE: ApplicationType.EZSP.value,
+ FIRMWARE_VERSION: None,
},
options=self.config_entry.options,
)
@@ -198,23 +217,32 @@ class HomeAssistantSkyConnectOptionsFlowHandler(
self._usb_info = get_usb_service_info(self.config_entry)
self._hw_variant = HardwareVariant.from_usb_product_name(
- self.config_entry.data["product"]
+ self.config_entry.data[PRODUCT]
)
self._hardware_name = self._hw_variant.full_name
self._device = self._usb_info.device
+ self._probed_firmware_info = FirmwareInfo(
+ device=self._device,
+ firmware_type=ApplicationType(self.config_entry.data[FIRMWARE]),
+ firmware_version=self.config_entry.data[FIRMWARE_VERSION],
+ source="guess",
+ owners=[],
+ )
+
# Regenerate the translation placeholders
self._get_translation_placeholders()
def _async_flow_finished(self) -> ConfigFlowResult:
"""Create the config entry."""
- assert self._probed_firmware_type is not None
+ assert self._probed_firmware_info is not None
self.hass.config_entries.async_update_entry(
entry=self.config_entry,
data={
**self.config_entry.data,
- "firmware": self._probed_firmware_type.value,
+ FIRMWARE: self._probed_firmware_info.firmware_type.value,
+ FIRMWARE_VERSION: self._probed_firmware_info.firmware_version,
},
options=self.config_entry.options,
)
diff --git a/homeassistant/components/homeassistant_sky_connect/const.py b/homeassistant/components/homeassistant_sky_connect/const.py
index cae0b98a25b..70ff047366d 100644
--- a/homeassistant/components/homeassistant_sky_connect/const.py
+++ b/homeassistant/components/homeassistant_sky_connect/const.py
@@ -7,6 +7,20 @@ from typing import Self
DOMAIN = "homeassistant_sky_connect"
DOCS_WEB_FLASHER_URL = "https://skyconnect.home-assistant.io/firmware-update/"
+NABU_CASA_FIRMWARE_RELEASES_URL = (
+ "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest"
+)
+
+FIRMWARE = "firmware"
+FIRMWARE_VERSION = "firmware_version"
+SERIAL_NUMBER = "serial_number"
+MANUFACTURER = "manufacturer"
+PRODUCT = "product"
+DESCRIPTION = "description"
+PID = "pid"
+VID = "vid"
+DEVICE = "device"
+
@dataclasses.dataclass(frozen=True)
class VariantInfo:
diff --git a/homeassistant/components/homeassistant_sky_connect/hardware.py b/homeassistant/components/homeassistant_sky_connect/hardware.py
index 2872077111a..9bfa5d16655 100644
--- a/homeassistant/components/homeassistant_sky_connect/hardware.py
+++ b/homeassistant/components/homeassistant_sky_connect/hardware.py
@@ -5,17 +5,21 @@ from __future__ import annotations
from homeassistant.components.hardware.models import HardwareInfo, USBInfo
from homeassistant.core import HomeAssistant, callback
+from .config_flow import HomeAssistantSkyConnectConfigFlow
from .const import DOMAIN
from .util import get_hardware_variant
DOCUMENTATION_URL = "https://skyconnect.home-assistant.io/documentation/"
+EXPECTED_ENTRY_VERSION = (
+ HomeAssistantSkyConnectConfigFlow.VERSION,
+ HomeAssistantSkyConnectConfigFlow.MINOR_VERSION,
+)
@callback
def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
"""Return board info."""
entries = hass.config_entries.async_entries(DOMAIN)
-
return [
HardwareInfo(
board=None,
@@ -31,4 +35,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]:
url=DOCUMENTATION_URL,
)
for entry in entries
+ # Ignore unmigrated config entries in the hardware page
+ if (entry.version, entry.minor_version) == EXPECTED_ENTRY_VERSION
]
diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json
index a596b9846ce..a990f025e8d 100644
--- a/homeassistant/components/homeassistant_sky_connect/strings.json
+++ b/homeassistant/components/homeassistant_sky_connect/strings.json
@@ -195,5 +195,10 @@
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
}
+ },
+ "exceptions": {
+ "device_disconnected": {
+ "message": "The device is not plugged in"
+ }
}
}
diff --git a/homeassistant/components/homeassistant_sky_connect/update.py b/homeassistant/components/homeassistant_sky_connect/update.py
new file mode 100644
index 00000000000..74c28b37eaf
--- /dev/null
+++ b/homeassistant/components/homeassistant_sky_connect/update.py
@@ -0,0 +1,228 @@
+"""Home Assistant SkyConnect firmware update entity."""
+
+from __future__ import annotations
+
+import logging
+
+import aiohttp
+
+from homeassistant.components.homeassistant_hardware.coordinator import (
+ FirmwareUpdateCoordinator,
+)
+from homeassistant.components.homeassistant_hardware.update import (
+ BaseFirmwareUpdateEntity,
+ FirmwareUpdateEntityDescription,
+)
+from homeassistant.components.homeassistant_hardware.util import (
+ ApplicationType,
+ FirmwareInfo,
+)
+from homeassistant.components.update import UpdateDeviceClass
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import (
+ DOMAIN,
+ FIRMWARE,
+ FIRMWARE_VERSION,
+ NABU_CASA_FIRMWARE_RELEASES_URL,
+ PRODUCT,
+ SERIAL_NUMBER,
+ HardwareVariant,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+FIRMWARE_ENTITY_DESCRIPTIONS: dict[
+ ApplicationType | None, FirmwareUpdateEntityDescription
+] = {
+ ApplicationType.EZSP: FirmwareUpdateEntityDescription(
+ key="firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw.split(" ", 1)[0],
+ fw_type="skyconnect_zigbee_ncp",
+ version_key="ezsp_version",
+ expected_firmware_type=ApplicationType.EZSP,
+ firmware_name="EmberZNet Zigbee",
+ ),
+ ApplicationType.SPINEL: FirmwareUpdateEntityDescription(
+ key="firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0],
+ fw_type="skyconnect_openthread_rcp",
+ version_key="ot_rcp_version",
+ expected_firmware_type=ApplicationType.SPINEL,
+ firmware_name="OpenThread RCP",
+ ),
+ ApplicationType.CPC: FirmwareUpdateEntityDescription(
+ key="firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw,
+ fw_type="skyconnect_multipan",
+ version_key="cpc_version",
+ expected_firmware_type=ApplicationType.CPC,
+ firmware_name="Multiprotocol",
+ ),
+ ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription(
+ key="firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw,
+ fw_type=None, # We don't want to update the bootloader
+ version_key="gecko_bootloader_version",
+ expected_firmware_type=ApplicationType.GECKO_BOOTLOADER,
+ firmware_name="Gecko Bootloader",
+ ),
+ None: FirmwareUpdateEntityDescription(
+ key="firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw,
+ fw_type=None,
+ version_key=None,
+ expected_firmware_type=None,
+ firmware_name=None,
+ ),
+}
+
+
+def _async_create_update_entity(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ session: aiohttp.ClientSession,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> FirmwareUpdateEntity:
+ """Create an update entity that handles firmware type changes."""
+ firmware_type = config_entry.data[FIRMWARE]
+
+ try:
+ entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[
+ ApplicationType(firmware_type)
+ ]
+ except (KeyError, ValueError):
+ _LOGGER.debug(
+ "Unknown firmware type %r, using default entity description", firmware_type
+ )
+ entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None]
+
+ entity = FirmwareUpdateEntity(
+ device=config_entry.data["device"],
+ config_entry=config_entry,
+ update_coordinator=FirmwareUpdateCoordinator(
+ hass,
+ session,
+ NABU_CASA_FIRMWARE_RELEASES_URL,
+ ),
+ entity_description=entity_description,
+ )
+
+ def firmware_type_changed(
+ old_type: ApplicationType | None, new_type: ApplicationType | None
+ ) -> None:
+ """Replace the current entity when the firmware type changes."""
+ er.async_get(hass).async_remove(entity.entity_id)
+ async_add_entities(
+ [
+ _async_create_update_entity(
+ hass, config_entry, session, async_add_entities
+ )
+ ]
+ )
+
+ entity.async_on_remove(
+ entity.add_firmware_type_changed_callback(firmware_type_changed)
+ )
+
+ return entity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the firmware update config entry."""
+ session = async_get_clientsession(hass)
+ entity = _async_create_update_entity(
+ hass, config_entry, session, async_add_entities
+ )
+
+ async_add_entities([entity])
+
+
+class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
+ """SkyConnect firmware update entity."""
+
+ bootloader_reset_type = None
+
+ def __init__(
+ self,
+ device: str,
+ config_entry: ConfigEntry,
+ update_coordinator: FirmwareUpdateCoordinator,
+ entity_description: FirmwareUpdateEntityDescription,
+ ) -> None:
+ """Initialize the SkyConnect firmware update entity."""
+ super().__init__(device, config_entry, update_coordinator, entity_description)
+
+ variant = HardwareVariant.from_usb_product_name(
+ self._config_entry.data[PRODUCT]
+ )
+ serial_number = self._config_entry.data[SERIAL_NUMBER]
+
+ self._attr_unique_id = f"{serial_number}_{self.entity_description.key}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, serial_number)},
+ name=f"{variant.full_name} ({serial_number[:8]})",
+ model=variant.full_name,
+ manufacturer="Nabu Casa",
+ serial_number=serial_number,
+ )
+
+ # Use the cached firmware info if it exists
+ if self._config_entry.data[FIRMWARE] is not None:
+ self._current_firmware_info = FirmwareInfo(
+ device=device,
+ firmware_type=ApplicationType(self._config_entry.data[FIRMWARE]),
+ firmware_version=self._config_entry.data[FIRMWARE_VERSION],
+ owners=[],
+ source="homeassistant_sky_connect",
+ )
+
+ def _update_attributes(self) -> None:
+ """Recompute the attributes of the entity."""
+ super()._update_attributes()
+
+ assert self.device_entry is not None
+ device_registry = dr.async_get(self.hass)
+ device_registry.async_update_device(
+ device_id=self.device_entry.id,
+ sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}",
+ )
+
+ @callback
+ def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None:
+ """Handle updated firmware info being pushed by an integration."""
+ self.hass.config_entries.async_update_entry(
+ self._config_entry,
+ data={
+ **self._config_entry.data,
+ FIRMWARE: firmware_info.firmware_type,
+ FIRMWARE_VERSION: firmware_info.firmware_version,
+ },
+ )
+ super()._firmware_info_callback(firmware_info)
diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py
index b0837eeedbe..71aa8ef99b7 100644
--- a/homeassistant/components/homeassistant_yellow/__init__.py
+++ b/homeassistant/components/homeassistant_yellow/__init__.py
@@ -18,7 +18,7 @@ from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.hassio import is_hassio
-from .const import FIRMWARE, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA
+from .const import FIRMWARE, FIRMWARE_VERSION, RADIO_DEVICE, ZHA_HW_DISCOVERY_DATA
_LOGGER = logging.getLogger(__name__)
@@ -55,11 +55,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
data=ZHA_HW_DISCOVERY_DATA,
)
+ await hass.config_entries.async_forward_entry_setups(entry, ["update"])
+
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
+ await hass.config_entries.async_unload_platforms(entry, ["update"])
return True
@@ -87,6 +90,18 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
minor_version=2,
)
+ if config_entry.minor_version == 2:
+ # Add a `firmware_version` key
+ hass.config_entries.async_update_entry(
+ config_entry,
+ data={
+ **config_entry.data,
+ FIRMWARE_VERSION: None,
+ },
+ version=1,
+ minor_version=3,
+ )
+
_LOGGER.debug(
"Migration to version %s.%s successful",
config_entry.version,
diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py
index 502a20db07c..5472c346e94 100644
--- a/homeassistant/components/homeassistant_yellow/config_flow.py
+++ b/homeassistant/components/homeassistant_yellow/config_flow.py
@@ -24,7 +24,10 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
OptionsFlowHandler as MultiprotocolOptionsFlowHandler,
SerialPortSettings as MultiprotocolSerialPortSettings,
)
-from homeassistant.components.homeassistant_hardware.util import ApplicationType
+from homeassistant.components.homeassistant_hardware.util import (
+ ApplicationType,
+ FirmwareInfo,
+)
from homeassistant.config_entries import (
SOURCE_HARDWARE,
ConfigEntry,
@@ -34,7 +37,14 @@ from homeassistant.config_entries import (
from homeassistant.core import HomeAssistant, async_get_hass, callback
from homeassistant.helpers import discovery_flow, selector
-from .const import DOMAIN, FIRMWARE, RADIO_DEVICE, ZHA_DOMAIN, ZHA_HW_DISCOVERY_DATA
+from .const import (
+ DOMAIN,
+ FIRMWARE,
+ FIRMWARE_VERSION,
+ RADIO_DEVICE,
+ ZHA_DOMAIN,
+ ZHA_HW_DISCOVERY_DATA,
+)
from .hardware import BOARD_NAME
_LOGGER = logging.getLogger(__name__)
@@ -52,7 +62,7 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
"""Handle a config flow for Home Assistant Yellow."""
VERSION = 1
- MINOR_VERSION = 2
+ MINOR_VERSION = 3
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Instantiate config flow."""
@@ -79,10 +89,13 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
- await self._probe_firmware_type()
+ await self._probe_firmware_info()
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
- if self._probed_firmware_type is ApplicationType.EZSP:
+ if (
+ self._probed_firmware_info is not None
+ and self._probed_firmware_info.firmware_type is ApplicationType.EZSP
+ ):
discovery_flow.async_create_flow(
self.hass,
ZHA_DOMAIN,
@@ -98,7 +111,11 @@ class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
title=BOARD_NAME,
data={
# Assume the firmware type is EZSP if we cannot probe it
- FIRMWARE: (self._probed_firmware_type or ApplicationType.EZSP).value,
+ FIRMWARE: (
+ self._probed_firmware_info.firmware_type
+ if self._probed_firmware_info is not None
+ else ApplicationType.EZSP
+ ).value,
},
)
@@ -264,6 +281,14 @@ class HomeAssistantYellowOptionsFlowHandler(
self._hardware_name = BOARD_NAME
self._device = RADIO_DEVICE
+ self._probed_firmware_info = FirmwareInfo(
+ device=self._device,
+ firmware_type=ApplicationType(self.config_entry.data["firmware"]),
+ firmware_version=None,
+ source="guess",
+ owners=[],
+ )
+
# Regenerate the translation placeholders
self._get_translation_placeholders()
@@ -285,13 +310,14 @@ class HomeAssistantYellowOptionsFlowHandler(
def _async_flow_finished(self) -> ConfigFlowResult:
"""Create the config entry."""
- assert self._probed_firmware_type is not None
+ assert self._probed_firmware_info is not None
self.hass.config_entries.async_update_entry(
entry=self.config_entry,
data={
**self.config_entry.data,
- FIRMWARE: self._probed_firmware_type.value,
+ FIRMWARE: self._probed_firmware_info.firmware_type.value,
+ FIRMWARE_VERSION: self._probed_firmware_info.firmware_version,
},
)
diff --git a/homeassistant/components/homeassistant_yellow/const.py b/homeassistant/components/homeassistant_yellow/const.py
index 79753ae9b9e..b8bf17391f9 100644
--- a/homeassistant/components/homeassistant_yellow/const.py
+++ b/homeassistant/components/homeassistant_yellow/const.py
@@ -2,7 +2,11 @@
DOMAIN = "homeassistant_yellow"
+MODEL = "Home Assistant Yellow"
+MANUFACTURER = "Nabu Casa"
+
RADIO_DEVICE = "/dev/ttyAMA1"
+
ZHA_HW_DISCOVERY_DATA = {
"name": "Yellow",
"port": {
@@ -14,4 +18,9 @@ ZHA_HW_DISCOVERY_DATA = {
}
FIRMWARE = "firmware"
+FIRMWARE_VERSION = "firmware_version"
ZHA_DOMAIN = "zha"
+
+NABU_CASA_FIRMWARE_RELEASES_URL = (
+ "https://api.github.com/repos/NabuCasa/silabs-firmware-builder/releases/latest"
+)
diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json
index b089e483899..41c1438b234 100644
--- a/homeassistant/components/homeassistant_yellow/strings.json
+++ b/homeassistant/components/homeassistant_yellow/strings.json
@@ -149,5 +149,12 @@
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
}
+ },
+ "entity": {
+ "update": {
+ "radio_firmware": {
+ "name": "Radio firmware"
+ }
+ }
}
}
diff --git a/homeassistant/components/homeassistant_yellow/update.py b/homeassistant/components/homeassistant_yellow/update.py
new file mode 100644
index 00000000000..9531bd456cb
--- /dev/null
+++ b/homeassistant/components/homeassistant_yellow/update.py
@@ -0,0 +1,226 @@
+"""Home Assistant Yellow firmware update entity."""
+
+from __future__ import annotations
+
+import logging
+
+import aiohttp
+
+from homeassistant.components.homeassistant_hardware.coordinator import (
+ FirmwareUpdateCoordinator,
+)
+from homeassistant.components.homeassistant_hardware.update import (
+ BaseFirmwareUpdateEntity,
+ FirmwareUpdateEntityDescription,
+)
+from homeassistant.components.homeassistant_hardware.util import (
+ ApplicationType,
+ FirmwareInfo,
+)
+from homeassistant.components.update import UpdateDeviceClass
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import (
+ DOMAIN,
+ FIRMWARE,
+ FIRMWARE_VERSION,
+ MANUFACTURER,
+ MODEL,
+ NABU_CASA_FIRMWARE_RELEASES_URL,
+ RADIO_DEVICE,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+FIRMWARE_ENTITY_DESCRIPTIONS: dict[
+ ApplicationType | None, FirmwareUpdateEntityDescription
+] = {
+ ApplicationType.EZSP: FirmwareUpdateEntityDescription(
+ key="radio_firmware",
+ translation_key="radio_firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw.split(" ", 1)[0],
+ fw_type="yellow_zigbee_ncp",
+ version_key="ezsp_version",
+ expected_firmware_type=ApplicationType.EZSP,
+ firmware_name="EmberZNet Zigbee",
+ ),
+ ApplicationType.SPINEL: FirmwareUpdateEntityDescription(
+ key="radio_firmware",
+ translation_key="radio_firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw.split("/", 1)[1].split("_", 1)[0],
+ fw_type="yellow_openthread_rcp",
+ version_key="ot_rcp_version",
+ expected_firmware_type=ApplicationType.SPINEL,
+ firmware_name="OpenThread RCP",
+ ),
+ ApplicationType.CPC: FirmwareUpdateEntityDescription(
+ key="radio_firmware",
+ translation_key="radio_firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw,
+ fw_type="yellow_multipan",
+ version_key="cpc_version",
+ expected_firmware_type=ApplicationType.CPC,
+ firmware_name="Multiprotocol",
+ ),
+ ApplicationType.GECKO_BOOTLOADER: FirmwareUpdateEntityDescription(
+ key="radio_firmware",
+ translation_key="radio_firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw,
+ fw_type=None, # We don't want to update the bootloader
+ version_key="gecko_bootloader_version",
+ expected_firmware_type=ApplicationType.GECKO_BOOTLOADER,
+ firmware_name="Gecko Bootloader",
+ ),
+ None: FirmwareUpdateEntityDescription(
+ key="radio_firmware",
+ translation_key="radio_firmware",
+ display_precision=0,
+ device_class=UpdateDeviceClass.FIRMWARE,
+ entity_category=EntityCategory.CONFIG,
+ version_parser=lambda fw: fw,
+ fw_type=None,
+ version_key=None,
+ expected_firmware_type=None,
+ firmware_name=None,
+ ),
+}
+
+
+def _async_create_update_entity(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ session: aiohttp.ClientSession,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> FirmwareUpdateEntity:
+ """Create an update entity that handles firmware type changes."""
+ firmware_type = config_entry.data[FIRMWARE]
+
+ try:
+ entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[
+ ApplicationType(firmware_type)
+ ]
+ except (KeyError, ValueError):
+ _LOGGER.debug(
+ "Unknown firmware type %r, using default entity description", firmware_type
+ )
+ entity_description = FIRMWARE_ENTITY_DESCRIPTIONS[None]
+
+ entity = FirmwareUpdateEntity(
+ device=RADIO_DEVICE,
+ config_entry=config_entry,
+ update_coordinator=FirmwareUpdateCoordinator(
+ hass,
+ session,
+ NABU_CASA_FIRMWARE_RELEASES_URL,
+ ),
+ entity_description=entity_description,
+ )
+
+ def firmware_type_changed(
+ old_type: ApplicationType | None, new_type: ApplicationType | None
+ ) -> None:
+ """Replace the current entity when the firmware type changes."""
+ er.async_get(hass).async_remove(entity.entity_id)
+ async_add_entities(
+ [
+ _async_create_update_entity(
+ hass, config_entry, session, async_add_entities
+ )
+ ]
+ )
+
+ entity.async_on_remove(
+ entity.add_firmware_type_changed_callback(firmware_type_changed)
+ )
+
+ return entity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the firmware update config entry."""
+ session = async_get_clientsession(hass)
+ entity = _async_create_update_entity(
+ hass, config_entry, session, async_add_entities
+ )
+
+ async_add_entities([entity])
+
+
+class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
+ """Yellow firmware update entity."""
+
+ bootloader_reset_type = "yellow" # Triggers a GPIO reset
+
+ def __init__(
+ self,
+ device: str,
+ config_entry: ConfigEntry,
+ update_coordinator: FirmwareUpdateCoordinator,
+ entity_description: FirmwareUpdateEntityDescription,
+ ) -> None:
+ """Initialize the Yellow firmware update entity."""
+ super().__init__(device, config_entry, update_coordinator, entity_description)
+ self._attr_unique_id = self.entity_description.key
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, "yellow")},
+ name=MODEL,
+ model=MODEL,
+ manufacturer=MANUFACTURER,
+ )
+
+ # Use the cached firmware info if it exists
+ if self._config_entry.data[FIRMWARE] is not None:
+ self._current_firmware_info = FirmwareInfo(
+ device=device,
+ firmware_type=ApplicationType(self._config_entry.data[FIRMWARE]),
+ firmware_version=self._config_entry.data[FIRMWARE_VERSION],
+ owners=[],
+ source="homeassistant_yellow",
+ )
+
+ def _update_attributes(self) -> None:
+ """Recompute the attributes of the entity."""
+ super()._update_attributes()
+
+ assert self.device_entry is not None
+ device_registry = dr.async_get(self.hass)
+ device_registry.async_update_device(
+ device_id=self.device_entry.id,
+ sw_version=f"{self.entity_description.firmware_name} {self._attr_installed_version}",
+ )
+
+ @callback
+ def _firmware_info_callback(self, firmware_info: FirmwareInfo) -> None:
+ """Handle updated firmware info being pushed by an integration."""
+ self.hass.config_entries.async_update_entry(
+ self._config_entry,
+ data={
+ **self._config_entry.data,
+ FIRMWARE: firmware_info.firmware_type,
+ FIRMWARE_VERSION: firmware_info.firmware_version,
+ },
+ )
+ super()._firmware_info_callback(firmware_info)
diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py
index 9837d6094ff..fbd34743496 100644
--- a/homeassistant/components/homee/__init__.py
+++ b/homeassistant/components/homee/__init__.py
@@ -14,7 +14,19 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
-PLATFORMS = [Platform.COVER, Platform.SENSOR]
+PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.BUTTON,
+ Platform.CLIMATE,
+ Platform.COVER,
+ Platform.LIGHT,
+ Platform.LOCK,
+ Platform.NUMBER,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.SWITCH,
+ Platform.VALVE,
+]
type HomeeConfigEntry = ConfigEntry[Homee]
diff --git a/homeassistant/components/homee/binary_sensor.py b/homeassistant/components/homee/binary_sensor.py
new file mode 100644
index 00000000000..3f5f5c46a29
--- /dev/null
+++ b/homeassistant/components/homee/binary_sensor.py
@@ -0,0 +1,190 @@
+"""The Homee binary sensor platform."""
+
+from pyHomee.const import AttributeType
+from pyHomee.model import HomeeAttribute
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import HomeeConfigEntry
+from .entity import HomeeEntity
+
+PARALLEL_UPDATES = 0
+
+BINARY_SENSOR_DESCRIPTIONS: dict[AttributeType, BinarySensorEntityDescription] = {
+ AttributeType.BATTERY_LOW_ALARM: BinarySensorEntityDescription(
+ key="battery",
+ device_class=BinarySensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.BLACKOUT_ALARM: BinarySensorEntityDescription(
+ key="blackout_alarm",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.COALARM: BinarySensorEntityDescription(
+ key="carbon_monoxide", device_class=BinarySensorDeviceClass.CO
+ ),
+ AttributeType.CO2ALARM: BinarySensorEntityDescription(
+ key="carbon_dioxide", device_class=BinarySensorDeviceClass.PROBLEM
+ ),
+ AttributeType.FLOOD_ALARM: BinarySensorEntityDescription(
+ key="flood",
+ device_class=BinarySensorDeviceClass.MOISTURE,
+ ),
+ AttributeType.HIGH_TEMPERATURE_ALARM: BinarySensorEntityDescription(
+ key="high_temperature",
+ device_class=BinarySensorDeviceClass.HEAT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.LEAK_ALARM: BinarySensorEntityDescription(
+ key="leak_alarm",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ ),
+ AttributeType.LOAD_ALARM: BinarySensorEntityDescription(
+ key="load_alarm",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.LOCK_STATE: BinarySensorEntityDescription(
+ key="lock",
+ device_class=BinarySensorDeviceClass.LOCK,
+ ),
+ AttributeType.LOW_TEMPERATURE_ALARM: BinarySensorEntityDescription(
+ key="low_temperature",
+ device_class=BinarySensorDeviceClass.COLD,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.MALFUNCTION_ALARM: BinarySensorEntityDescription(
+ key="malfunction",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.MAXIMUM_ALARM: BinarySensorEntityDescription(
+ key="maximum",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.MINIMUM_ALARM: BinarySensorEntityDescription(
+ key="minimum",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.MOTION_ALARM: BinarySensorEntityDescription(
+ key="motion",
+ device_class=BinarySensorDeviceClass.MOTION,
+ ),
+ AttributeType.MOTOR_BLOCKED_ALARM: BinarySensorEntityDescription(
+ key="motor_blocked",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.ON_OFF: BinarySensorEntityDescription(
+ key="plug",
+ device_class=BinarySensorDeviceClass.PLUG,
+ ),
+ AttributeType.OPEN_CLOSE: BinarySensorEntityDescription(
+ key="opening",
+ device_class=BinarySensorDeviceClass.OPENING,
+ ),
+ AttributeType.OVER_CURRENT_ALARM: BinarySensorEntityDescription(
+ key="overcurrent",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.OVERLOAD_ALARM: BinarySensorEntityDescription(
+ key="overload",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.PRESENCE_ALARM: BinarySensorEntityDescription(
+ key="presence",
+ device_class=BinarySensorDeviceClass.PRESENCE,
+ ),
+ AttributeType.POWER_SUPPLY_ALARM: BinarySensorEntityDescription(
+ key="power",
+ device_class=BinarySensorDeviceClass.POWER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.RAIN_FALL: BinarySensorEntityDescription(
+ key="rain",
+ device_class=BinarySensorDeviceClass.MOISTURE,
+ ),
+ AttributeType.REPLACE_FILTER_ALARM: BinarySensorEntityDescription(
+ key="replace_filter",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.SMOKE_ALARM: BinarySensorEntityDescription(
+ key="smoke",
+ device_class=BinarySensorDeviceClass.SMOKE,
+ ),
+ AttributeType.STORAGE_ALARM: BinarySensorEntityDescription(
+ key="storage",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.SURGE_ALARM: BinarySensorEntityDescription(
+ key="surge",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.TAMPER_ALARM: BinarySensorEntityDescription(
+ key="tamper",
+ device_class=BinarySensorDeviceClass.TAMPER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.VOLTAGE_DROP_ALARM: BinarySensorEntityDescription(
+ key="voltage_drop",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ AttributeType.WATER_ALARM: BinarySensorEntityDescription(
+ key="water",
+ device_class=BinarySensorDeviceClass.MOISTURE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_devices: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add the Homee platform for the binary sensor component."""
+
+ async_add_devices(
+ HomeeBinarySensor(
+ attribute, config_entry, BINARY_SENSOR_DESCRIPTIONS[attribute.type]
+ )
+ for node in config_entry.runtime_data.nodes
+ for attribute in node.attributes
+ if attribute.type in BINARY_SENSOR_DESCRIPTIONS and not attribute.editable
+ )
+
+
+class HomeeBinarySensor(HomeeEntity, BinarySensorEntity):
+ """Representation of a Homee binary sensor."""
+
+ def __init__(
+ self,
+ attribute: HomeeAttribute,
+ entry: HomeeConfigEntry,
+ description: BinarySensorEntityDescription,
+ ) -> None:
+ """Initialize a Homee binary sensor entity."""
+ super().__init__(attribute, entry)
+
+ self.entity_description = description
+ self._attr_translation_key = description.key
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if the binary sensor is on."""
+ return bool(self._attribute.current_value)
diff --git a/homeassistant/components/homee/button.py b/homeassistant/components/homee/button.py
new file mode 100644
index 00000000000..33a8b5f23c8
--- /dev/null
+++ b/homeassistant/components/homee/button.py
@@ -0,0 +1,80 @@
+"""The homee button platform."""
+
+from pyHomee.const import AttributeType
+from pyHomee.model import HomeeAttribute
+
+from homeassistant.components.button import (
+ ButtonDeviceClass,
+ ButtonEntity,
+ ButtonEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import HomeeConfigEntry
+from .entity import HomeeEntity
+
+PARALLEL_UPDATES = 0
+
+BUTTON_DESCRIPTIONS: dict[AttributeType, ButtonEntityDescription] = {
+ AttributeType.AUTOMATIC_MODE_IMPULSE: ButtonEntityDescription(key="automatic_mode"),
+ AttributeType.BRIEFLY_OPEN_IMPULSE: ButtonEntityDescription(key="briefly_open"),
+ AttributeType.IDENTIFICATION_MODE: ButtonEntityDescription(
+ key="identification_mode",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=ButtonDeviceClass.IDENTIFY,
+ ),
+ AttributeType.IMPULSE: ButtonEntityDescription(key="impulse"),
+ AttributeType.LIGHT_IMPULSE: ButtonEntityDescription(key="light"),
+ AttributeType.OPEN_PARTIAL_IMPULSE: ButtonEntityDescription(key="open_partial"),
+ AttributeType.PERMANENTLY_OPEN_IMPULSE: ButtonEntityDescription(
+ key="permanently_open"
+ ),
+ AttributeType.RESET_METER: ButtonEntityDescription(
+ key="reset_meter",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.VENTILATE_IMPULSE: ButtonEntityDescription(key="ventilate"),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add the Homee platform for the button component."""
+
+ async_add_entities(
+ HomeeButton(attribute, config_entry, BUTTON_DESCRIPTIONS[attribute.type])
+ for node in config_entry.runtime_data.nodes
+ for attribute in node.attributes
+ if attribute.type in BUTTON_DESCRIPTIONS and attribute.editable
+ )
+
+
+class HomeeButton(HomeeEntity, ButtonEntity):
+ """Representation of a Homee button."""
+
+ def __init__(
+ self,
+ attribute: HomeeAttribute,
+ entry: HomeeConfigEntry,
+ description: ButtonEntityDescription,
+ ) -> None:
+ """Initialize a Homee button entity."""
+ super().__init__(attribute, entry)
+ self.entity_description = description
+ if attribute.instance == 0:
+ if attribute.type == AttributeType.IMPULSE:
+ self._attr_name = None
+ else:
+ self._attr_translation_key = description.key
+ else:
+ self._attr_translation_key = f"{description.key}_instance"
+ self._attr_translation_placeholders = {"instance": str(attribute.instance)}
+
+ async def async_press(self) -> None:
+ """Handle the button press."""
+ await self.async_set_homee_value(1)
diff --git a/homeassistant/components/homee/climate.py b/homeassistant/components/homee/climate.py
new file mode 100644
index 00000000000..3411d31461c
--- /dev/null
+++ b/homeassistant/components/homee/climate.py
@@ -0,0 +1,200 @@
+"""The Homee climate platform."""
+
+from typing import Any
+
+from pyHomee.const import AttributeType, NodeProfile
+from pyHomee.model import HomeeNode
+
+from homeassistant.components.climate import (
+ ATTR_TEMPERATURE,
+ PRESET_BOOST,
+ PRESET_ECO,
+ PRESET_NONE,
+ ClimateEntity,
+ ClimateEntityFeature,
+ HVACAction,
+ HVACMode,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import HomeeConfigEntry
+from .const import CLIMATE_PROFILES, DOMAIN, HOMEE_UNIT_TO_HA_UNIT, PRESET_MANUAL
+from .entity import HomeeNodeEntity
+
+PARALLEL_UPDATES = 0
+
+ROOM_THERMOSTATS = {
+ NodeProfile.ROOM_THERMOSTAT,
+ NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR,
+ NodeProfile.WIFI_ROOM_THERMOSTAT,
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_devices: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add the Homee platform for the climate component."""
+
+ async_add_devices(
+ HomeeClimate(node, config_entry)
+ for node in config_entry.runtime_data.nodes
+ if node.profile in CLIMATE_PROFILES
+ )
+
+
+class HomeeClimate(HomeeNodeEntity, ClimateEntity):
+ """Representation of a Homee climate entity."""
+
+ _attr_name = None
+ _attr_translation_key = DOMAIN
+
+ def __init__(self, node: HomeeNode, entry: HomeeConfigEntry) -> None:
+ """Initialize a Homee climate entity."""
+ super().__init__(node, entry)
+
+ (
+ self._attr_supported_features,
+ self._attr_hvac_modes,
+ self._attr_preset_modes,
+ ) = get_climate_features(self._node)
+
+ self._target_temp = self._node.get_attribute_by_type(
+ AttributeType.TARGET_TEMPERATURE
+ )
+ assert self._target_temp is not None
+ self._attr_temperature_unit = str(HOMEE_UNIT_TO_HA_UNIT[self._target_temp.unit])
+ self._attr_target_temperature_step = self._target_temp.step_value
+ self._attr_unique_id = f"{self._attr_unique_id}-{self._target_temp.id}"
+
+ self._heating_mode = self._node.get_attribute_by_type(
+ AttributeType.HEATING_MODE
+ )
+ self._temperature = self._node.get_attribute_by_type(AttributeType.TEMPERATURE)
+ self._valve_position = self._node.get_attribute_by_type(
+ AttributeType.CURRENT_VALVE_POSITION
+ )
+
+ @property
+ def hvac_mode(self) -> HVACMode:
+ """Return the hvac operation mode."""
+ if ClimateEntityFeature.TURN_OFF in self.supported_features and (
+ self._heating_mode is not None
+ ):
+ if self._heating_mode.current_value == 0:
+ return HVACMode.OFF
+
+ return HVACMode.HEAT
+
+ @property
+ def hvac_action(self) -> HVACAction:
+ """Return the hvac action."""
+ if self._heating_mode is not None and self._heating_mode.current_value == 0:
+ return HVACAction.OFF
+
+ if (
+ self._valve_position is not None and self._valve_position.current_value == 0
+ ) or (
+ self._temperature is not None
+ and self._temperature.current_value >= self.target_temperature
+ ):
+ return HVACAction.IDLE
+
+ return HVACAction.HEATING
+
+ @property
+ def preset_mode(self) -> str:
+ """Return the present preset mode."""
+ if (
+ ClimateEntityFeature.PRESET_MODE in self.supported_features
+ and self._heating_mode is not None
+ and self._heating_mode.current_value > 0
+ ):
+ assert self._attr_preset_modes is not None
+ return self._attr_preset_modes[int(self._heating_mode.current_value) - 1]
+
+ return PRESET_NONE
+
+ @property
+ def current_temperature(self) -> float | None:
+ """Return the current temperature."""
+ if self._temperature is not None:
+ return self._temperature.current_value
+ return None
+
+ @property
+ def target_temperature(self) -> float:
+ """Return the temperature we try to reach."""
+ assert self._target_temp is not None
+ return self._target_temp.current_value
+
+ @property
+ def min_temp(self) -> float:
+ """Return the lowest settable target temperature."""
+ assert self._target_temp is not None
+ return self._target_temp.minimum
+
+ @property
+ def max_temp(self) -> float:
+ """Return the lowest settable target temperature."""
+ assert self._target_temp is not None
+ return self._target_temp.maximum
+
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
+ """Set new target hvac mode."""
+ # Currently only HEAT and OFF are supported.
+ assert self._heating_mode is not None
+ await self.async_set_homee_value(
+ self._heating_mode, float(hvac_mode == HVACMode.HEAT)
+ )
+
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
+ """Set new target preset mode."""
+ assert self._heating_mode is not None and self._attr_preset_modes is not None
+ await self.async_set_homee_value(
+ self._heating_mode, self._attr_preset_modes.index(preset_mode) + 1
+ )
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new target temperature."""
+ assert self._target_temp is not None
+ if ATTR_TEMPERATURE in kwargs:
+ await self.async_set_homee_value(
+ self._target_temp, kwargs[ATTR_TEMPERATURE]
+ )
+
+ async def async_turn_on(self) -> None:
+ """Turn the entity on."""
+ assert self._heating_mode is not None
+ await self.async_set_homee_value(self._heating_mode, 1)
+
+ async def async_turn_off(self) -> None:
+ """Turn the entity on."""
+ assert self._heating_mode is not None
+ await self.async_set_homee_value(self._heating_mode, 0)
+
+
+def get_climate_features(
+ node: HomeeNode,
+) -> tuple[ClimateEntityFeature, list[HVACMode], list[str] | None]:
+ """Determine supported climate features of a node based on the available attributes."""
+ features = ClimateEntityFeature.TARGET_TEMPERATURE
+ hvac_modes = [HVACMode.HEAT]
+ preset_modes: list[str] = []
+
+ if (
+ attribute := node.get_attribute_by_type(AttributeType.HEATING_MODE)
+ ) is not None:
+ features |= ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
+ hvac_modes.append(HVACMode.OFF)
+
+ if attribute.maximum > 1:
+ # Node supports more modes than off and heating.
+ features |= ClimateEntityFeature.PRESET_MODE
+ preset_modes.extend([PRESET_ECO, PRESET_BOOST, PRESET_MANUAL])
+
+ if len(preset_modes) > 0:
+ preset_modes.insert(0, PRESET_NONE)
+ return (features, hvac_modes, preset_modes if len(preset_modes) > 0 else None)
diff --git a/homeassistant/components/homee/config_flow.py b/homeassistant/components/homee/config_flow.py
index 61d2a3f25a5..1a3c5011f82 100644
--- a/homeassistant/components/homee/config_flow.py
+++ b/homeassistant/components/homee/config_flow.py
@@ -52,7 +52,7 @@ class HomeeConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except HomeeAuthenticationFailedException:
errors["base"] = "invalid_auth"
- except Exception: # pylint: disable=broad-except
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py
index 1d7ce27335f..468fb2d49ac 100644
--- a/homeassistant/components/homee/const.py
+++ b/homeassistant/components/homee/const.py
@@ -1,5 +1,7 @@
"""Constants for the homee integration."""
+from pyHomee.const import NodeProfile
+
from homeassistant.const import (
DEGREE,
LIGHT_LUX,
@@ -62,3 +64,37 @@ WINDOW_MAP = {
2.0: "tilted",
}
WINDOW_MAP_REVERSED = {0.0: "open", 1.0: "closed", 2.0: "tilted"}
+
+# Profile Groups
+CLIMATE_PROFILES = [
+ NodeProfile.COSI_THERM_CHANNEL,
+ NodeProfile.HEATING_SYSTEM,
+ NodeProfile.RADIATOR_THERMOSTAT,
+ NodeProfile.ROOM_THERMOSTAT,
+ NodeProfile.ROOM_THERMOSTAT_WITH_HUMIDITY_SENSOR,
+ NodeProfile.THERMOSTAT_WITH_HEATING_AND_COOLING,
+ NodeProfile.WIFI_RADIATOR_THERMOSTAT,
+ NodeProfile.WIFI_ROOM_THERMOSTAT,
+]
+
+LIGHT_PROFILES = [
+ NodeProfile.DIMMABLE_COLOR_LIGHT,
+ NodeProfile.DIMMABLE_COLOR_METERING_PLUG,
+ NodeProfile.DIMMABLE_COLOR_TEMPERATURE_LIGHT,
+ NodeProfile.DIMMABLE_EXTENDED_COLOR_LIGHT,
+ NodeProfile.DIMMABLE_LIGHT,
+ NodeProfile.DIMMABLE_LIGHT_WITH_BRIGHTNESS_SENSOR,
+ NodeProfile.DIMMABLE_LIGHT_WITH_BRIGHTNESS_AND_PRESENCE_SENSOR,
+ NodeProfile.DIMMABLE_LIGHT_WITH_PRESENCE_SENSOR,
+ NodeProfile.DIMMABLE_METERING_SWITCH,
+ NodeProfile.DIMMABLE_METERING_PLUG,
+ NodeProfile.DIMMABLE_PLUG,
+ NodeProfile.DIMMABLE_RGBWLIGHT,
+ NodeProfile.DIMMABLE_SWITCH,
+ NodeProfile.WIFI_DIMMABLE_RGBWLIGHT,
+ NodeProfile.WIFI_DIMMABLE_LIGHT,
+ NodeProfile.WIFI_ON_OFF_DIMMABLE_METERING_SWITCH,
+]
+
+# Climate Presets
+PRESET_MANUAL = "manual"
diff --git a/homeassistant/components/homee/cover.py b/homeassistant/components/homee/cover.py
index 2e6f7babaff..79a9b00ffba 100644
--- a/homeassistant/components/homee/cover.py
+++ b/homeassistant/components/homee/cover.py
@@ -14,13 +14,15 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .entity import HomeeNodeEntity
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
OPEN_CLOSE_ATTRIBUTES = [
AttributeType.OPEN_CLOSE,
AttributeType.SLAT_ROTATION_IMPULSE,
@@ -78,7 +80,7 @@ def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
- async_add_devices: AddEntitiesCallback,
+ async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the homee platform for the cover integration."""
@@ -205,17 +207,17 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
"""Open the cover."""
assert self._open_close_attribute is not None
if not self._open_close_attribute.is_reversed:
- await self.async_set_value(self._open_close_attribute, 0)
+ await self.async_set_homee_value(self._open_close_attribute, 0)
else:
- await self.async_set_value(self._open_close_attribute, 1)
+ await self.async_set_homee_value(self._open_close_attribute, 1)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
assert self._open_close_attribute is not None
if not self._open_close_attribute.is_reversed:
- await self.async_set_value(self._open_close_attribute, 1)
+ await self.async_set_homee_value(self._open_close_attribute, 1)
else:
- await self.async_set_value(self._open_close_attribute, 0)
+ await self.async_set_homee_value(self._open_close_attribute, 0)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
@@ -230,12 +232,12 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
homee_max = attribute.maximum
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
- await self.async_set_value(attribute, homee_position)
+ await self.async_set_homee_value(attribute, homee_position)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
if self._open_close_attribute is not None:
- await self.async_set_value(self._open_close_attribute, 2)
+ await self.async_set_homee_value(self._open_close_attribute, 2)
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the cover tilt."""
@@ -245,9 +247,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
)
) is not None:
if not slat_attribute.is_reversed:
- await self.async_set_value(slat_attribute, 2)
+ await self.async_set_homee_value(slat_attribute, 2)
else:
- await self.async_set_value(slat_attribute, 1)
+ await self.async_set_homee_value(slat_attribute, 1)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the cover tilt."""
@@ -257,9 +259,9 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
)
) is not None:
if not slat_attribute.is_reversed:
- await self.async_set_value(slat_attribute, 1)
+ await self.async_set_homee_value(slat_attribute, 1)
else:
- await self.async_set_value(slat_attribute, 2)
+ await self.async_set_homee_value(slat_attribute, 2)
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover tilt to a specific position."""
@@ -276,4 +278,4 @@ class HomeeCover(HomeeNodeEntity, CoverEntity):
homee_max = attribute.maximum
homee_position = (position / 100) * (homee_max - homee_min) + homee_min
- await self.async_set_value(attribute, homee_position)
+ await self.async_set_homee_value(attribute, homee_position)
diff --git a/homeassistant/components/homee/entity.py b/homeassistant/components/homee/entity.py
index 5a46b366d3e..165a655d82b 100644
--- a/homeassistant/components/homee/entity.py
+++ b/homeassistant/components/homee/entity.py
@@ -26,10 +26,14 @@ class HomeeEntity(Entity):
f"{entry.runtime_data.settings.uid}-{attribute.node_id}-{attribute.id}"
)
self._entry = entry
+ node = entry.runtime_data.get_node_by_id(attribute.node_id)
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, f"{entry.runtime_data.settings.uid}-{attribute.node_id}")
- }
+ },
+ name=node.name,
+ model=get_name_for_enum(NodeProfile, node.profile),
+ via_device=(DOMAIN, entry.runtime_data.settings.uid),
)
self._host_connected = entry.runtime_data.connected
@@ -50,6 +54,17 @@ class HomeeEntity(Entity):
"""Return the availability of the underlying node."""
return (self._attribute.state == AttributeState.NORMAL) and self._host_connected
+ async def async_set_homee_value(self, value: float) -> None:
+ """Set an attribute value on the homee node."""
+ homee = self._entry.runtime_data
+ try:
+ await homee.set_value(self._attribute.node_id, self._attribute.id, value)
+ except ConnectionClosed as exception:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="connection_closed",
+ ) from exception
+
async def async_update(self) -> None:
"""Update entity from homee."""
homee = self._entry.runtime_data
@@ -129,14 +144,9 @@ class HomeeNodeEntity(Entity):
return None
- def has_attribute(self, attribute_type: AttributeType) -> bool:
- """Check if an attribute of the given type exists."""
- if self._node.attribute_map is None:
- return False
-
- return attribute_type in self._node.attribute_map
-
- async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None:
+ async def async_set_homee_value(
+ self, attribute: HomeeAttribute, value: float
+ ) -> None:
"""Set an attribute value on the homee node."""
homee = self._entry.runtime_data
try:
diff --git a/homeassistant/components/homee/icons.json b/homeassistant/components/homee/icons.json
index 3b1ee17b89c..d6d327a32c5 100644
--- a/homeassistant/components/homee/icons.json
+++ b/homeassistant/components/homee/icons.json
@@ -1,12 +1,37 @@
{
"entity": {
+ "climate": {
+ "homee": {
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "manual": "mdi:hand-back-left"
+ }
+ }
+ }
+ }
+ },
"sensor": {
+ "brightness": {
+ "default": "mdi:brightness-5"
+ },
+ "brightness_instance": {
+ "default": "mdi:brightness-5"
+ },
"link_quality": {
"default": "mdi:signal"
},
"window_position": {
"default": "mdi:window-closed"
}
+ },
+ "switch": {
+ "watchdog": {
+ "default": "mdi:dog"
+ },
+ "manual_operation": {
+ "default": "mdi:hand-back-left"
+ }
}
}
}
diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py
new file mode 100644
index 00000000000..9c66764760e
--- /dev/null
+++ b/homeassistant/components/homee/light.py
@@ -0,0 +1,217 @@
+"""The Homee light platform."""
+
+from typing import Any
+
+from pyHomee.const import AttributeType
+from pyHomee.model import HomeeAttribute, HomeeNode
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS,
+ ATTR_COLOR_TEMP_KELVIN,
+ ATTR_HS_COLOR,
+ ColorMode,
+ LightEntity,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.util.color import (
+ brightness_to_value,
+ color_hs_to_RGB,
+ color_RGB_to_hs,
+ value_to_brightness,
+)
+
+from . import HomeeConfigEntry
+from .const import LIGHT_PROFILES
+from .entity import HomeeNodeEntity
+
+LIGHT_ATTRIBUTES = [
+ AttributeType.COLOR,
+ AttributeType.COLOR_MODE,
+ AttributeType.COLOR_TEMPERATURE,
+ AttributeType.DIMMING_LEVEL,
+]
+
+PARALLEL_UPDATES = 0
+
+
+def is_light_node(node: HomeeNode) -> bool:
+ """Determine if a node is controllable as a homee light based on its profile and attributes."""
+ assert node.attribute_map is not None
+ return node.profile in LIGHT_PROFILES and AttributeType.ON_OFF in node.attribute_map
+
+
+def get_color_mode(supported_modes: set[ColorMode]) -> ColorMode:
+ """Determine the color mode from the supported modes."""
+ if ColorMode.HS in supported_modes:
+ return ColorMode.HS
+ if ColorMode.COLOR_TEMP in supported_modes:
+ return ColorMode.COLOR_TEMP
+ if ColorMode.BRIGHTNESS in supported_modes:
+ return ColorMode.BRIGHTNESS
+
+ return ColorMode.ONOFF
+
+
+def get_light_attribute_sets(
+ node: HomeeNode,
+) -> list[dict[AttributeType, HomeeAttribute]]:
+ """Return the lights with their attributes as found in the node."""
+ lights: list[dict[AttributeType, HomeeAttribute]] = []
+ on_off_attributes = [
+ i for i in node.attributes if i.type == AttributeType.ON_OFF and i.editable
+ ]
+ for a in on_off_attributes:
+ attribute_dict: dict[AttributeType, HomeeAttribute] = {a.type: a}
+ for attribute in node.attributes:
+ if attribute.instance == a.instance and attribute.type in LIGHT_ATTRIBUTES:
+ attribute_dict[attribute.type] = attribute
+ lights.append(attribute_dict)
+
+ return lights
+
+
+def rgb_list_to_decimal(color: tuple[int, int, int]) -> int:
+ """Convert an rgb color from list to decimal representation."""
+ return int(int(color[0]) << 16) + (int(color[1]) << 8) + (int(color[2]))
+
+
+def decimal_to_rgb_list(color: float) -> list[int]:
+ """Convert an rgb color from decimal to list representation."""
+ return [
+ (int(color) & 0xFF0000) >> 16,
+ (int(color) & 0x00FF00) >> 8,
+ (int(color) & 0x0000FF),
+ ]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add the Homee platform for the light entity."""
+
+ async_add_entities(
+ HomeeLight(node, light, config_entry)
+ for node in config_entry.runtime_data.nodes
+ for light in get_light_attribute_sets(node)
+ if is_light_node(node)
+ )
+
+
+class HomeeLight(HomeeNodeEntity, LightEntity):
+ """Representation of a Homee light."""
+
+ def __init__(
+ self,
+ node: HomeeNode,
+ light: dict[AttributeType, HomeeAttribute],
+ entry: HomeeConfigEntry,
+ ) -> None:
+ """Initialize a Homee light."""
+ super().__init__(node, entry)
+
+ self._on_off_attr: HomeeAttribute = light[AttributeType.ON_OFF]
+ self._dimmer_attr: HomeeAttribute | None = light.get(
+ AttributeType.DIMMING_LEVEL
+ )
+ self._col_attr: HomeeAttribute | None = light.get(AttributeType.COLOR)
+ self._temp_attr: HomeeAttribute | None = light.get(
+ AttributeType.COLOR_TEMPERATURE
+ )
+ self._mode_attr: HomeeAttribute | None = light.get(AttributeType.COLOR_MODE)
+
+ self._attr_supported_color_modes = self._get_supported_color_modes()
+ self._attr_color_mode = get_color_mode(self._attr_supported_color_modes)
+
+ if self._temp_attr is not None:
+ self._attr_min_color_temp_kelvin = int(self._temp_attr.minimum)
+ self._attr_max_color_temp_kelvin = int(self._temp_attr.maximum)
+
+ if self._on_off_attr.instance > 0:
+ self._attr_translation_key = "light_instance"
+ self._attr_translation_placeholders = {
+ "instance": str(self._on_off_attr.instance)
+ }
+ else:
+ # If a device has only one light, it will get its name.
+ self._attr_name = None
+ self._attr_unique_id = (
+ f"{entry.runtime_data.settings.uid}-{self._node.id}-{self._on_off_attr.id}"
+ )
+
+ @property
+ def brightness(self) -> int:
+ """Return the brightness of the light."""
+ assert self._dimmer_attr is not None
+ return value_to_brightness(
+ (self._dimmer_attr.minimum + 1, self._dimmer_attr.maximum),
+ self._dimmer_attr.current_value,
+ )
+
+ @property
+ def hs_color(self) -> tuple[float, float] | None:
+ """Return the color of the light."""
+ assert self._col_attr is not None
+ rgb = decimal_to_rgb_list(self._col_attr.current_value)
+ return color_RGB_to_hs(*rgb)
+
+ @property
+ def color_temp_kelvin(self) -> int:
+ """Return the color temperature of the light."""
+ assert self._temp_attr is not None
+ return int(self._temp_attr.current_value)
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if light is on."""
+ return bool(self._on_off_attr.current_value)
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Instruct the light to turn on."""
+ if ATTR_BRIGHTNESS in kwargs and self._dimmer_attr is not None:
+ target_value = round(
+ brightness_to_value(
+ (self._dimmer_attr.minimum, self._dimmer_attr.maximum),
+ kwargs[ATTR_BRIGHTNESS],
+ )
+ )
+ await self.async_set_homee_value(self._dimmer_attr, target_value)
+ else:
+ # If no brightness value is given, just turn on.
+ await self.async_set_homee_value(self._on_off_attr, 1)
+
+ if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temp_attr is not None:
+ await self.async_set_homee_value(
+ self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN]
+ )
+ if ATTR_HS_COLOR in kwargs:
+ color = kwargs[ATTR_HS_COLOR]
+ if self._col_attr is not None:
+ await self.async_set_homee_value(
+ self._col_attr,
+ rgb_list_to_decimal(color_hs_to_RGB(*color)),
+ )
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Instruct the light to turn off."""
+ await self.async_set_homee_value(self._on_off_attr, 0)
+
+ def _get_supported_color_modes(self) -> set[ColorMode]:
+ """Determine the supported color modes from the available attributes."""
+ color_modes: set[ColorMode] = set()
+
+ if self._temp_attr is not None and self._temp_attr.editable:
+ color_modes.add(ColorMode.COLOR_TEMP)
+ if self._col_attr is not None:
+ color_modes.add(ColorMode.HS)
+
+ # If no other color modes are available, set one of those.
+ if len(color_modes) == 0:
+ if self._dimmer_attr is not None:
+ color_modes.add(ColorMode.BRIGHTNESS)
+ else:
+ color_modes.add(ColorMode.ONOFF)
+
+ return color_modes
diff --git a/homeassistant/components/homee/lock.py b/homeassistant/components/homee/lock.py
new file mode 100644
index 00000000000..4cfc34e11fe
--- /dev/null
+++ b/homeassistant/components/homee/lock.py
@@ -0,0 +1,73 @@
+"""The Homee lock platform."""
+
+from typing import Any
+
+from pyHomee.const import AttributeChangedBy, AttributeType
+
+from homeassistant.components.lock import LockEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import HomeeConfigEntry
+from .entity import HomeeEntity
+from .helpers import get_name_for_enum
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_devices: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add the Homee platform for the lock component."""
+
+ async_add_devices(
+ HomeeLock(attribute, config_entry)
+ for node in config_entry.runtime_data.nodes
+ for attribute in node.attributes
+ if (attribute.type == AttributeType.LOCK_STATE and attribute.editable)
+ )
+
+
+class HomeeLock(HomeeEntity, LockEntity):
+ """Representation of a Homee lock."""
+
+ _attr_name = None
+
+ @property
+ def is_locked(self) -> bool:
+ """Return if lock is locked."""
+ return self._attribute.current_value == 1.0
+
+ @property
+ def is_locking(self) -> bool:
+ """Return if lock is locking."""
+ return self._attribute.target_value > self._attribute.current_value
+
+ @property
+ def is_unlocking(self) -> bool:
+ """Return if lock is unlocking."""
+ return self._attribute.target_value < self._attribute.current_value
+
+ @property
+ def changed_by(self) -> str:
+ """Return by whom or what the lock was last changed."""
+ changed_id = str(self._attribute.changed_by_id)
+ changed_by_name = get_name_for_enum(
+ AttributeChangedBy, self._attribute.changed_by
+ )
+ if self._attribute.changed_by == AttributeChangedBy.USER:
+ changed_id = self._entry.runtime_data.get_user_by_id(
+ self._attribute.changed_by_id
+ ).username
+
+ return f"{changed_by_name}-{changed_id}"
+
+ async def async_lock(self, **kwargs: Any) -> None:
+ """Lock specified lock. A code to lock the lock with may be specified."""
+ await self.async_set_homee_value(1)
+
+ async def async_unlock(self, **kwargs: Any) -> None:
+ """Unlock specified lock. A code to unlock the lock with may be specified."""
+ await self.async_set_homee_value(0)
diff --git a/homeassistant/components/homee/manifest.json b/homeassistant/components/homee/manifest.json
index d85ba25b6e7..3c2a99c30dc 100644
--- a/homeassistant/components/homee/manifest.json
+++ b/homeassistant/components/homee/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["homee"],
"quality_scale": "bronze",
- "requirements": ["pyHomee==1.2.5"]
+ "requirements": ["pyHomee==1.2.8"]
}
diff --git a/homeassistant/components/homee/number.py b/homeassistant/components/homee/number.py
new file mode 100644
index 00000000000..5f76b826fcf
--- /dev/null
+++ b/homeassistant/components/homee/number.py
@@ -0,0 +1,132 @@
+"""The Homee number platform."""
+
+from pyHomee.const import AttributeType
+from pyHomee.model import HomeeAttribute
+
+from homeassistant.components.number import (
+ NumberDeviceClass,
+ NumberEntity,
+ NumberEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import HomeeConfigEntry
+from .const import HOMEE_UNIT_TO_HA_UNIT
+from .entity import HomeeEntity
+
+PARALLEL_UPDATES = 0
+
+NUMBER_DESCRIPTIONS = {
+ AttributeType.DOWN_POSITION: NumberEntityDescription(
+ key="down_position",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.DOWN_SLAT_POSITION: NumberEntityDescription(
+ key="down_slat_position",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.DOWN_TIME: NumberEntityDescription(
+ key="down_time",
+ device_class=NumberDeviceClass.DURATION,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.ENDPOSITION_CONFIGURATION: NumberEntityDescription(
+ key="endposition_configuration",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.MOTION_ALARM_CANCELATION_DELAY: NumberEntityDescription(
+ key="motion_alarm_cancelation_delay",
+ device_class=NumberDeviceClass.DURATION,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.OPEN_WINDOW_DETECTION_SENSIBILITY: NumberEntityDescription(
+ key="open_window_detection_sensibility",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.POLLING_INTERVAL: NumberEntityDescription(
+ key="polling_interval",
+ device_class=NumberDeviceClass.DURATION,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.SHUTTER_SLAT_TIME: NumberEntityDescription(
+ key="shutter_slat_time",
+ device_class=NumberDeviceClass.DURATION,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.SLAT_MAX_ANGLE: NumberEntityDescription(
+ key="slat_max_angle",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.SLAT_MIN_ANGLE: NumberEntityDescription(
+ key="slat_min_angle",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.SLAT_STEPS: NumberEntityDescription(
+ key="slat_steps",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.TEMPERATURE_OFFSET: NumberEntityDescription(
+ key="temperature_offset",
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.UP_TIME: NumberEntityDescription(
+ key="up_time",
+ device_class=NumberDeviceClass.DURATION,
+ entity_category=EntityCategory.CONFIG,
+ ),
+ AttributeType.WAKE_UP_INTERVAL: NumberEntityDescription(
+ key="wake_up_interval",
+ device_class=NumberDeviceClass.DURATION,
+ entity_category=EntityCategory.CONFIG,
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add the Homee platform for the number component."""
+
+ async_add_entities(
+ HomeeNumber(attribute, config_entry, NUMBER_DESCRIPTIONS[attribute.type])
+ for node in config_entry.runtime_data.nodes
+ for attribute in node.attributes
+ if attribute.type in NUMBER_DESCRIPTIONS and attribute.data != "fixed_value"
+ )
+
+
+class HomeeNumber(HomeeEntity, NumberEntity):
+ """Representation of a Homee number."""
+
+ def __init__(
+ self,
+ attribute: HomeeAttribute,
+ entry: HomeeConfigEntry,
+ description: NumberEntityDescription,
+ ) -> None:
+ """Initialize a Homee number entity."""
+ super().__init__(attribute, entry)
+ self.entity_description = description
+ self._attr_translation_key = description.key
+ self._attr_native_unit_of_measurement = HOMEE_UNIT_TO_HA_UNIT[attribute.unit]
+ self._attr_native_min_value = attribute.minimum
+ self._attr_native_max_value = attribute.maximum
+ self._attr_native_step = attribute.step_value
+
+ @property
+ def available(self) -> bool:
+ """Return the availability of the entity."""
+ return super().available and self._attribute.editable
+
+ @property
+ def native_value(self) -> int:
+ """Return the native value of the number."""
+ return int(self._attribute.current_value)
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the selected value."""
+ await self.async_set_homee_value(value)
diff --git a/homeassistant/components/homee/quality_scale.yaml b/homeassistant/components/homee/quality_scale.yaml
index ff99d177018..906218cf823 100644
--- a/homeassistant/components/homee/quality_scale.yaml
+++ b/homeassistant/components/homee/quality_scale.yaml
@@ -35,7 +35,7 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
- parallel-updates: todo
+ parallel-updates: done
reauthentication-flow: todo
test-coverage: todo
diff --git a/homeassistant/components/homee/select.py b/homeassistant/components/homee/select.py
new file mode 100644
index 00000000000..70c7972bbda
--- /dev/null
+++ b/homeassistant/components/homee/select.py
@@ -0,0 +1,63 @@
+"""The Homee select platform."""
+
+from pyHomee.const import AttributeType
+from pyHomee.model import HomeeAttribute
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import HomeeConfigEntry
+from .entity import HomeeEntity
+
+PARALLEL_UPDATES = 0
+
+SELECT_DESCRIPTIONS: dict[AttributeType, SelectEntityDescription] = {
+ AttributeType.REPEATER_MODE: SelectEntityDescription(
+ key="repeater_mode",
+ options=["off", "level1", "level2"],
+ entity_category=EntityCategory.CONFIG,
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add the Homee platform for the select component."""
+
+ async_add_entities(
+ HomeeSelect(attribute, config_entry, SELECT_DESCRIPTIONS[attribute.type])
+ for node in config_entry.runtime_data.nodes
+ for attribute in node.attributes
+ if attribute.type in SELECT_DESCRIPTIONS and attribute.editable
+ )
+
+
+class HomeeSelect(HomeeEntity, SelectEntity):
+ """Representation of a Homee select entity."""
+
+ def __init__(
+ self,
+ attribute: HomeeAttribute,
+ entry: HomeeConfigEntry,
+ description: SelectEntityDescription,
+ ) -> None:
+ """Initialize a Homee select entity."""
+ super().__init__(attribute, entry)
+ self.entity_description = description
+ assert description.options is not None
+ self._attr_options = description.options
+ self._attr_translation_key = description.key
+
+ @property
+ def current_option(self) -> str:
+ """Return the current selected option."""
+ return self.options[int(self._attribute.current_value)]
+
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ await self.async_set_homee_value(self.options.index(option))
diff --git a/homeassistant/components/homee/sensor.py b/homeassistant/components/homee/sensor.py
index da01c2aa5b9..e65b73b4a67 100644
--- a/homeassistant/components/homee/sensor.py
+++ b/homeassistant/components/homee/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeeConfigEntry
from .const import (
@@ -27,6 +27,8 @@ from .const import (
from .entity import HomeeEntity, HomeeNodeEntity
from .helpers import get_name_for_enum
+PARALLEL_UPDATES = 0
+
def get_open_close_value(attribute: HomeeAttribute) -> str | None:
"""Return the open/close value."""
@@ -40,10 +42,22 @@ def get_window_value(attribute: HomeeAttribute) -> str | None:
return vals.get(attribute.current_value)
+def get_brightness_device_class(
+ attribute: HomeeAttribute, device_class: SensorDeviceClass | None
+) -> SensorDeviceClass | None:
+ """Return the device class for a brightness sensor."""
+ if attribute.unit == "%":
+ return None
+ return device_class
+
+
@dataclass(frozen=True, kw_only=True)
class HomeeSensorEntityDescription(SensorEntityDescription):
"""A class that describes Homee sensor entities."""
+ device_class_fn: Callable[
+ [HomeeAttribute, SensorDeviceClass | None], SensorDeviceClass | None
+ ] = lambda attribute, device_class: device_class
value_fn: Callable[[HomeeAttribute], str | float | None] = (
lambda value: value.current_value
)
@@ -67,6 +81,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
AttributeType.BRIGHTNESS: HomeeSensorEntityDescription(
key="brightness",
device_class=SensorDeviceClass.ILLUMINANCE,
+ device_class_fn=get_brightness_device_class,
state_class=SensorStateClass.MEASUREMENT,
value_fn=(
lambda attribute: attribute.current_value * 1000
@@ -157,7 +172,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
AttributeType.RAIN_FALL_TODAY: HomeeSensorEntityDescription(
key="rainfall_day",
device_class=SensorDeviceClass.PRECIPITATION,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
AttributeType.RELATIVE_HUMIDITY: HomeeSensorEntityDescription(
key="humidity",
@@ -262,7 +277,7 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HomeeConfigEntry,
- async_add_devices: AddEntitiesCallback,
+ async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Add the homee platform for the sensor components."""
@@ -303,6 +318,9 @@ class HomeeSensor(HomeeEntity, SensorEntity):
if attribute.instance > 0:
self._attr_translation_key = f"{self._attr_translation_key}_instance"
self._attr_translation_placeholders = {"instance": str(attribute.instance)}
+ self._attr_device_class = description.device_class_fn(
+ attribute, description.device_class
+ )
@property
def native_value(self) -> float | str | None:
diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json
index 025d8df21d6..756bdbdf9eb 100644
--- a/homeassistant/components/homee/strings.json
+++ b/homeassistant/components/homee/strings.json
@@ -1,6 +1,6 @@
{
"config": {
- "flow_title": "Homee {name} ({host})",
+ "flow_title": "homee {name} ({host})",
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
@@ -18,15 +18,193 @@
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
- "host": "The IP address of your Homee.",
- "username": "The username for your Homee.",
- "password": "The password for your Homee."
+ "host": "The IP address of your homee.",
+ "username": "The username for your homee.",
+ "password": "The password for your homee."
}
}
}
},
"entity": {
+ "binary_sensor": {
+ "blackout_alarm": {
+ "name": "Blackout"
+ },
+ "carbon_dioxide": {
+ "name": "Carbon dioxide"
+ },
+ "flood": {
+ "name": "Flood"
+ },
+ "high_temperature": {
+ "name": "High temperature"
+ },
+ "leak_alarm": {
+ "name": "Leak"
+ },
+ "load_alarm": {
+ "name": "Load",
+ "state": {
+ "off": "[%key:common::state::normal%]",
+ "on": "Overload"
+ }
+ },
+ "low_temperature": {
+ "name": "Low temperature"
+ },
+ "malfunction": {
+ "name": "Malfunction"
+ },
+ "maximum": {
+ "name": "Maximum level"
+ },
+ "minimum": {
+ "name": "Minimum level"
+ },
+ "motor_blocked": {
+ "name": "Motor blocked"
+ },
+ "overcurrent": {
+ "name": "Overcurrent"
+ },
+ "overload": {
+ "name": "Overload"
+ },
+ "rain": {
+ "name": "Rain"
+ },
+ "replace_filter": {
+ "name": "Replace filter",
+ "state": {
+ "on": "Replace"
+ }
+ },
+ "storage": {
+ "name": "Storage",
+ "state": {
+ "off": "Space available",
+ "on": "Storage full"
+ }
+ },
+ "surge": {
+ "name": "Surge"
+ },
+ "voltage_drop": {
+ "name": "Voltage drop"
+ },
+ "water": {
+ "name": "Water"
+ }
+ },
+ "button": {
+ "automatic_mode": {
+ "name": "Automatic mode"
+ },
+ "briefly_open": {
+ "name": "Briefly open"
+ },
+ "identification_mode": {
+ "name": "Identification mode"
+ },
+ "impulse_instance": {
+ "name": "Impulse {instance}"
+ },
+ "light": {
+ "name": "Light"
+ },
+ "light_instance": {
+ "name": "Light {instance}"
+ },
+ "open_partial": {
+ "name": "Open partially"
+ },
+ "permanently_open": {
+ "name": "Open permanently"
+ },
+ "reset_meter": {
+ "name": "Reset meter"
+ },
+ "reset_meter_instance": {
+ "name": "Reset meter {instance}"
+ },
+ "ventilate": {
+ "name": "Ventilate"
+ }
+ },
+ "climate": {
+ "homee": {
+ "state_attributes": {
+ "preset_mode": {
+ "state": {
+ "manual": "[%key:common::state::manual%]"
+ }
+ }
+ }
+ }
+ },
+ "light": {
+ "light_instance": {
+ "name": "Light {instance}"
+ }
+ },
+ "number": {
+ "down_position": {
+ "name": "Down position"
+ },
+ "down_slat_position": {
+ "name": "Down slat position"
+ },
+ "down_time": {
+ "name": "Down-movement duration"
+ },
+ "endposition_configuration": {
+ "name": "End position"
+ },
+ "motion_alarm_cancelation_delay": {
+ "name": "Motion alarm delay"
+ },
+ "open_window_detection_sensibility": {
+ "name": "Window open sensibility"
+ },
+ "polling_interval": {
+ "name": "Polling interval"
+ },
+ "shutter_slat_time": {
+ "name": "Slat turn duration"
+ },
+ "slat_max_angle": {
+ "name": "Maximum slat angle"
+ },
+ "slat_min_angle": {
+ "name": "Minimum slat angle"
+ },
+ "slat_steps": {
+ "name": "Slat steps"
+ },
+ "temperature_offset": {
+ "name": "Temperature offset"
+ },
+ "up_time": {
+ "name": "Up-movement duration"
+ },
+ "wake_up_interval": {
+ "name": "Wake-up interval"
+ }
+ },
+ "select": {
+ "repeater_mode": {
+ "name": "Repeater mode",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "level1": "Level 1",
+ "level2": "Level 2"
+ }
+ }
+ },
"sensor": {
+ "brightness": {
+ "name": "Illuminance"
+ },
"brightness_instance": {
"name": "Illuminance {instance}"
},
@@ -130,8 +308,8 @@
"open": "[%key:common::state::open%]",
"closed": "[%key:common::state::closed%]",
"partial": "Partially open",
- "opening": "Opening",
- "closing": "Closing"
+ "opening": "[%key:common::state::opening%]",
+ "closing": "[%key:common::state::closing%]"
}
},
"uv": {
@@ -151,11 +329,30 @@
"tilted": "Tilted"
}
}
+ },
+ "switch": {
+ "external_binary_input": {
+ "name": "Child lock"
+ },
+ "manual_operation": {
+ "name": "Manual operation"
+ },
+ "on_off_instance": {
+ "name": "Switch {instance}"
+ },
+ "watchdog": {
+ "name": "Watchdog"
+ }
+ },
+ "valve": {
+ "valve_position": {
+ "name": "Valve position"
+ }
}
},
"exceptions": {
"connection_closed": {
- "message": "Could not connect to Homee while setting attribute"
+ "message": "Could not connect to homee while setting attribute."
}
}
}
diff --git a/homeassistant/components/homee/switch.py b/homeassistant/components/homee/switch.py
new file mode 100644
index 00000000000..041b96963f1
--- /dev/null
+++ b/homeassistant/components/homee/switch.py
@@ -0,0 +1,129 @@
+"""The homee switch platform."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
+from pyHomee.const import AttributeType, NodeProfile
+from pyHomee.model import HomeeAttribute
+
+from homeassistant.components.switch import (
+ SwitchDeviceClass,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import HomeeConfigEntry
+from .const import CLIMATE_PROFILES, LIGHT_PROFILES
+from .entity import HomeeEntity
+
+PARALLEL_UPDATES = 0
+
+
+def get_device_class(
+ attribute: HomeeAttribute, config_entry: HomeeConfigEntry
+) -> SwitchDeviceClass:
+ """Check device class of Switch according to node profile."""
+ node = config_entry.runtime_data.get_node_by_id(attribute.node_id)
+ if node.profile in [
+ NodeProfile.ON_OFF_PLUG,
+ NodeProfile.METERING_PLUG,
+ NodeProfile.DOUBLE_ON_OFF_PLUG,
+ NodeProfile.IMPULSE_PLUG,
+ ]:
+ return SwitchDeviceClass.OUTLET
+
+ return SwitchDeviceClass.SWITCH
+
+
+@dataclass(frozen=True, kw_only=True)
+class HomeeSwitchEntityDescription(SwitchEntityDescription):
+ """A class that describes Homee switch entity."""
+
+ device_class_fn: Callable[[HomeeAttribute, HomeeConfigEntry], SwitchDeviceClass] = (
+ lambda attribute, entry: SwitchDeviceClass.SWITCH
+ )
+
+
+SWITCH_DESCRIPTIONS: dict[AttributeType, HomeeSwitchEntityDescription] = {
+ AttributeType.EXTERNAL_BINARY_INPUT: HomeeSwitchEntityDescription(
+ key="external_binary_input", entity_category=EntityCategory.CONFIG
+ ),
+ AttributeType.MANUAL_OPERATION: HomeeSwitchEntityDescription(
+ key="manual_operation"
+ ),
+ AttributeType.ON_OFF: HomeeSwitchEntityDescription(
+ key="on_off", device_class_fn=get_device_class, name=None
+ ),
+ AttributeType.WATCHDOG_ON_OFF: HomeeSwitchEntityDescription(
+ key="watchdog", entity_category=EntityCategory.CONFIG
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_devices: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the switch platform for the Homee component."""
+
+ for node in config_entry.runtime_data.nodes:
+ async_add_devices(
+ HomeeSwitch(attribute, config_entry, SWITCH_DESCRIPTIONS[attribute.type])
+ for attribute in node.attributes
+ if (attribute.type in SWITCH_DESCRIPTIONS and attribute.editable)
+ and not (
+ attribute.type == AttributeType.ON_OFF
+ and node.profile in LIGHT_PROFILES
+ )
+ and not (
+ attribute.type == AttributeType.MANUAL_OPERATION
+ and node.profile in CLIMATE_PROFILES
+ )
+ )
+
+
+class HomeeSwitch(HomeeEntity, SwitchEntity):
+ """Representation of a Homee switch."""
+
+ entity_description: HomeeSwitchEntityDescription
+
+ def __init__(
+ self,
+ attribute: HomeeAttribute,
+ entry: HomeeConfigEntry,
+ description: HomeeSwitchEntityDescription,
+ ) -> None:
+ """Initialize a Homee switch entity."""
+ super().__init__(attribute, entry)
+ self.entity_description = description
+ if attribute.instance == 0:
+ if attribute.type == AttributeType.ON_OFF:
+ self._attr_name = None
+ else:
+ self._attr_translation_key = description.key
+ else:
+ self._attr_translation_key = f"{description.key}_instance"
+ self._attr_translation_placeholders = {"instance": str(attribute.instance)}
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if entity is on."""
+ return bool(self._attribute.current_value)
+
+ @property
+ def device_class(self) -> SwitchDeviceClass:
+ """Return the device class of the switch."""
+ return self.entity_description.device_class_fn(self._attribute, self._entry)
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the switch on."""
+ await self.async_set_homee_value(1)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the switch off."""
+ await self.async_set_homee_value(0)
diff --git a/homeassistant/components/homee/valve.py b/homeassistant/components/homee/valve.py
new file mode 100644
index 00000000000..995716d7ef8
--- /dev/null
+++ b/homeassistant/components/homee/valve.py
@@ -0,0 +1,83 @@
+"""The Homee valve platform."""
+
+from pyHomee.const import AttributeType
+from pyHomee.model import HomeeAttribute
+
+from homeassistant.components.valve import (
+ ValveDeviceClass,
+ ValveEntity,
+ ValveEntityDescription,
+ ValveEntityFeature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import HomeeConfigEntry
+from .entity import HomeeEntity
+
+PARALLEL_UPDATES = 0
+
+VALVE_DESCRIPTIONS = {
+ AttributeType.CURRENT_VALVE_POSITION: ValveEntityDescription(
+ key="valve_position",
+ device_class=ValveDeviceClass.WATER,
+ )
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: HomeeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add the Homee platform for the valve component."""
+
+ async_add_entities(
+ HomeeValve(attribute, config_entry, VALVE_DESCRIPTIONS[attribute.type])
+ for node in config_entry.runtime_data.nodes
+ for attribute in node.attributes
+ if attribute.type in VALVE_DESCRIPTIONS
+ )
+
+
+class HomeeValve(HomeeEntity, ValveEntity):
+ """Representation of a Homee valve."""
+
+ _attr_reports_position = True
+
+ def __init__(
+ self,
+ attribute: HomeeAttribute,
+ entry: HomeeConfigEntry,
+ description: ValveEntityDescription,
+ ) -> None:
+ """Initialize a Homee valve entity."""
+ super().__init__(attribute, entry)
+ self.entity_description = description
+ self._attr_translation_key = description.key
+
+ @property
+ def supported_features(self) -> ValveEntityFeature:
+ """Return the supported features."""
+ if self._attribute.editable:
+ return ValveEntityFeature.SET_POSITION
+ return ValveEntityFeature(0)
+
+ @property
+ def current_valve_position(self) -> int | None:
+ """Return the current valve position."""
+ return int(self._attribute.current_value)
+
+ @property
+ def is_closing(self) -> bool:
+ """Return if the valve is closing."""
+ return self._attribute.target_value < self._attribute.current_value
+
+ @property
+ def is_opening(self) -> bool:
+ """Return if the valve is opening."""
+ return self._attribute.target_value > self._attribute.current_value
+
+ async def async_set_valve_position(self, position: int) -> None:
+ """Move the valve to a specific position."""
+ await self.async_set_homee_value(position)
diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py
index 97fb17d7db5..8b526b62302 100644
--- a/homeassistant/components/homekit/__init__.py
+++ b/homeassistant/components/homekit/__init__.py
@@ -31,6 +31,7 @@ from homeassistant.components.device_automation.trigger import (
async_validate_trigger_config,
)
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass
+from homeassistant.components.fan import DOMAIN as FAN_DOMAIN
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN
from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
@@ -49,6 +50,7 @@ from homeassistant.const import (
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PORT,
+ CONF_TYPE,
EVENT_HOMEASSISTANT_STOP,
SERVICE_RELOAD,
)
@@ -83,6 +85,7 @@ from homeassistant.loader import IntegrationNotFound, async_get_integration
from homeassistant.util.async_ import create_eager_task
from . import ( # noqa: F401
+ type_air_purifiers,
type_cameras,
type_covers,
type_fans,
@@ -113,6 +116,8 @@ from .const import (
CONF_LINKED_DOORBELL_SENSOR,
CONF_LINKED_HUMIDITY_SENSOR,
CONF_LINKED_MOTION_SENSOR,
+ CONF_LINKED_PM25_SENSOR,
+ CONF_LINKED_TEMPERATURE_SENSOR,
CONFIG_OPTIONS,
DEFAULT_EXCLUDE_ACCESSORY_MODE,
DEFAULT_HOMEKIT_MODE,
@@ -126,6 +131,7 @@ from .const import (
SERVICE_HOMEKIT_UNPAIR,
SHUTDOWN_TIMEOUT,
SIGNAL_RELOAD_ENTITIES,
+ TYPE_AIR_PURIFIER,
)
from .iidmanager import AccessoryIIDStorage
from .models import HomeKitConfigEntry, HomeKitEntryData
@@ -169,6 +175,8 @@ MOTION_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.MOTION)
MOTION_SENSOR = (BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass.MOTION)
DOORBELL_EVENT_SENSOR = (EVENT_DOMAIN, EventDeviceClass.DOORBELL)
HUMIDITY_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.HUMIDITY)
+TEMPERATURE_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.TEMPERATURE)
+PM25_SENSOR = (SENSOR_DOMAIN, SensorDeviceClass.PM25)
def _has_all_unique_names_and_ports(
@@ -221,6 +229,34 @@ UNPAIR_SERVICE_SCHEMA = vol.All(
)
+@callback
+def _async_update_entries_from_yaml(
+ hass: HomeAssistant, config: ConfigType, start_import_flow: bool
+) -> None:
+ current_entries = hass.config_entries.async_entries(DOMAIN)
+ entries_by_name, entries_by_port = _async_get_imported_entries_indices(
+ current_entries
+ )
+ hk_config: list[dict[str, Any]] = config[DOMAIN]
+
+ for index, conf in enumerate(hk_config):
+ if _async_update_config_entry_from_yaml(
+ hass, entries_by_name, entries_by_port, conf
+ ):
+ continue
+
+ if start_import_flow:
+ conf[CONF_ENTRY_INDEX] = index
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=conf,
+ ),
+ eager_start=True,
+ )
+
+
def _async_all_homekit_instances(hass: HomeAssistant) -> list[HomeKit]:
"""All active HomeKit instances."""
hk_data: HomeKitEntryData | None
@@ -258,31 +294,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
await hass.async_add_executor_job(get_loader)
_async_register_events_and_services(hass)
-
if DOMAIN not in config:
return True
- current_entries = hass.config_entries.async_entries(DOMAIN)
- entries_by_name, entries_by_port = _async_get_imported_entries_indices(
- current_entries
- )
-
- for index, conf in enumerate(config[DOMAIN]):
- if _async_update_config_entry_from_yaml(
- hass, entries_by_name, entries_by_port, conf
- ):
- continue
-
- conf[CONF_ENTRY_INDEX] = index
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=conf,
- ),
- eager_start=True,
- )
-
+ _async_update_entries_from_yaml(hass, config, start_import_flow=True)
return True
@@ -326,13 +341,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeKitConfigEntry) -> b
conf = entry.data
options = entry.options
- name = conf[CONF_NAME]
- port = conf[CONF_PORT]
- _LOGGER.debug("Begin setup HomeKit for %s", name)
-
+ name: str = conf[CONF_NAME]
+ port: int = conf[CONF_PORT]
# ip_address and advertise_ip are yaml only
- ip_address = conf.get(CONF_IP_ADDRESS, _DEFAULT_BIND)
- advertise_ips: list[str] = conf.get(
+ ip_address: str | list[str] | None = conf.get(CONF_IP_ADDRESS, _DEFAULT_BIND)
+ advertise_ips: list[str]
+ advertise_ips = conf.get(
CONF_ADVERTISE_IP
) or await network.async_get_announce_addresses(hass)
@@ -344,13 +358,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeKitConfigEntry) -> b
# with users who have not migrated yet we do not do exclude
# these entities by default as we cannot migrate automatically
# since it requires a re-pairing.
- exclude_accessory_mode = conf.get(
+ exclude_accessory_mode: bool = conf.get(
CONF_EXCLUDE_ACCESSORY_MODE, DEFAULT_EXCLUDE_ACCESSORY_MODE
)
- homekit_mode = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE)
- entity_config = options.get(CONF_ENTITY_CONFIG, {}).copy()
- entity_filter = FILTER_SCHEMA(options.get(CONF_FILTER, {}))
- devices = options.get(CONF_DEVICES, [])
+ homekit_mode: str = options.get(CONF_HOMEKIT_MODE, DEFAULT_HOMEKIT_MODE)
+ entity_config: dict[str, Any] = options.get(CONF_ENTITY_CONFIG, {}).copy()
+ entity_filter: EntityFilter = FILTER_SCHEMA(options.get(CONF_FILTER, {}))
+ devices: list[str] = options.get(CONF_DEVICES, [])
homekit = HomeKit(
hass,
@@ -500,26 +514,15 @@ def _async_register_events_and_services(hass: HomeAssistant) -> None:
async def _handle_homekit_reload(service: ServiceCall) -> None:
"""Handle start HomeKit service call."""
config = await async_integration_yaml_config(hass, DOMAIN)
-
if not config or DOMAIN not in config:
return
-
- current_entries = hass.config_entries.async_entries(DOMAIN)
- entries_by_name, entries_by_port = _async_get_imported_entries_indices(
- current_entries
- )
-
- for conf in config[DOMAIN]:
- _async_update_config_entry_from_yaml(
- hass, entries_by_name, entries_by_port, conf
+ _async_update_entries_from_yaml(hass, config, start_import_flow=False)
+ await asyncio.gather(
+ *(
+ create_eager_task(hass.config_entries.async_reload(entry.entry_id))
+ for entry in hass.config_entries.async_entries(DOMAIN)
)
-
- reload_tasks = [
- create_eager_task(hass.config_entries.async_reload(entry.entry_id))
- for entry in current_entries
- ]
-
- await asyncio.gather(*reload_tasks)
+ )
async_register_admin_service(
hass,
@@ -537,7 +540,7 @@ class HomeKit:
hass: HomeAssistant,
name: str,
port: int,
- ip_address: str | None,
+ ip_address: list[str] | str | None,
entity_filter: EntityFilter,
exclude_accessory_mode: bool,
entity_config: dict[str, Any],
@@ -1141,6 +1144,21 @@ class HomeKit:
CONF_LINKED_DOORBELL_SENSOR, doorbell_event_entity_id
)
+ if domain == FAN_DOMAIN:
+ if current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR):
+ config[entity_id].setdefault(
+ CONF_LINKED_HUMIDITY_SENSOR, current_humidity_sensor_entity_id
+ )
+ if current_pm25_sensor_entity_id := lookup.get(PM25_SENSOR):
+ config[entity_id].setdefault(CONF_TYPE, TYPE_AIR_PURIFIER)
+ config[entity_id].setdefault(
+ CONF_LINKED_PM25_SENSOR, current_pm25_sensor_entity_id
+ )
+ if current_temperature_sensor_entity_id := lookup.get(TEMPERATURE_SENSOR):
+ config[entity_id].setdefault(
+ CONF_LINKED_TEMPERATURE_SENSOR, current_temperature_sensor_entity_id
+ )
+
if domain == HUMIDIFIER_DOMAIN and (
current_humidity_sensor_entity_id := lookup.get(HUMIDITY_SENSOR)
):
diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py
index 8d10387e239..95842d56094 100644
--- a/homeassistant/components/homekit/accessories.py
+++ b/homeassistant/components/homekit/accessories.py
@@ -15,6 +15,7 @@ from pyhap.service import Service
from pyhap.util import callback as pyhap_callback
from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature
+from homeassistant.components.lawn_mower import LawnMowerEntityFeature
from homeassistant.components.media_player import MediaPlayerDeviceClass
from homeassistant.components.remote import RemoteEntityFeature
from homeassistant.components.sensor import SensorDeviceClass
@@ -84,6 +85,8 @@ from .const import (
SERV_ACCESSORY_INFO,
SERV_BATTERY_SERVICE,
SIGNAL_RELOAD_ENTITIES,
+ TYPE_AIR_PURIFIER,
+ TYPE_FAN,
TYPE_FAUCET,
TYPE_OUTLET,
TYPE_SHOWER,
@@ -111,6 +114,10 @@ SWITCH_TYPES = {
TYPE_SWITCH: "Switch",
TYPE_VALVE: "ValveSwitch",
}
+FAN_TYPES = {
+ TYPE_AIR_PURIFIER: "AirPurifier",
+ TYPE_FAN: "Fan",
+}
TYPES: Registry[str, type[HomeAccessory]] = Registry()
RELOAD_ON_CHANGE_ATTRS = (
@@ -177,7 +184,10 @@ def get_accessory( # noqa: C901
a_type = "WindowCovering"
elif state.domain == "fan":
- a_type = "Fan"
+ if fan_type := config.get(CONF_TYPE):
+ a_type = FAN_TYPES[fan_type]
+ else:
+ a_type = "Fan"
elif state.domain == "humidifier":
a_type = "HumidifierDehumidifier"
@@ -235,6 +245,13 @@ def get_accessory( # noqa: C901
a_type = "CarbonDioxideSensor"
elif device_class == SensorDeviceClass.ILLUMINANCE or unit == LIGHT_LUX:
a_type = "LightSensor"
+ else:
+ _LOGGER.debug(
+ "%s: Unsupported sensor type (device_class=%s) (unit=%s)",
+ state.entity_id,
+ device_class,
+ unit,
+ )
elif state.domain == "switch":
if switch_type := config.get(CONF_TYPE):
@@ -250,6 +267,13 @@ def get_accessory( # noqa: C901
elif state.domain == "vacuum":
a_type = "Vacuum"
+ elif (
+ state.domain == "lawn_mower"
+ and features & LawnMowerEntityFeature.DOCK
+ and features & LawnMowerEntityFeature.START_MOWING
+ ):
+ a_type = "LawnMower"
+
elif state.domain == "remote" and features & RemoteEntityFeature.ACTIVITY:
a_type = "ActivityRemote"
diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py
index 53db7774821..0ef2e8563bc 100644
--- a/homeassistant/components/homekit/config_flow.py
+++ b/homeassistant/components/homekit/config_flow.py
@@ -106,6 +106,7 @@ SUPPORTED_DOMAINS = [
"sensor",
"switch",
"vacuum",
+ "lawn_mower",
"water_heater",
VALVE_DOMAIN,
]
@@ -123,6 +124,7 @@ DEFAULT_DOMAINS = [
REMOTE_DOMAIN,
"switch",
"vacuum",
+ "lawn_mower",
"water_heater",
]
diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py
index 00b3de49169..ae682a0ea2d 100644
--- a/homeassistant/components/homekit/const.py
+++ b/homeassistant/components/homekit/const.py
@@ -49,9 +49,13 @@ CONF_EXCLUDE_ACCESSORY_MODE = "exclude_accessory_mode"
CONF_LINKED_BATTERY_SENSOR = "linked_battery_sensor"
CONF_LINKED_BATTERY_CHARGING_SENSOR = "linked_battery_charging_sensor"
CONF_LINKED_DOORBELL_SENSOR = "linked_doorbell_sensor"
+CONF_LINKED_FILTER_CHANGE_INDICATION = "linked_filter_change_indication_binary_sensor"
+CONF_LINKED_FILTER_LIFE_LEVEL = "linked_filter_life_level_sensor"
CONF_LINKED_MOTION_SENSOR = "linked_motion_sensor"
CONF_LINKED_HUMIDITY_SENSOR = "linked_humidity_sensor"
CONF_LINKED_OBSTRUCTION_SENSOR = "linked_obstruction_sensor"
+CONF_LINKED_PM25_SENSOR = "linked_pm25_sensor"
+CONF_LINKED_TEMPERATURE_SENSOR = "linked_temperature_sensor"
CONF_LOW_BATTERY_THRESHOLD = "low_battery_threshold"
CONF_MAX_FPS = "max_fps"
CONF_MAX_HEIGHT = "max_height"
@@ -120,12 +124,15 @@ TYPE_SHOWER = "shower"
TYPE_SPRINKLER = "sprinkler"
TYPE_SWITCH = "switch"
TYPE_VALVE = "valve"
+TYPE_FAN = "fan"
+TYPE_AIR_PURIFIER = "air_purifier"
# #### Categories ####
CATEGORY_RECEIVER = 34
# #### Services ####
SERV_ACCESSORY_INFO = "AccessoryInformation"
+SERV_AIR_PURIFIER = "AirPurifier"
SERV_AIR_QUALITY_SENSOR = "AirQualitySensor"
SERV_BATTERY_SERVICE = "BatteryService"
SERV_CAMERA_RTP_STREAM_MANAGEMENT = "CameraRTPStreamManagement"
@@ -135,6 +142,7 @@ SERV_CONTACT_SENSOR = "ContactSensor"
SERV_DOOR = "Door"
SERV_DOORBELL = "Doorbell"
SERV_FANV2 = "Fanv2"
+SERV_FILTER_MAINTENANCE = "FilterMaintenance"
SERV_GARAGE_DOOR_OPENER = "GarageDoorOpener"
SERV_HUMIDIFIER_DEHUMIDIFIER = "HumidifierDehumidifier"
SERV_HUMIDITY_SENSOR = "HumiditySensor"
@@ -181,6 +189,7 @@ CHAR_CONFIGURED_NAME = "ConfiguredName"
CHAR_CONTACT_SENSOR_STATE = "ContactSensorState"
CHAR_COOLING_THRESHOLD_TEMPERATURE = "CoolingThresholdTemperature"
CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = "CurrentAmbientLightLevel"
+CHAR_CURRENT_AIR_PURIFIER_STATE = "CurrentAirPurifierState"
CHAR_CURRENT_DOOR_STATE = "CurrentDoorState"
CHAR_CURRENT_FAN_STATE = "CurrentFanState"
CHAR_CURRENT_HEATING_COOLING = "CurrentHeatingCoolingState"
@@ -192,6 +201,8 @@ CHAR_CURRENT_TEMPERATURE = "CurrentTemperature"
CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle"
CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState"
CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY = "RelativeHumidityDehumidifierThreshold"
+CHAR_FILTER_CHANGE_INDICATION = "FilterChangeIndication"
+CHAR_FILTER_LIFE_LEVEL = "FilterLifeLevel"
CHAR_FIRMWARE_REVISION = "FirmwareRevision"
CHAR_HARDWARE_REVISION = "HardwareRevision"
CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature"
@@ -229,6 +240,7 @@ CHAR_SMOKE_DETECTED = "SmokeDetected"
CHAR_STATUS_LOW_BATTERY = "StatusLowBattery"
CHAR_STREAMING_STRATUS = "StreamingStatus"
CHAR_SWING_MODE = "SwingMode"
+CHAR_TARGET_AIR_PURIFIER_STATE = "TargetAirPurifierState"
CHAR_TARGET_DOOR_STATE = "TargetDoorState"
CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState"
CHAR_TARGET_POSITION = "TargetPosition"
@@ -256,6 +268,7 @@ PROP_VALID_VALUES = "ValidValues"
# #### Thresholds ####
THRESHOLD_CO = 25
THRESHOLD_CO2 = 1000
+THRESHOLD_FILTER_CHANGE_NEEDED = 10
# #### Default values ####
DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C
diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json
index d7ea293b5dc..4ae2e43dfb2 100644
--- a/homeassistant/components/homekit/manifest.json
+++ b/homeassistant/components/homekit/manifest.json
@@ -10,7 +10,7 @@
"loggers": ["pyhap"],
"requirements": [
"HAP-python==4.9.2",
- "fnv-hash-fast==1.2.2",
+ "fnv-hash-fast==1.4.0",
"PyQRCode==1.2.1",
"base36==0.1.1"
],
diff --git a/homeassistant/components/homekit/type_air_purifiers.py b/homeassistant/components/homekit/type_air_purifiers.py
new file mode 100644
index 00000000000..25d305a0aa9
--- /dev/null
+++ b/homeassistant/components/homekit/type_air_purifiers.py
@@ -0,0 +1,469 @@
+"""Class to hold all air purifier accessories."""
+
+import logging
+from typing import Any
+
+from pyhap.characteristic import Characteristic
+from pyhap.const import CATEGORY_AIR_PURIFIER
+from pyhap.service import Service
+from pyhap.util import callback as pyhap_callback
+
+from homeassistant.const import STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
+from homeassistant.core import (
+ Event,
+ EventStateChangedData,
+ HassJobType,
+ State,
+ callback,
+)
+from homeassistant.helpers.event import async_track_state_change_event
+
+from .accessories import TYPES
+from .const import (
+ CHAR_ACTIVE,
+ CHAR_AIR_QUALITY,
+ CHAR_CURRENT_AIR_PURIFIER_STATE,
+ CHAR_CURRENT_HUMIDITY,
+ CHAR_CURRENT_TEMPERATURE,
+ CHAR_FILTER_CHANGE_INDICATION,
+ CHAR_FILTER_LIFE_LEVEL,
+ CHAR_NAME,
+ CHAR_PM25_DENSITY,
+ CHAR_TARGET_AIR_PURIFIER_STATE,
+ CONF_LINKED_FILTER_CHANGE_INDICATION,
+ CONF_LINKED_FILTER_LIFE_LEVEL,
+ CONF_LINKED_HUMIDITY_SENSOR,
+ CONF_LINKED_PM25_SENSOR,
+ CONF_LINKED_TEMPERATURE_SENSOR,
+ SERV_AIR_PURIFIER,
+ SERV_AIR_QUALITY_SENSOR,
+ SERV_FILTER_MAINTENANCE,
+ SERV_HUMIDITY_SENSOR,
+ SERV_TEMPERATURE_SENSOR,
+ THRESHOLD_FILTER_CHANGE_NEEDED,
+)
+from .type_fans import ATTR_PRESET_MODE, CHAR_ROTATION_SPEED, Fan
+from .util import cleanup_name_for_homekit, convert_to_float, density_to_air_quality
+
+_LOGGER = logging.getLogger(__name__)
+
+CURRENT_STATE_INACTIVE = 0
+CURRENT_STATE_IDLE = 1
+CURRENT_STATE_PURIFYING_AIR = 2
+TARGET_STATE_MANUAL = 0
+TARGET_STATE_AUTO = 1
+FILTER_CHANGE_FILTER = 1
+FILTER_OK = 0
+
+IGNORED_STATES = {STATE_UNAVAILABLE, STATE_UNKNOWN}
+
+
+@TYPES.register("AirPurifier")
+class AirPurifier(Fan):
+ """Generate an AirPurifier accessory for an air purifier entity.
+
+ Currently supports, in addition to Fan properties:
+ temperature; humidity; PM2.5; auto mode.
+ """
+
+ def __init__(self, *args: Any) -> None:
+ """Initialize a new AirPurifier accessory object."""
+ super().__init__(*args, category=CATEGORY_AIR_PURIFIER)
+
+ self.auto_preset: str | None = None
+ if self.preset_modes is not None:
+ for preset in self.preset_modes:
+ if str(preset).lower() == "auto":
+ self.auto_preset = preset
+ break
+
+ def create_services(self) -> Service:
+ """Create and configure the primary service for this accessory."""
+ self.chars.append(CHAR_ACTIVE)
+ self.chars.append(CHAR_CURRENT_AIR_PURIFIER_STATE)
+ self.chars.append(CHAR_TARGET_AIR_PURIFIER_STATE)
+ serv_air_purifier = self.add_preload_service(SERV_AIR_PURIFIER, self.chars)
+ self.set_primary_service(serv_air_purifier)
+
+ self.char_active: Characteristic = serv_air_purifier.configure_char(
+ CHAR_ACTIVE, value=0
+ )
+
+ self.preset_mode_chars: dict[str, Characteristic]
+ self.char_current_humidity: Characteristic | None = None
+ self.char_pm25_density: Characteristic | None = None
+ self.char_current_temperature: Characteristic | None = None
+ self.char_filter_change_indication: Characteristic | None = None
+ self.char_filter_life_level: Characteristic | None = None
+
+ self.char_target_air_purifier_state: Characteristic = (
+ serv_air_purifier.configure_char(
+ CHAR_TARGET_AIR_PURIFIER_STATE,
+ value=0,
+ )
+ )
+
+ self.char_current_air_purifier_state: Characteristic = (
+ serv_air_purifier.configure_char(
+ CHAR_CURRENT_AIR_PURIFIER_STATE,
+ value=0,
+ )
+ )
+
+ self.linked_humidity_sensor = self.config.get(CONF_LINKED_HUMIDITY_SENSOR)
+ if self.linked_humidity_sensor:
+ humidity_serv = self.add_preload_service(SERV_HUMIDITY_SENSOR, CHAR_NAME)
+ serv_air_purifier.add_linked_service(humidity_serv)
+ self.char_current_humidity = humidity_serv.configure_char(
+ CHAR_CURRENT_HUMIDITY, value=0
+ )
+
+ humidity_state = self.hass.states.get(self.linked_humidity_sensor)
+ if humidity_state:
+ self._async_update_current_humidity(humidity_state)
+
+ self.linked_pm25_sensor = self.config.get(CONF_LINKED_PM25_SENSOR)
+ if self.linked_pm25_sensor:
+ pm25_serv = self.add_preload_service(
+ SERV_AIR_QUALITY_SENSOR,
+ [CHAR_AIR_QUALITY, CHAR_NAME, CHAR_PM25_DENSITY],
+ )
+ serv_air_purifier.add_linked_service(pm25_serv)
+ self.char_pm25_density = pm25_serv.configure_char(
+ CHAR_PM25_DENSITY, value=0
+ )
+
+ self.char_air_quality = pm25_serv.configure_char(CHAR_AIR_QUALITY)
+
+ pm25_state = self.hass.states.get(self.linked_pm25_sensor)
+ if pm25_state:
+ self._async_update_current_pm25(pm25_state)
+
+ self.linked_temperature_sensor = self.config.get(CONF_LINKED_TEMPERATURE_SENSOR)
+ if self.linked_temperature_sensor:
+ temperature_serv = self.add_preload_service(
+ SERV_TEMPERATURE_SENSOR, [CHAR_NAME, CHAR_CURRENT_TEMPERATURE]
+ )
+ serv_air_purifier.add_linked_service(temperature_serv)
+ self.char_current_temperature = temperature_serv.configure_char(
+ CHAR_CURRENT_TEMPERATURE, value=0
+ )
+
+ temperature_state = self.hass.states.get(self.linked_temperature_sensor)
+ if temperature_state:
+ self._async_update_current_temperature(temperature_state)
+
+ self.linked_filter_change_indicator_binary_sensor = self.config.get(
+ CONF_LINKED_FILTER_CHANGE_INDICATION
+ )
+ self.linked_filter_life_level_sensor = self.config.get(
+ CONF_LINKED_FILTER_LIFE_LEVEL
+ )
+ if (
+ self.linked_filter_change_indicator_binary_sensor
+ or self.linked_filter_life_level_sensor
+ ):
+ chars = [CHAR_NAME, CHAR_FILTER_CHANGE_INDICATION]
+ if self.linked_filter_life_level_sensor:
+ chars.append(CHAR_FILTER_LIFE_LEVEL)
+ serv_filter_maintenance = self.add_preload_service(
+ SERV_FILTER_MAINTENANCE, chars
+ )
+ serv_air_purifier.add_linked_service(serv_filter_maintenance)
+ serv_filter_maintenance.configure_char(
+ CHAR_NAME,
+ value=cleanup_name_for_homekit(f"{self.display_name} Filter"),
+ )
+
+ self.char_filter_change_indication = serv_filter_maintenance.configure_char(
+ CHAR_FILTER_CHANGE_INDICATION,
+ value=0,
+ )
+
+ if self.linked_filter_change_indicator_binary_sensor:
+ filter_change_indicator_state = self.hass.states.get(
+ self.linked_filter_change_indicator_binary_sensor
+ )
+ if filter_change_indicator_state:
+ self._async_update_filter_change_indicator(
+ filter_change_indicator_state
+ )
+
+ if self.linked_filter_life_level_sensor:
+ self.char_filter_life_level = serv_filter_maintenance.configure_char(
+ CHAR_FILTER_LIFE_LEVEL,
+ value=0,
+ )
+
+ filter_life_level_state = self.hass.states.get(
+ self.linked_filter_life_level_sensor
+ )
+ if filter_life_level_state:
+ self._async_update_filter_life_level(filter_life_level_state)
+
+ return serv_air_purifier
+
+ def should_add_preset_mode_switch(self, preset_mode: str) -> bool:
+ """Check if a preset mode switch should be added."""
+ return preset_mode.lower() != "auto"
+
+ @callback
+ @pyhap_callback # type: ignore[misc]
+ def run(self) -> None:
+ """Handle accessory driver started event.
+
+ Run inside the Home Assistant event loop.
+ """
+ if self.linked_humidity_sensor:
+ self._subscriptions.append(
+ async_track_state_change_event(
+ self.hass,
+ [self.linked_humidity_sensor],
+ self._async_update_current_humidity_event,
+ job_type=HassJobType.Callback,
+ )
+ )
+
+ if self.linked_pm25_sensor:
+ self._subscriptions.append(
+ async_track_state_change_event(
+ self.hass,
+ [self.linked_pm25_sensor],
+ self._async_update_current_pm25_event,
+ job_type=HassJobType.Callback,
+ )
+ )
+
+ if self.linked_temperature_sensor:
+ self._subscriptions.append(
+ async_track_state_change_event(
+ self.hass,
+ [self.linked_temperature_sensor],
+ self._async_update_current_temperature_event,
+ job_type=HassJobType.Callback,
+ )
+ )
+
+ if self.linked_filter_change_indicator_binary_sensor:
+ self._subscriptions.append(
+ async_track_state_change_event(
+ self.hass,
+ [self.linked_filter_change_indicator_binary_sensor],
+ self._async_update_filter_change_indicator_event,
+ job_type=HassJobType.Callback,
+ )
+ )
+
+ if self.linked_filter_life_level_sensor:
+ self._subscriptions.append(
+ async_track_state_change_event(
+ self.hass,
+ [self.linked_filter_life_level_sensor],
+ self._async_update_filter_life_level_event,
+ job_type=HassJobType.Callback,
+ )
+ )
+
+ super().run()
+
+ @callback
+ def _async_update_current_humidity_event(
+ self, event: Event[EventStateChangedData]
+ ) -> None:
+ """Handle state change event listener callback."""
+ self._async_update_current_humidity(event.data["new_state"])
+
+ @callback
+ def _async_update_current_humidity(self, new_state: State | None) -> None:
+ """Handle linked humidity sensor state change to update HomeKit value."""
+ if new_state is None or new_state.state in IGNORED_STATES:
+ return
+
+ if (
+ (current_humidity := convert_to_float(new_state.state)) is None
+ or not self.char_current_humidity
+ or self.char_current_humidity.value == current_humidity
+ ):
+ return
+
+ _LOGGER.debug(
+ "%s: Linked humidity sensor %s changed to %d",
+ self.entity_id,
+ self.linked_humidity_sensor,
+ current_humidity,
+ )
+ self.char_current_humidity.set_value(current_humidity)
+
+ @callback
+ def _async_update_current_pm25_event(
+ self, event: Event[EventStateChangedData]
+ ) -> None:
+ """Handle state change event listener callback."""
+ self._async_update_current_pm25(event.data["new_state"])
+
+ @callback
+ def _async_update_current_pm25(self, new_state: State | None) -> None:
+ """Handle linked pm25 sensor state change to update HomeKit value."""
+ if new_state is None or new_state.state in IGNORED_STATES:
+ return
+
+ if (
+ (current_pm25 := convert_to_float(new_state.state)) is None
+ or not self.char_pm25_density
+ or self.char_pm25_density.value == current_pm25
+ ):
+ return
+
+ _LOGGER.debug(
+ "%s: Linked pm25 sensor %s changed to %d",
+ self.entity_id,
+ self.linked_pm25_sensor,
+ current_pm25,
+ )
+ self.char_pm25_density.set_value(current_pm25)
+ air_quality = density_to_air_quality(current_pm25)
+ self.char_air_quality.set_value(air_quality)
+ _LOGGER.debug("%s: Set air_quality to %d", self.entity_id, air_quality)
+
+ @callback
+ def _async_update_current_temperature_event(
+ self, event: Event[EventStateChangedData]
+ ) -> None:
+ """Handle state change event listener callback."""
+ self._async_update_current_temperature(event.data["new_state"])
+
+ @callback
+ def _async_update_current_temperature(self, new_state: State | None) -> None:
+ """Handle linked temperature sensor state change to update HomeKit value."""
+ if new_state is None or new_state.state in IGNORED_STATES:
+ return
+
+ if (
+ (current_temperature := convert_to_float(new_state.state)) is None
+ or not self.char_current_temperature
+ or self.char_current_temperature.value == current_temperature
+ ):
+ return
+
+ _LOGGER.debug(
+ "%s: Linked temperature sensor %s changed to %d",
+ self.entity_id,
+ self.linked_temperature_sensor,
+ current_temperature,
+ )
+ self.char_current_temperature.set_value(current_temperature)
+
+ @callback
+ def _async_update_filter_change_indicator_event(
+ self, event: Event[EventStateChangedData]
+ ) -> None:
+ """Handle state change event listener callback."""
+ self._async_update_filter_change_indicator(event.data.get("new_state"))
+
+ @callback
+ def _async_update_filter_change_indicator(self, new_state: State | None) -> None:
+ """Handle linked filter change indicator binary sensor state change to update HomeKit value."""
+ if new_state is None or new_state.state in IGNORED_STATES:
+ return
+
+ current_change_indicator = (
+ FILTER_CHANGE_FILTER if new_state.state == "on" else FILTER_OK
+ )
+ if (
+ not self.char_filter_change_indication
+ or self.char_filter_change_indication.value == current_change_indicator
+ ):
+ return
+
+ _LOGGER.debug(
+ "%s: Linked filter change indicator binary sensor %s changed to %d",
+ self.entity_id,
+ self.linked_filter_change_indicator_binary_sensor,
+ current_change_indicator,
+ )
+ self.char_filter_change_indication.set_value(current_change_indicator)
+
+ @callback
+ def _async_update_filter_life_level_event(
+ self, event: Event[EventStateChangedData]
+ ) -> None:
+ """Handle state change event listener callback."""
+ self._async_update_filter_life_level(event.data.get("new_state"))
+
+ @callback
+ def _async_update_filter_life_level(self, new_state: State | None) -> None:
+ """Handle linked filter life level sensor state change to update HomeKit value."""
+ if new_state is None or new_state.state in IGNORED_STATES:
+ return
+
+ if (
+ (current_life_level := convert_to_float(new_state.state)) is not None
+ and self.char_filter_life_level
+ and self.char_filter_life_level.value != current_life_level
+ ):
+ _LOGGER.debug(
+ "%s: Linked filter life level sensor %s changed to %d",
+ self.entity_id,
+ self.linked_filter_life_level_sensor,
+ current_life_level,
+ )
+ self.char_filter_life_level.set_value(current_life_level)
+
+ if self.linked_filter_change_indicator_binary_sensor or not current_life_level:
+ # Handled by its own event listener
+ return
+
+ current_change_indicator = (
+ FILTER_CHANGE_FILTER
+ if (current_life_level < THRESHOLD_FILTER_CHANGE_NEEDED)
+ else FILTER_OK
+ )
+ if (
+ not self.char_filter_change_indication
+ or self.char_filter_change_indication.value == current_change_indicator
+ ):
+ return
+
+ _LOGGER.debug(
+ "%s: Linked filter life level sensor %s changed to %d",
+ self.entity_id,
+ self.linked_filter_life_level_sensor,
+ current_change_indicator,
+ )
+ self.char_filter_change_indication.set_value(current_change_indicator)
+
+ @callback
+ def async_update_state(self, new_state: State) -> None:
+ """Update fan after state change."""
+ super().async_update_state(new_state)
+ # Handle State
+ state = new_state.state
+
+ if self.char_current_air_purifier_state is not None:
+ self.char_current_air_purifier_state.set_value(
+ CURRENT_STATE_PURIFYING_AIR
+ if state == STATE_ON
+ else CURRENT_STATE_INACTIVE
+ )
+
+ # Automatic mode is represented in HASS by a preset called Auto or auto
+ attributes = new_state.attributes
+ if ATTR_PRESET_MODE in attributes:
+ current_preset_mode = attributes.get(ATTR_PRESET_MODE)
+ self.char_target_air_purifier_state.set_value(
+ TARGET_STATE_AUTO
+ if current_preset_mode and current_preset_mode.lower() == "auto"
+ else TARGET_STATE_MANUAL
+ )
+
+ def set_chars(self, char_values: dict[str, Any]) -> None:
+ """Handle automatic mode after state change."""
+ super().set_chars(char_values)
+ if (
+ CHAR_TARGET_AIR_PURIFIER_STATE in char_values
+ and self.auto_preset is not None
+ ):
+ if char_values[CHAR_TARGET_AIR_PURIFIER_STATE] == TARGET_STATE_AUTO:
+ super().set_preset_mode(True, self.auto_preset)
+ elif self.char_speed is not None:
+ super().set_chars({CHAR_ROTATION_SPEED: self.char_speed.get_value()})
diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py
index 542d4500cbc..5c91dd0c3bb 100644
--- a/homeassistant/components/homekit/type_fans.py
+++ b/homeassistant/components/homekit/type_fans.py
@@ -4,6 +4,7 @@ import logging
from typing import Any
from pyhap.const import CATEGORY_FAN
+from pyhap.service import Service
from homeassistant.components.fan import (
ATTR_DIRECTION,
@@ -34,6 +35,7 @@ from homeassistant.core import State, callback
from .accessories import TYPES, HomeAccessory
from .const import (
CHAR_ACTIVE,
+ CHAR_CONFIGURED_NAME,
CHAR_NAME,
CHAR_ON,
CHAR_ROTATION_DIRECTION,
@@ -56,9 +58,9 @@ class Fan(HomeAccessory):
Currently supports: state, speed, oscillate, direction.
"""
- def __init__(self, *args: Any) -> None:
+ def __init__(self, *args: Any, category: int = CATEGORY_FAN) -> None:
"""Initialize a new Fan accessory object."""
- super().__init__(*args, category=CATEGORY_FAN)
+ super().__init__(*args, category=category)
self.chars: list[str] = []
state = self.hass.states.get(self.entity_id)
assert state
@@ -79,12 +81,8 @@ class Fan(HomeAccessory):
self.chars.append(CHAR_SWING_MODE)
if features & FanEntityFeature.SET_SPEED:
self.chars.append(CHAR_ROTATION_SPEED)
- if self.preset_modes and len(self.preset_modes) == 1:
- self.chars.append(CHAR_TARGET_FAN_STATE)
- serv_fan = self.add_preload_service(SERV_FANV2, self.chars)
- self.set_primary_service(serv_fan)
- self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0)
+ serv_fan = self.create_services()
self.char_direction = None
self.char_speed = None
@@ -107,15 +105,25 @@ class Fan(HomeAccessory):
properties={PROP_MIN_STEP: percentage_step},
)
- if self.preset_modes and len(self.preset_modes) == 1:
+ if (
+ self.preset_modes
+ and len(self.preset_modes) == 1
+ # NOTE: This would be missing for air purifiers
+ and CHAR_TARGET_FAN_STATE in self.chars
+ ):
self.char_target_fan_state = serv_fan.configure_char(
CHAR_TARGET_FAN_STATE,
value=0,
)
elif self.preset_modes:
for preset_mode in self.preset_modes:
+ if not self.should_add_preset_mode_switch(preset_mode):
+ continue
+
preset_serv = self.add_preload_service(
- SERV_SWITCH, CHAR_NAME, unique_id=preset_mode
+ SERV_SWITCH,
+ [CHAR_NAME, CHAR_CONFIGURED_NAME],
+ unique_id=preset_mode,
)
serv_fan.add_linked_service(preset_serv)
preset_serv.configure_char(
@@ -124,9 +132,12 @@ class Fan(HomeAccessory):
f"{self.display_name} {preset_mode}"
),
)
+ preset_serv.configure_char(
+ CHAR_CONFIGURED_NAME, value=cleanup_name_for_homekit(preset_mode)
+ )
def setter_callback(value: int, preset_mode: str = preset_mode) -> None:
- return self.set_preset_mode(value, preset_mode)
+ self.set_preset_mode(value, preset_mode)
self.preset_mode_chars[preset_mode] = preset_serv.configure_char(
CHAR_ON,
@@ -137,10 +148,27 @@ class Fan(HomeAccessory):
if CHAR_SWING_MODE in self.chars:
self.char_swing = serv_fan.configure_char(CHAR_SWING_MODE, value=0)
self.async_update_state(state)
- serv_fan.setter_callback = self._set_chars
+ serv_fan.setter_callback = self.set_chars
- def _set_chars(self, char_values: dict[str, Any]) -> None:
- _LOGGER.debug("Fan _set_chars: %s", char_values)
+ def create_services(self) -> Service:
+ """Create and configure the primary service for this accessory."""
+ if self.preset_modes and len(self.preset_modes) == 1:
+ self.chars.append(CHAR_TARGET_FAN_STATE)
+ serv_fan = self.add_preload_service(SERV_FANV2, self.chars)
+ self.set_primary_service(serv_fan)
+ self.char_active = serv_fan.configure_char(CHAR_ACTIVE, value=0)
+ return serv_fan
+
+ def should_add_preset_mode_switch(self, preset_mode: str) -> bool:
+ """Check if a preset mode switch should be added.
+
+ Always true for fans, but can be overridden by subclasses.
+ """
+ return True
+
+ def set_chars(self, char_values: dict[str, Any]) -> None:
+ """Set characteristic values."""
+ _LOGGER.debug("Fan set_chars: %s", char_values)
if CHAR_ACTIVE in char_values:
if char_values[CHAR_ACTIVE]:
# If the device supports set speed we
diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py
index adb16da5a2d..88d227d0ca5 100644
--- a/homeassistant/components/homekit/type_media_players.py
+++ b/homeassistant/components/homekit/type_media_players.py
@@ -41,6 +41,7 @@ from .const import (
ATTR_KEY_NAME,
CATEGORY_RECEIVER,
CHAR_ACTIVE,
+ CHAR_CONFIGURED_NAME,
CHAR_MUTE,
CHAR_NAME,
CHAR_ON,
@@ -100,41 +101,67 @@ class MediaPlayer(HomeAccessory):
)
if FEATURE_ON_OFF in feature_list:
- name = self.generate_service_name(FEATURE_ON_OFF)
serv_on_off = self.add_preload_service(
- SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_ON_OFF
+ SERV_SWITCH, [CHAR_CONFIGURED_NAME, CHAR_NAME], unique_id=FEATURE_ON_OFF
+ )
+ serv_on_off.configure_char(
+ CHAR_NAME, value=self.generate_service_name(FEATURE_ON_OFF)
+ )
+ serv_on_off.configure_char(
+ CHAR_CONFIGURED_NAME,
+ value=self.generated_configured_name(FEATURE_ON_OFF),
)
- serv_on_off.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char(
CHAR_ON, value=False, setter_callback=self.set_on_off
)
if FEATURE_PLAY_PAUSE in feature_list:
- name = self.generate_service_name(FEATURE_PLAY_PAUSE)
serv_play_pause = self.add_preload_service(
- SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_PAUSE
+ SERV_SWITCH,
+ [CHAR_CONFIGURED_NAME, CHAR_NAME],
+ unique_id=FEATURE_PLAY_PAUSE,
+ )
+ serv_play_pause.configure_char(
+ CHAR_NAME, value=self.generate_service_name(FEATURE_PLAY_PAUSE)
+ )
+ serv_play_pause.configure_char(
+ CHAR_CONFIGURED_NAME,
+ value=self.generated_configured_name(FEATURE_PLAY_PAUSE),
)
- serv_play_pause.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char(
CHAR_ON, value=False, setter_callback=self.set_play_pause
)
if FEATURE_PLAY_STOP in feature_list:
- name = self.generate_service_name(FEATURE_PLAY_STOP)
serv_play_stop = self.add_preload_service(
- SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_PLAY_STOP
+ SERV_SWITCH,
+ [CHAR_CONFIGURED_NAME, CHAR_NAME],
+ unique_id=FEATURE_PLAY_STOP,
+ )
+ serv_play_stop.configure_char(
+ CHAR_NAME, value=self.generate_service_name(FEATURE_PLAY_STOP)
+ )
+ serv_play_stop.configure_char(
+ CHAR_CONFIGURED_NAME,
+ value=self.generated_configured_name(FEATURE_PLAY_STOP),
)
- serv_play_stop.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char(
CHAR_ON, value=False, setter_callback=self.set_play_stop
)
if FEATURE_TOGGLE_MUTE in feature_list:
- name = self.generate_service_name(FEATURE_TOGGLE_MUTE)
serv_toggle_mute = self.add_preload_service(
- SERV_SWITCH, CHAR_NAME, unique_id=FEATURE_TOGGLE_MUTE
+ SERV_SWITCH,
+ [CHAR_CONFIGURED_NAME, CHAR_NAME],
+ unique_id=FEATURE_TOGGLE_MUTE,
+ )
+ serv_toggle_mute.configure_char(
+ CHAR_NAME, value=self.generate_service_name(FEATURE_TOGGLE_MUTE)
+ )
+ serv_toggle_mute.configure_char(
+ CHAR_CONFIGURED_NAME,
+ value=self.generated_configured_name(FEATURE_TOGGLE_MUTE),
)
- serv_toggle_mute.configure_char(CHAR_NAME, value=name)
self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char(
CHAR_ON, value=False, setter_callback=self.set_toggle_mute
)
@@ -146,6 +173,10 @@ class MediaPlayer(HomeAccessory):
f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}"
)
+ def generated_configured_name(self, mode: str) -> str:
+ """Generate name for individual service."""
+ return cleanup_name_for_homekit(MODE_FRIENDLY_NAME[mode])
+
def set_on_off(self, value: bool) -> None:
"""Move switch state to value if call came from HomeKit."""
_LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value)
diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py
index 0482a5956ac..18150c820c3 100644
--- a/homeassistant/components/homekit/type_switches.py
+++ b/homeassistant/components/homekit/type_switches.py
@@ -16,6 +16,12 @@ from pyhap.const import (
from homeassistant.components import button, input_button
from homeassistant.components.input_select import ATTR_OPTIONS, SERVICE_SELECT_OPTION
+from homeassistant.components.lawn_mower import (
+ DOMAIN as LAWN_MOWER_DOMAIN,
+ SERVICE_DOCK,
+ SERVICE_START_MOWING,
+ LawnMowerActivity,
+)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.vacuum import (
DOMAIN as VACUUM_DOMAIN,
@@ -43,6 +49,7 @@ from homeassistant.helpers.event import async_call_later
from .accessories import TYPES, HomeAccessory, HomeDriver
from .const import (
CHAR_ACTIVE,
+ CHAR_CONFIGURED_NAME,
CHAR_IN_USE,
CHAR_NAME,
CHAR_ON,
@@ -218,6 +225,29 @@ class Vacuum(Switch):
self.char_on.set_value(current_state)
+@TYPES.register("LawnMower")
+class LawnMower(Switch):
+ """Generate a Switch accessory."""
+
+ def set_state(self, value: bool) -> None:
+ """Move switch state to value if call came from HomeKit."""
+ _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value)
+ state = self.hass.states.get(self.entity_id)
+ assert state
+
+ service = SERVICE_START_MOWING if value else SERVICE_DOCK
+ self.async_call_service(
+ LAWN_MOWER_DOMAIN, service, {ATTR_ENTITY_ID: self.entity_id}
+ )
+
+ @callback
+ def async_update_state(self, new_state: State) -> None:
+ """Update switch state after state changed."""
+ current_state = new_state.state in (LawnMowerActivity.MOWING, STATE_ON)
+ _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state)
+ self.char_on.set_value(current_state)
+
+
class ValveBase(HomeAccessory):
"""Valve base class."""
@@ -331,11 +361,13 @@ class SelectSwitch(HomeAccessory):
options = state.attributes[ATTR_OPTIONS]
for option in options:
serv_option = self.add_preload_service(
- SERV_OUTLET, [CHAR_NAME, CHAR_IN_USE], unique_id=option
- )
- serv_option.configure_char(
- CHAR_NAME, value=cleanup_name_for_homekit(option)
+ SERV_OUTLET,
+ [CHAR_NAME, CHAR_CONFIGURED_NAME, CHAR_IN_USE],
+ unique_id=option,
)
+ name = cleanup_name_for_homekit(option)
+ serv_option.configure_char(CHAR_NAME, value=name)
+ serv_option.configure_char(CHAR_CONFIGURED_NAME, value=name)
serv_option.configure_char(CHAR_IN_USE, value=False)
self.select_chars[option] = serv_option.configure_char(
CHAR_ON,
diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py
index f32c4f55a0f..44db65d7b0b 100644
--- a/homeassistant/components/homekit/type_triggers.py
+++ b/homeassistant/components/homekit/type_triggers.py
@@ -15,6 +15,7 @@ from homeassistant.helpers.trigger import async_initialize_triggers
from .accessories import TYPES, HomeAccessory
from .aidmanager import get_system_unique_id
from .const import (
+ CHAR_CONFIGURED_NAME,
CHAR_NAME,
CHAR_PROGRAMMABLE_SWITCH_EVENT,
CHAR_SERVICE_LABEL_INDEX,
@@ -66,7 +67,7 @@ class DeviceTriggerAccessory(HomeAccessory):
trigger_name = cleanup_name_for_homekit(" ".join(trigger_name_parts))
serv_stateless_switch = self.add_preload_service(
SERV_STATELESS_PROGRAMMABLE_SWITCH,
- [CHAR_NAME, CHAR_SERVICE_LABEL_INDEX],
+ [CHAR_NAME, CHAR_CONFIGURED_NAME, CHAR_SERVICE_LABEL_INDEX],
unique_id=unique_id,
)
self.triggers.append(
@@ -77,6 +78,9 @@ class DeviceTriggerAccessory(HomeAccessory):
)
)
serv_stateless_switch.configure_char(CHAR_NAME, value=trigger_name)
+ serv_stateless_switch.configure_char(
+ CHAR_CONFIGURED_NAME, value=trigger_name
+ )
serv_stateless_switch.configure_char(
CHAR_SERVICE_LABEL_INDEX, value=idx + 1
)
diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py
index 1181ceaa953..bc98f00c15a 100644
--- a/homeassistant/components/homekit/util.py
+++ b/homeassistant/components/homekit/util.py
@@ -62,9 +62,13 @@ from .const import (
CONF_LINKED_BATTERY_CHARGING_SENSOR,
CONF_LINKED_BATTERY_SENSOR,
CONF_LINKED_DOORBELL_SENSOR,
+ CONF_LINKED_FILTER_CHANGE_INDICATION,
+ CONF_LINKED_FILTER_LIFE_LEVEL,
CONF_LINKED_HUMIDITY_SENSOR,
CONF_LINKED_MOTION_SENSOR,
CONF_LINKED_OBSTRUCTION_SENSOR,
+ CONF_LINKED_PM25_SENSOR,
+ CONF_LINKED_TEMPERATURE_SENSOR,
CONF_LOW_BATTERY_THRESHOLD,
CONF_MAX_FPS,
CONF_MAX_HEIGHT,
@@ -98,6 +102,8 @@ from .const import (
FEATURE_PLAY_STOP,
FEATURE_TOGGLE_MUTE,
MAX_NAME_LENGTH,
+ TYPE_AIR_PURIFIER,
+ TYPE_FAN,
TYPE_FAUCET,
TYPE_OUTLET,
TYPE_SHOWER,
@@ -187,6 +193,27 @@ HUMIDIFIER_SCHEMA = BASIC_INFO_SCHEMA.extend(
{vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN)}
)
+FAN_SCHEMA = BASIC_INFO_SCHEMA.extend(
+ {
+ vol.Optional(CONF_TYPE, default=TYPE_FAN): vol.All(
+ cv.string,
+ vol.In(
+ (
+ TYPE_FAN,
+ TYPE_AIR_PURIFIER,
+ )
+ ),
+ ),
+ vol.Optional(CONF_LINKED_HUMIDITY_SENSOR): cv.entity_domain(sensor.DOMAIN),
+ vol.Optional(CONF_LINKED_PM25_SENSOR): cv.entity_domain(sensor.DOMAIN),
+ vol.Optional(CONF_LINKED_TEMPERATURE_SENSOR): cv.entity_domain(sensor.DOMAIN),
+ vol.Optional(CONF_LINKED_FILTER_CHANGE_INDICATION): cv.entity_domain(
+ binary_sensor.DOMAIN
+ ),
+ vol.Optional(CONF_LINKED_FILTER_LIFE_LEVEL): cv.entity_domain(sensor.DOMAIN),
+ }
+)
+
COVER_SCHEMA = BASIC_INFO_SCHEMA.extend(
{
vol.Optional(CONF_LINKED_OBSTRUCTION_SENSOR): cv.entity_domain(
@@ -325,6 +352,9 @@ def validate_entity_config(values: dict) -> dict[str, dict]:
elif domain == "cover":
config = COVER_SCHEMA(config)
+ elif domain == "fan":
+ config = FAN_SCHEMA(config)
+
elif domain == "sensor":
config = SENSOR_SCHEMA(config)
diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py
index b17f122dfa5..a0342203e4a 100644
--- a/homeassistant/components/homekit_controller/alarm_control_panel.py
+++ b/homeassistant/components/homekit_controller/alarm_control_panel.py
@@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_BATTERY_LEVEL, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import KNOWN_DEVICES
from .connection import HKDevice
@@ -40,7 +40,7 @@ TARGET_STATE_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit alarm control panel."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py
index 26e19c8944a..1c80da3cc9c 100644
--- a/homeassistant/components/homekit_controller/binary_sensor.py
+++ b/homeassistant/components/homekit_controller/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import KNOWN_DEVICES
from .connection import HKDevice
@@ -156,7 +156,7 @@ REJECT_CHAR_BY_TYPE = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit lighting."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/button.py b/homeassistant/components/homekit_controller/button.py
index ac2133f61ca..730b3c8425d 100644
--- a/homeassistant/components/homekit_controller/button.py
+++ b/homeassistant/components/homekit_controller/button.py
@@ -20,7 +20,7 @@ from homeassistant.components.button import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNOWN_DEVICES
@@ -66,7 +66,7 @@ BUTTON_ENTITIES: dict[str, HomeKitButtonEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit buttons."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/camera.py b/homeassistant/components/homekit_controller/camera.py
index 4332032867a..36bf30e5bab 100644
--- a/homeassistant/components/homekit_controller/camera.py
+++ b/homeassistant/components/homekit_controller/camera.py
@@ -9,7 +9,7 @@ from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import KNOWN_DEVICES
from .connection import HKDevice
@@ -39,7 +39,7 @@ class HomeKitCamera(AccessoryEntity, Camera):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit sensors."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py
index cbf4ad61c2f..4c8bf8517be 100644
--- a/homeassistant/components/homekit_controller/climate.py
+++ b/homeassistant/components/homekit_controller/climate.py
@@ -41,7 +41,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -111,7 +111,7 @@ HASS_FAN_MODE_TO_HOMEKIT_ROTATION = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit climate."""
hkid: str = config_entry.data["AccessoryPairingID"]
@@ -659,13 +659,7 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity):
# e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit.
# Can be 0 - 2 (Off, Heat, Cool)
- # If the HVAC is switched off, it must be idle
- # This works around a bug in some devices (like Eve radiator valves) that
- # return they are heating when they are not.
target = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET)
- if target == HeatingCoolingTargetValues.OFF:
- return HVACAction.IDLE
-
value = self.service.value(CharacteristicsTypes.HEATING_COOLING_CURRENT)
current_hass_value = CURRENT_MODE_HOMEKIT_TO_HASS.get(value)
@@ -679,6 +673,12 @@ class HomeKitClimateEntity(HomeKitBaseClimateEntity):
):
return HVACAction.FAN
+ # If the HVAC is switched off, it must be idle
+ # This works around a bug in some devices (like Eve radiator valves) that
+ # return they are heating when they are not.
+ if target == HeatingCoolingTargetValues.OFF:
+ return HVACAction.IDLE
+
return current_hass_value
@property
diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py
index 211aec2c2d5..931bd40d64c 100644
--- a/homeassistant/components/homekit_controller/connection.py
+++ b/homeassistant/components/homekit_controller/connection.py
@@ -9,10 +9,11 @@ from functools import partial
import logging
from operator import attrgetter
from types import MappingProxyType
-from typing import Any
+from typing import Any, cast
from aiohomekit import Controller
from aiohomekit.controller import TransportType
+from aiohomekit.controller.ble.discovery import BleDiscovery
from aiohomekit.exceptions import (
AccessoryDisconnectedError,
AccessoryNotFoundError,
@@ -154,7 +155,6 @@ class HKDevice:
self._pending_subscribes: set[tuple[int, int]] = set()
self._subscribe_timer: CALLBACK_TYPE | None = None
self._load_platforms_lock = asyncio.Lock()
- self._full_update_requested: bool = False
@property
def entity_map(self) -> Accessories:
@@ -373,6 +373,16 @@ class HKDevice:
if not self.unreliable_serial_numbers:
identifiers.add((IDENTIFIER_SERIAL_NUMBER, accessory.serial_number))
+ connections: set[tuple[str, str]] = set()
+ if self.pairing.transport == Transport.BLE and (
+ discovery := self.pairing.controller.discoveries.get(
+ normalize_hkid(self.unique_id)
+ )
+ ):
+ connections = {
+ (dr.CONNECTION_BLUETOOTH, cast(BleDiscovery, discovery).device.address),
+ }
+
device_info = DeviceInfo(
identifiers={
(
@@ -380,6 +390,7 @@ class HKDevice:
f"{self.unique_id}:aid:{accessory.aid}",
)
},
+ connections=connections,
name=accessory.name,
manufacturer=accessory.manufacturer,
model=accessory.model,
@@ -841,48 +852,11 @@ class HKDevice:
async def async_request_update(self, now: datetime | None = None) -> None:
"""Request an debounced update from the accessory."""
- self._full_update_requested = True
await self._debounced_update.async_call()
async def async_update(self, now: datetime | None = None) -> None:
"""Poll state of all entities attached to this bridge/accessory."""
to_poll = self.pollable_characteristics
- accessories = self.entity_map.accessories
-
- if (
- not self._full_update_requested
- and len(accessories) == 1
- and self.available
- and not (to_poll - self.watchable_characteristics)
- and self.pairing.is_available
- and await self.pairing.controller.async_reachable(
- self.unique_id, timeout=5.0
- )
- ):
- # If its a single accessory and all chars are watchable,
- # only poll the firmware version to keep the connection alive
- # https://github.com/home-assistant/core/issues/123412
- #
- # Firmware revision is used here since iOS does this to keep camera
- # connections alive, and the goal is to not regress
- # https://github.com/home-assistant/core/issues/116143
- # by polling characteristics that are not normally polled frequently
- # and may not be tested by the device vendor.
- #
- _LOGGER.debug(
- "Accessory is reachable, limiting poll to firmware version: %s",
- self.unique_id,
- )
- first_accessory = accessories[0]
- accessory_info = first_accessory.services.first(
- service_type=ServicesTypes.ACCESSORY_INFORMATION
- )
- assert accessory_info is not None
- firmware_iid = accessory_info[CharacteristicsTypes.FIRMWARE_REVISION].iid
- to_poll = {(first_accessory.aid, firmware_iid)}
-
- self._full_update_requested = False
-
if not to_poll:
self.async_update_available_state()
_LOGGER.debug(
diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py
index 4fff32002e2..5ea990f55e6 100644
--- a/homeassistant/components/homekit_controller/cover.py
+++ b/homeassistant/components/homekit_controller/cover.py
@@ -19,7 +19,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import KNOWN_DEVICES
from .connection import HKDevice
@@ -51,7 +51,7 @@ CURRENT_WINDOW_STATE_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit covers."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/event.py b/homeassistant/components/homekit_controller/event.py
index 890c12c9bab..b90d561d60d 100644
--- a/homeassistant/components/homekit_controller/event.py
+++ b/homeassistant/components/homekit_controller/event.py
@@ -14,7 +14,7 @@ from homeassistant.components.event import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import KNOWN_DEVICES
from .connection import HKDevice
@@ -86,7 +86,7 @@ class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit event."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py
index b7f1842392b..4138277d81c 100644
--- a/homeassistant/components/homekit_controller/fan.py
+++ b/homeassistant/components/homekit_controller/fan.py
@@ -5,6 +5,10 @@ from __future__ import annotations
from typing import Any
from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics.const import (
+ TargetAirPurifierStateValues,
+ TargetFanStateValues,
+)
from aiohomekit.model.services import Service, ServicesTypes
from propcache.api import cached_property
@@ -17,7 +21,7 @@ from homeassistant.components.fan import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -35,6 +39,8 @@ DIRECTION_TO_HK = {
}
HK_DIRECTION_TO_HA = {v: k for (k, v) in DIRECTION_TO_HK.items()}
+PRESET_AUTO = "auto"
+
class BaseHomeKitFan(HomeKitEntity, FanEntity):
"""Representation of a Homekit fan."""
@@ -42,6 +48,9 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
# This must be set in subclasses to the name of a boolean characteristic
# that controls whether the fan is on or off.
on_characteristic: str
+ preset_char = CharacteristicsTypes.FAN_STATE_TARGET
+ preset_manual_value: int = TargetFanStateValues.MANUAL
+ preset_automatic_value: int = TargetFanStateValues.AUTOMATIC
@callback
def _async_reconfigure(self) -> None:
@@ -51,6 +60,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
"_speed_range",
"_min_speed",
"_max_speed",
+ "preset_modes",
"speed_count",
"supported_features",
)
@@ -59,12 +69,15 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
def get_characteristic_types(self) -> list[str]:
"""Define the homekit characteristics the entity cares about."""
- return [
+ types = [
CharacteristicsTypes.SWING_MODE,
CharacteristicsTypes.ROTATION_DIRECTION,
CharacteristicsTypes.ROTATION_SPEED,
self.on_characteristic,
]
+ if self.service.has(self.preset_char):
+ types.append(self.preset_char)
+ return types
@property
def is_on(self) -> bool:
@@ -124,6 +137,9 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
if self.service.has(CharacteristicsTypes.SWING_MODE):
features |= FanEntityFeature.OSCILLATE
+ if self.service.has(self.preset_char):
+ features |= FanEntityFeature.PRESET_MODE
+
return features
@cached_property
@@ -134,6 +150,32 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
/ max(1, self.service[CharacteristicsTypes.ROTATION_SPEED].minStep or 0)
)
+ @cached_property
+ def preset_modes(self) -> list[str]:
+ """Return the preset modes."""
+ return [PRESET_AUTO] if self.service.has(self.preset_char) else []
+
+ @property
+ def preset_mode(self) -> str | None:
+ """Return the current preset mode."""
+ if (
+ self.service.has(self.preset_char)
+ and self.service.value(self.preset_char) == self.preset_automatic_value
+ ):
+ return PRESET_AUTO
+ return None
+
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
+ """Set the preset mode of the fan."""
+ if self.service.has(self.preset_char):
+ await self.async_put_characteristics(
+ {
+ self.preset_char: self.preset_automatic_value
+ if preset_mode == PRESET_AUTO
+ else self.preset_manual_value
+ }
+ )
+
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
await self.async_put_characteristics(
@@ -146,13 +188,16 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
await self.async_turn_off()
return
- await self.async_put_characteristics(
- {
- CharacteristicsTypes.ROTATION_SPEED: round(
- percentage_to_ranged_value(self._speed_range, percentage)
- )
- }
- )
+ characteristics = {
+ CharacteristicsTypes.ROTATION_SPEED: round(
+ percentage_to_ranged_value(self._speed_range, percentage)
+ )
+ }
+
+ if FanEntityFeature.PRESET_MODE in self.supported_features:
+ characteristics[self.preset_char] = self.preset_manual_value
+
+ await self.async_put_characteristics(characteristics)
async def async_oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
@@ -172,13 +217,17 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity):
if not self.is_on:
characteristics[self.on_characteristic] = True
- if (
+ if preset_mode == PRESET_AUTO:
+ characteristics[self.preset_char] = self.preset_automatic_value
+ elif (
percentage is not None
and FanEntityFeature.SET_SPEED in self.supported_features
):
characteristics[CharacteristicsTypes.ROTATION_SPEED] = round(
percentage_to_ranged_value(self._speed_range, percentage)
)
+ if FanEntityFeature.PRESET_MODE in self.supported_features:
+ characteristics[self.preset_char] = self.preset_manual_value
if characteristics:
await self.async_put_characteristics(characteristics)
@@ -200,17 +249,25 @@ class HomeKitFanV2(BaseHomeKitFan):
on_characteristic = CharacteristicsTypes.ACTIVE
+class HomeKitAirPurifer(HomeKitFanV2):
+ """Implement air purifier support for public.hap.service.airpurifier."""
+
+ preset_char = CharacteristicsTypes.AIR_PURIFIER_STATE_TARGET
+ preset_manual_value = TargetAirPurifierStateValues.MANUAL
+ preset_automatic_value = TargetAirPurifierStateValues.AUTOMATIC
+
+
ENTITY_TYPES = {
ServicesTypes.FAN: HomeKitFanV1,
ServicesTypes.FAN_V2: HomeKitFanV2,
- ServicesTypes.AIR_PURIFIER: HomeKitFanV2,
+ ServicesTypes.AIR_PURIFIER: HomeKitAirPurifer,
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit fans."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py
index b2b0e0b1026..7906d5ec52b 100644
--- a/homeassistant/components/homekit_controller/humidifier.py
+++ b/homeassistant/components/homekit_controller/humidifier.py
@@ -20,7 +20,7 @@ from homeassistant.components.humidifier import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNOWN_DEVICES
@@ -165,7 +165,7 @@ class HomeKitDehumidifier(HomeKitBaseHumidifier):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit humidifer."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py
index 04c75731731..5409df7c1a8 100644
--- a/homeassistant/components/homekit_controller/light.py
+++ b/homeassistant/components/homekit_controller/light.py
@@ -20,7 +20,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from . import KNOWN_DEVICES
@@ -31,7 +31,7 @@ from .entity import HomeKitEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit lightbulb."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py
index 98974c4a514..06b8382c8af 100644
--- a/homeassistant/components/homekit_controller/lock.py
+++ b/homeassistant/components/homekit_controller/lock.py
@@ -11,7 +11,7 @@ from homeassistant.components.lock import LockEntity, LockState
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import KNOWN_DEVICES
from .connection import HKDevice
@@ -32,7 +32,7 @@ REVERSED_TARGET_STATE_MAP = {v: k for k, v in TARGET_STATE_MAP.items()}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit lock."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json
index b7c82b9fd51..6562a3edcc9 100644
--- a/homeassistant/components/homekit_controller/manifest.json
+++ b/homeassistant/components/homekit_controller/manifest.json
@@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
- "requirements": ["aiohomekit==3.2.7"],
+ "requirements": ["aiohomekit==3.2.13"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}
diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py
index 4232d1b7649..e3b4a760680 100644
--- a/homeassistant/components/homekit_controller/media_player.py
+++ b/homeassistant/components/homekit_controller/media_player.py
@@ -22,7 +22,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import KNOWN_DEVICES
from .connection import HKDevice
@@ -41,7 +41,7 @@ HK_TO_HA_STATE = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit television."""
hkid: str = config_entry.data["AccessoryPairingID"]
@@ -83,7 +83,7 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity):
@property
def supported_features(self) -> MediaPlayerEntityFeature:
"""Flag media player features that are supported."""
- features = MediaPlayerEntityFeature(0)
+ features = MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.TURN_ON
if self.service.has(CharacteristicsTypes.ACTIVE_IDENTIFIER):
features |= MediaPlayerEntityFeature.SELECT_SOURCE
@@ -177,6 +177,14 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity):
return MediaPlayerState.ON
+ async def async_turn_on(self) -> None:
+ """Turn the tv on."""
+ await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: 1})
+
+ async def async_turn_off(self) -> None:
+ """Turn the tv off."""
+ await self.async_put_characteristics({CharacteristicsTypes.ACTIVE: 0})
+
async def async_media_play(self) -> None:
"""Send play command."""
if self.state == MediaPlayerState.PLAYING:
diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py
index 340d31c91ae..96d6707d8eb 100644
--- a/homeassistant/components/homekit_controller/number.py
+++ b/homeassistant/components/homekit_controller/number.py
@@ -18,7 +18,7 @@ from homeassistant.components.number import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNOWN_DEVICES
@@ -68,7 +68,7 @@ NUMBER_ENTITIES: dict[str, NumberEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit numbers."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/select.py b/homeassistant/components/homekit_controller/select.py
index f672f293122..f174743b12f 100644
--- a/homeassistant/components/homekit_controller/select.py
+++ b/homeassistant/components/homekit_controller/select.py
@@ -15,7 +15,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNOWN_DEVICES
@@ -148,7 +148,7 @@ class EcobeeModeSelect(BaseHomeKitSelect):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit select entities."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py
index 059be5bad99..c97b45152e0 100644
--- a/homeassistant/components/homekit_controller/sensor.py
+++ b/homeassistant/components/homekit_controller/sensor.py
@@ -43,7 +43,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNOWN_DEVICES
@@ -640,7 +640,7 @@ class RSSISensor(HomeKitEntity, SensorEntity):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit sensors."""
hkid = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json
index d1205645fd3..e857e1a7f01 100644
--- a/homeassistant/components/homekit_controller/strings.json
+++ b/homeassistant/components/homekit_controller/strings.json
@@ -14,7 +14,7 @@
"title": "Pair with a device via HomeKit Accessory Protocol",
"description": "HomeKit Device communicates with {name} ({category}) over the local area network using a secure encrypted connection without a separate HomeKit Controller or iCloud. Enter your eight digit HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory. This code is usually found on the device itself or in the packaging, often close to a HomeKit bar code, next to the image of a small house.",
"data": {
- "pairing_code": "Pairing Code",
+ "pairing_code": "Pairing code",
"allow_insecure_setup_codes": "Allow pairing with insecure setup codes."
}
},
@@ -112,7 +112,7 @@
"air_purifier_state_target": {
"state": {
"automatic": "Automatic",
- "manual": "Manual"
+ "manual": "[%key:common::state::manual%]"
}
}
},
@@ -141,7 +141,7 @@
"air_purifier_state_current": {
"state": {
"inactive": "Inactive",
- "idle": "Idle",
+ "idle": "[%key:common::state::idle%]",
"purifying": "Purifying"
}
}
diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py
index 5abed2a5c79..c24a4edf545 100644
--- a/homeassistant/components/homekit_controller/switch.py
+++ b/homeassistant/components/homekit_controller/switch.py
@@ -17,7 +17,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNOWN_DEVICES
@@ -224,7 +224,7 @@ ENTITY_TYPES: dict[str, type[HomeKitSwitch | HomeKitFaucet | HomeKitValve]] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homekit switches."""
hkid: str = config_entry.data["AccessoryPairingID"]
diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py
index 5a5b2a3b8c8..44e95e98f38 100644
--- a/homeassistant/components/homematic/entity.py
+++ b/homeassistant/components/homematic/entity.py
@@ -62,7 +62,7 @@ class HMDevice(Entity):
if self._state:
self._state = self._state.upper()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Load data init callbacks."""
self._subscribe_homematic_events()
@@ -77,7 +77,7 @@ class HMDevice(Entity):
return self._name
@property
- def available(self):
+ def available(self) -> bool:
"""Return true if device is available."""
return self._available
diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py
index b33a725db0f..bdd446d7091 100644
--- a/homeassistant/components/homematic/sensor.py
+++ b/homeassistant/components/homematic/sensor.py
@@ -177,6 +177,8 @@ SENSOR_DESCRIPTIONS: dict[str, SensorEntityDescription] = {
"WIND_DIRECTION": SensorEntityDescription(
key="WIND_DIRECTION",
native_unit_of_measurement=DEGREE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
"WIND_DIRECTION_RANGE": SensorEntityDescription(
key="WIND_DIRECTION_RANGE",
diff --git a/homeassistant/components/homematic/strings.json b/homeassistant/components/homematic/strings.json
index d962a218a4f..78159189db8 100644
--- a/homeassistant/components/homematic/strings.json
+++ b/homeassistant/components/homematic/strings.json
@@ -2,7 +2,7 @@
"services": {
"virtualkey": {
"name": "Virtual key",
- "description": "Presses a virtual key from CCU/Homegear or simulate keypress.",
+ "description": "Simulates a keypress (or other valid action) on CCU/Homegear with virtual or device keys.",
"fields": {
"address": {
"name": "Address",
@@ -24,7 +24,7 @@
},
"set_variable_value": {
"name": "Set variable value",
- "description": "Sets the name of a node.",
+ "description": "Sets the value of a system variable.",
"fields": {
"entity_id": {
"name": "Entity",
diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py
index 4241316c2a4..af57d8b0cd0 100644
--- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py
+++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py
@@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .hap import AsyncHome, HomematicipHAP
@@ -27,7 +27,7 @@ CONST_ALARM_CONTROL_PANEL_NAME = "HmIP Alarm Control Panel"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP alrm control panel from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id]
@@ -82,15 +82,15 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity):
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
- await self._home.set_security_zones_activation(False, False)
+ await self._home.set_security_zones_activation_async(False, False)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
- await self._home.set_security_zones_activation(False, True)
+ await self._home.set_security_zones_activation_async(False, True)
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
- await self._home.set_security_zones_activation(True, True)
+ await self._home.set_security_zones_activation_async(True, True)
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py
index 38590e4505b..e135e95634d 100644
--- a/homeassistant/components/homematicip_cloud/binary_sensor.py
+++ b/homeassistant/components/homematicip_cloud/binary_sensor.py
@@ -4,31 +4,31 @@ from __future__ import annotations
from typing import Any
-from homematicip.aio.device import (
- AsyncAccelerationSensor,
- AsyncContactInterface,
- AsyncDevice,
- AsyncFullFlushContactInterface,
- AsyncFullFlushContactInterface6,
- AsyncMotionDetectorIndoor,
- AsyncMotionDetectorOutdoor,
- AsyncMotionDetectorPushButton,
- AsyncPluggableMainsFailureSurveillance,
- AsyncPresenceDetectorIndoor,
- AsyncRainSensor,
- AsyncRotaryHandleSensor,
- AsyncShutterContact,
- AsyncShutterContactMagnetic,
- AsyncSmokeDetector,
- AsyncTiltVibrationSensor,
- AsyncWaterSensor,
- AsyncWeatherSensor,
- AsyncWeatherSensorPlus,
- AsyncWeatherSensorPro,
- AsyncWiredInput32,
-)
-from homematicip.aio.group import AsyncSecurityGroup, AsyncSecurityZoneGroup
from homematicip.base.enums import SmokeDetectorAlarmType, WindowState
+from homematicip.device import (
+ AccelerationSensor,
+ ContactInterface,
+ Device,
+ FullFlushContactInterface,
+ FullFlushContactInterface6,
+ MotionDetectorIndoor,
+ MotionDetectorOutdoor,
+ MotionDetectorPushButton,
+ PluggableMainsFailureSurveillance,
+ PresenceDetectorIndoor,
+ RainSensor,
+ RotaryHandleSensor,
+ ShutterContact,
+ ShutterContactMagnetic,
+ SmokeDetector,
+ TiltVibrationSensor,
+ WaterSensor,
+ WeatherSensor,
+ WeatherSensorPlus,
+ WeatherSensorPro,
+ WiredInput32,
+)
+from homematicip.group import SecurityGroup, SecurityZoneGroup
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -37,7 +37,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import HomematicipGenericEntity
@@ -76,72 +76,66 @@ SAM_DEVICE_ATTRIBUTES = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP Cloud binary sensor from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id]
entities: list[HomematicipGenericEntity] = [HomematicipCloudConnectionSensor(hap)]
for device in hap.home.devices:
- if isinstance(device, AsyncAccelerationSensor):
+ if isinstance(device, AccelerationSensor):
entities.append(HomematicipAccelerationSensor(hap, device))
- if isinstance(device, AsyncTiltVibrationSensor):
+ if isinstance(device, TiltVibrationSensor):
entities.append(HomematicipTiltVibrationSensor(hap, device))
- if isinstance(device, AsyncWiredInput32):
+ if isinstance(device, WiredInput32):
entities.extend(
HomematicipMultiContactInterface(hap, device, channel=channel)
for channel in range(1, 33)
)
- elif isinstance(device, AsyncFullFlushContactInterface6):
+ elif isinstance(device, FullFlushContactInterface6):
entities.extend(
HomematicipMultiContactInterface(hap, device, channel=channel)
for channel in range(1, 7)
)
- elif isinstance(
- device, (AsyncContactInterface, AsyncFullFlushContactInterface)
- ):
+ elif isinstance(device, (ContactInterface, FullFlushContactInterface)):
entities.append(HomematicipContactInterface(hap, device))
if isinstance(
device,
- (AsyncShutterContact, AsyncShutterContactMagnetic),
+ (ShutterContact, ShutterContactMagnetic),
):
entities.append(HomematicipShutterContact(hap, device))
- if isinstance(device, AsyncRotaryHandleSensor):
+ if isinstance(device, RotaryHandleSensor):
entities.append(HomematicipShutterContact(hap, device, True))
if isinstance(
device,
(
- AsyncMotionDetectorIndoor,
- AsyncMotionDetectorOutdoor,
- AsyncMotionDetectorPushButton,
+ MotionDetectorIndoor,
+ MotionDetectorOutdoor,
+ MotionDetectorPushButton,
),
):
entities.append(HomematicipMotionDetector(hap, device))
- if isinstance(device, AsyncPluggableMainsFailureSurveillance):
+ if isinstance(device, PluggableMainsFailureSurveillance):
entities.append(
HomematicipPluggableMainsFailureSurveillanceSensor(hap, device)
)
- if isinstance(device, AsyncPresenceDetectorIndoor):
+ if isinstance(device, PresenceDetectorIndoor):
entities.append(HomematicipPresenceDetector(hap, device))
- if isinstance(device, AsyncSmokeDetector):
+ if isinstance(device, SmokeDetector):
entities.append(HomematicipSmokeDetector(hap, device))
- if isinstance(device, AsyncWaterSensor):
+ if isinstance(device, WaterSensor):
entities.append(HomematicipWaterDetector(hap, device))
- if isinstance(
- device, (AsyncRainSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro)
- ):
+ if isinstance(device, (RainSensor, WeatherSensorPlus, WeatherSensorPro)):
entities.append(HomematicipRainSensor(hap, device))
- if isinstance(
- device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro)
- ):
+ if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)):
entities.append(HomematicipStormSensor(hap, device))
entities.append(HomematicipSunshineSensor(hap, device))
- if isinstance(device, AsyncDevice) and device.lowBat is not None:
+ if isinstance(device, Device) and device.lowBat is not None:
entities.append(HomematicipBatterySensor(hap, device))
for group in hap.home.groups:
- if isinstance(group, AsyncSecurityGroup):
+ if isinstance(group, SecurityGroup):
entities.append(HomematicipSecuritySensorGroup(hap, device=group))
- elif isinstance(group, AsyncSecurityZoneGroup):
+ elif isinstance(group, SecurityZoneGroup):
entities.append(HomematicipSecurityZoneSensorGroup(hap, device=group))
async_add_entities(entities)
diff --git a/homeassistant/components/homematicip_cloud/button.py b/homeassistant/components/homematicip_cloud/button.py
index 244be47d7f6..0d70ad53d54 100644
--- a/homeassistant/components/homematicip_cloud/button.py
+++ b/homeassistant/components/homematicip_cloud/button.py
@@ -2,12 +2,12 @@
from __future__ import annotations
-from homematicip.aio.device import AsyncWallMountedGarageDoorController
+from homematicip.device import WallMountedGarageDoorController
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import HomematicipGenericEntity
@@ -17,7 +17,7 @@ from .hap import HomematicipHAP
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP button from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id]
@@ -25,7 +25,7 @@ async def async_setup_entry(
async_add_entities(
HomematicipGarageDoorControllerButton(hap, device)
for device in hap.home.devices
- if isinstance(device, AsyncWallMountedGarageDoorController)
+ if isinstance(device, WallMountedGarageDoorController)
)
@@ -39,4 +39,4 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti
async def async_press(self) -> None:
"""Handle the button press."""
- await self._device.send_start_impulse()
+ await self._device.send_start_impulse_async()
diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py
index e7132fac83c..0952f17d3ec 100644
--- a/homeassistant/components/homematicip_cloud/climate.py
+++ b/homeassistant/components/homematicip_cloud/climate.py
@@ -4,16 +4,15 @@ from __future__ import annotations
from typing import Any
-from homematicip.aio.device import (
- AsyncHeatingThermostat,
- AsyncHeatingThermostatCompact,
- AsyncHeatingThermostatEvo,
-)
-from homematicip.aio.group import AsyncHeatingGroup
from homematicip.base.enums import AbsenceType
-from homematicip.device import Switch
+from homematicip.device import (
+ HeatingThermostat,
+ HeatingThermostatCompact,
+ HeatingThermostatEvo,
+ Switch,
+)
from homematicip.functionalHomes import IndoorClimateHome
-from homematicip.group import HeatingCoolingProfile
+from homematicip.group import HeatingCoolingProfile, HeatingGroup
from homeassistant.components.climate import (
PRESET_AWAY,
@@ -29,7 +28,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import HomematicipGenericEntity
@@ -57,7 +56,7 @@ HMIP_ECO_CM = "ECO"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP climate from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id]
@@ -65,7 +64,7 @@ async def async_setup_entry(
async_add_entities(
HomematicipHeatingGroup(hap, device)
for device in hap.home.groups
- if isinstance(device, AsyncHeatingGroup)
+ if isinstance(device, HeatingGroup)
)
@@ -82,7 +81,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- def __init__(self, hap: HomematicipHAP, device: AsyncHeatingGroup) -> None:
+ def __init__(self, hap: HomematicipHAP, device: HeatingGroup) -> None:
"""Initialize heating group."""
device.modelType = "HmIP-Heating-Group"
super().__init__(hap, device)
@@ -214,7 +213,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
return
if self.min_temp <= temperature <= self.max_temp:
- await self._device.set_point_temperature(temperature)
+ await self._device.set_point_temperature_async(temperature)
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
@@ -222,23 +221,23 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
return
if hvac_mode == HVACMode.AUTO:
- await self._device.set_control_mode(HMIP_AUTOMATIC_CM)
+ await self._device.set_control_mode_async(HMIP_AUTOMATIC_CM)
else:
- await self._device.set_control_mode(HMIP_MANUAL_CM)
+ await self._device.set_control_mode_async(HMIP_MANUAL_CM)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
if self._device.boostMode and preset_mode != PRESET_BOOST:
- await self._device.set_boost(False)
+ await self._device.set_boost_async(False)
if preset_mode == PRESET_BOOST:
- await self._device.set_boost()
+ await self._device.set_boost_async()
if preset_mode == PRESET_ECO:
- await self._device.set_control_mode(HMIP_ECO_CM)
+ await self._device.set_control_mode_async(HMIP_ECO_CM)
if preset_mode in self._device_profile_names:
profile_idx = self._get_profile_idx_by_name(preset_mode)
if self._device.controlMode != HMIP_AUTOMATIC_CM:
await self.async_set_hvac_mode(HVACMode.AUTO)
- await self._device.set_active_profile(profile_idx)
+ await self._device.set_active_profile_async(profile_idx)
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -332,20 +331,15 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
@property
def _first_radiator_thermostat(
self,
- ) -> (
- AsyncHeatingThermostat
- | AsyncHeatingThermostatCompact
- | AsyncHeatingThermostatEvo
- | None
- ):
+ ) -> HeatingThermostat | HeatingThermostatCompact | HeatingThermostatEvo | None:
"""Return the first radiator thermostat from the hmip heating group."""
for device in self._device.devices:
if isinstance(
device,
(
- AsyncHeatingThermostat,
- AsyncHeatingThermostatCompact,
- AsyncHeatingThermostatEvo,
+ HeatingThermostat,
+ HeatingThermostatCompact,
+ HeatingThermostatEvo,
),
):
return device
diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py
index 1db536afd4f..317024658e1 100644
--- a/homeassistant/components/homematicip_cloud/cover.py
+++ b/homeassistant/components/homematicip_cloud/cover.py
@@ -4,16 +4,16 @@ from __future__ import annotations
from typing import Any
-from homematicip.aio.device import (
- AsyncBlindModule,
- AsyncDinRailBlind4,
- AsyncFullFlushBlind,
- AsyncFullFlushShutter,
- AsyncGarageDoorModuleTormatic,
- AsyncHoermannDrivesModule,
-)
-from homematicip.aio.group import AsyncExtendedLinkedShutterGroup
from homematicip.base.enums import DoorCommand, DoorState
+from homematicip.device import (
+ BlindModule,
+ DinRailBlind4,
+ FullFlushBlind,
+ FullFlushShutter,
+ GarageDoorModuleTormatic,
+ HoermannDrivesModule,
+)
+from homematicip.group import ExtendedLinkedShutterGroup
from homeassistant.components.cover import (
ATTR_POSITION,
@@ -23,7 +23,7 @@ from homeassistant.components.cover import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import HomematicipGenericEntity
@@ -38,30 +38,28 @@ HMIP_SLATS_CLOSED = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP cover from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id]
entities: list[HomematicipGenericEntity] = [
HomematicipCoverShutterGroup(hap, group)
for group in hap.home.groups
- if isinstance(group, AsyncExtendedLinkedShutterGroup)
+ if isinstance(group, ExtendedLinkedShutterGroup)
]
for device in hap.home.devices:
- if isinstance(device, AsyncBlindModule):
+ if isinstance(device, BlindModule):
entities.append(HomematicipBlindModule(hap, device))
- elif isinstance(device, AsyncDinRailBlind4):
+ elif isinstance(device, DinRailBlind4):
entities.extend(
HomematicipMultiCoverSlats(hap, device, channel=channel)
for channel in range(1, 5)
)
- elif isinstance(device, AsyncFullFlushBlind):
+ elif isinstance(device, FullFlushBlind):
entities.append(HomematicipCoverSlats(hap, device))
- elif isinstance(device, AsyncFullFlushShutter):
+ elif isinstance(device, FullFlushShutter):
entities.append(HomematicipCoverShutter(hap, device))
- elif isinstance(
- device, (AsyncHoermannDrivesModule, AsyncGarageDoorModuleTormatic)
- ):
+ elif isinstance(device, (HoermannDrivesModule, GarageDoorModuleTormatic)):
entities.append(HomematicipGarageDoorModule(hap, device))
async_add_entities(entities)
@@ -91,14 +89,14 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity):
position = kwargs[ATTR_POSITION]
# HmIP cover is closed:1 -> open:0
level = 1 - position / 100.0
- await self._device.set_primary_shading_level(primaryShadingLevel=level)
+ await self._device.set_primary_shading_level_async(primaryShadingLevel=level)
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific tilt position."""
position = kwargs[ATTR_TILT_POSITION]
# HmIP slats is closed:1 -> open:0
level = 1 - position / 100.0
- await self._device.set_secondary_shading_level(
+ await self._device.set_secondary_shading_level_async(
primaryShadingLevel=self._device.primaryShadingLevel,
secondaryShadingLevel=level,
)
@@ -112,37 +110,37 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
- await self._device.set_primary_shading_level(
+ await self._device.set_primary_shading_level_async(
primaryShadingLevel=HMIP_COVER_OPEN
)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
- await self._device.set_primary_shading_level(
+ await self._device.set_primary_shading_level_async(
primaryShadingLevel=HMIP_COVER_CLOSED
)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the device if in motion."""
- await self._device.stop()
+ await self._device.stop_async()
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the slats."""
- await self._device.set_secondary_shading_level(
+ await self._device.set_secondary_shading_level_async(
primaryShadingLevel=self._device.primaryShadingLevel,
secondaryShadingLevel=HMIP_SLATS_OPEN,
)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the slats."""
- await self._device.set_secondary_shading_level(
+ await self._device.set_secondary_shading_level_async(
primaryShadingLevel=self._device.primaryShadingLevel,
secondaryShadingLevel=HMIP_SLATS_CLOSED,
)
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the device if in motion."""
- await self._device.stop()
+ await self._device.stop_async()
class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity):
@@ -176,7 +174,7 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity):
position = kwargs[ATTR_POSITION]
# HmIP cover is closed:1 -> open:0
level = 1 - position / 100.0
- await self._device.set_shutter_level(level, self._channel)
+ await self._device.set_shutter_level_async(level, self._channel)
@property
def is_closed(self) -> bool | None:
@@ -190,15 +188,15 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
- await self._device.set_shutter_level(HMIP_COVER_OPEN, self._channel)
+ await self._device.set_shutter_level_async(HMIP_COVER_OPEN, self._channel)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
- await self._device.set_shutter_level(HMIP_COVER_CLOSED, self._channel)
+ await self._device.set_shutter_level_async(HMIP_COVER_CLOSED, self._channel)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the device if in motion."""
- await self._device.set_shutter_stop(self._channel)
+ await self._device.set_shutter_stop_async(self._channel)
class HomematicipCoverShutter(HomematicipMultiCoverShutter, CoverEntity):
@@ -238,23 +236,25 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity):
position = kwargs[ATTR_TILT_POSITION]
# HmIP slats is closed:1 -> open:0
level = 1 - position / 100.0
- await self._device.set_slats_level(slatsLevel=level, channelIndex=self._channel)
+ await self._device.set_slats_level_async(
+ slatsLevel=level, channelIndex=self._channel
+ )
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the slats."""
- await self._device.set_slats_level(
+ await self._device.set_slats_level_async(
slatsLevel=HMIP_SLATS_OPEN, channelIndex=self._channel
)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the slats."""
- await self._device.set_slats_level(
+ await self._device.set_slats_level_async(
slatsLevel=HMIP_SLATS_CLOSED, channelIndex=self._channel
)
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the device if in motion."""
- await self._device.set_shutter_stop(self._channel)
+ await self._device.set_shutter_stop_async(self._channel)
class HomematicipCoverSlats(HomematicipMultiCoverSlats, CoverEntity):
@@ -288,15 +288,15 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
- await self._device.send_door_command(DoorCommand.OPEN)
+ await self._device.send_door_command_async(DoorCommand.OPEN)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
- await self._device.send_door_command(DoorCommand.CLOSE)
+ await self._device.send_door_command_async(DoorCommand.CLOSE)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
- await self._device.send_door_command(DoorCommand.STOP)
+ await self._device.send_door_command_async(DoorCommand.STOP)
class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):
@@ -335,35 +335,35 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):
position = kwargs[ATTR_POSITION]
# HmIP cover is closed:1 -> open:0
level = 1 - position / 100.0
- await self._device.set_shutter_level(level)
+ await self._device.set_shutter_level_async(level)
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific tilt position."""
position = kwargs[ATTR_TILT_POSITION]
# HmIP slats is closed:1 -> open:0
level = 1 - position / 100.0
- await self._device.set_slats_level(level)
+ await self._device.set_slats_level_async(level)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
- await self._device.set_shutter_level(HMIP_COVER_OPEN)
+ await self._device.set_shutter_level_async(HMIP_COVER_OPEN)
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
- await self._device.set_shutter_level(HMIP_COVER_CLOSED)
+ await self._device.set_shutter_level_async(HMIP_COVER_CLOSED)
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the group if in motion."""
- await self._device.set_shutter_stop()
+ await self._device.set_shutter_stop_async()
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
"""Open the slats."""
- await self._device.set_slats_level(HMIP_SLATS_OPEN)
+ await self._device.set_slats_level_async(HMIP_SLATS_OPEN)
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
"""Close the slats."""
- await self._device.set_slats_level(HMIP_SLATS_CLOSED)
+ await self._device.set_slats_level_async(HMIP_SLATS_CLOSED)
async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
"""Stop the group if in motion."""
- await self._device.set_shutter_stop()
+ await self._device.set_shutter_stop_async()
diff --git a/homeassistant/components/homematicip_cloud/entity.py b/homeassistant/components/homematicip_cloud/entity.py
index 82d682b9910..41ccbb4b060 100644
--- a/homeassistant/components/homematicip_cloud/entity.py
+++ b/homeassistant/components/homematicip_cloud/entity.py
@@ -5,9 +5,9 @@ from __future__ import annotations
import logging
from typing import Any
-from homematicip.aio.device import AsyncDevice
-from homematicip.aio.group import AsyncGroup
from homematicip.base.functionalChannels import FunctionalChannel
+from homematicip.device import Device
+from homematicip.group import Group
from homeassistant.const import ATTR_ID
from homeassistant.core import callback
@@ -100,7 +100,7 @@ class HomematicipGenericEntity(Entity):
def device_info(self) -> DeviceInfo | None:
"""Return device specific attributes."""
# Only physical devices should be HA devices.
- if isinstance(self._device, AsyncDevice):
+ if isinstance(self._device, Device):
return DeviceInfo(
identifiers={
# Serial numbers of Homematic IP device
@@ -237,14 +237,14 @@ class HomematicipGenericEntity(Entity):
"""Return the state attributes of the generic entity."""
state_attr = {}
- if isinstance(self._device, AsyncDevice):
+ if isinstance(self._device, Device):
for attr, attr_key in DEVICE_ATTRIBUTES.items():
if attr_value := getattr(self._device, attr, None):
state_attr[attr_key] = attr_value
state_attr[ATTR_IS_GROUP] = False
- if isinstance(self._device, AsyncGroup):
+ if isinstance(self._device, Group):
for attr, attr_key in GROUP_ATTRIBUTES.items():
if attr_value := getattr(self._device, attr, None):
state_attr[attr_key] = attr_value
diff --git a/homeassistant/components/homematicip_cloud/event.py b/homeassistant/components/homematicip_cloud/event.py
index 8fb558b2b34..47a5ff46224 100644
--- a/homeassistant/components/homematicip_cloud/event.py
+++ b/homeassistant/components/homematicip_cloud/event.py
@@ -3,7 +3,7 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING
-from homematicip.aio.device import Device
+from homematicip.device import Device
from homeassistant.components.event import (
EventDeviceClass,
@@ -12,7 +12,7 @@ from homeassistant.components.event import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import HomematicipGenericEntity
@@ -37,7 +37,7 @@ EVENT_DESCRIPTIONS = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP cover from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id]
diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py
index db7fcb348c8..d55b98b8c18 100644
--- a/homeassistant/components/homematicip_cloud/hap.py
+++ b/homeassistant/components/homematicip_cloud/hap.py
@@ -7,15 +7,18 @@ from collections.abc import Callable
import logging
from typing import Any
-from homematicip.aio.auth import AsyncAuth
-from homematicip.aio.home import AsyncHome
+from homematicip.async_home import AsyncHome
+from homematicip.auth import Auth
from homematicip.base.base_connection import HmipConnectionError
from homematicip.base.enums import EventType
+from homematicip.connection.connection_context import ConnectionContextBuilder
+from homematicip.connection.rest_connection import RestConnection
+import homeassistant
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.httpx_client import get_async_client
from .const import HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN, PLATFORMS
from .errors import HmipcConnectionError
@@ -23,10 +26,25 @@ from .errors import HmipcConnectionError
_LOGGER = logging.getLogger(__name__)
+async def build_context_async(
+ hass: HomeAssistant, hapid: str | None, authtoken: str | None
+):
+ """Create a HomematicIP context object."""
+ ssl_ctx = homeassistant.util.ssl.get_default_context()
+ client_session = get_async_client(hass)
+
+ return await ConnectionContextBuilder.build_context_async(
+ accesspoint_id=hapid,
+ auth_token=authtoken,
+ ssl_ctx=ssl_ctx,
+ httpx_client_session=client_session,
+ )
+
+
class HomematicipAuth:
"""Manages HomematicIP client registration."""
- auth: AsyncAuth
+ auth: Auth
def __init__(self, hass: HomeAssistant, config: dict[str, str]) -> None:
"""Initialize HomematicIP Cloud client registration."""
@@ -46,27 +64,34 @@ class HomematicipAuth:
async def async_checkbutton(self) -> bool:
"""Check blue butten has been pressed."""
try:
- return await self.auth.isRequestAcknowledged()
+ return await self.auth.is_request_acknowledged()
except HmipConnectionError:
return False
async def async_register(self):
"""Register client at HomematicIP."""
try:
- authtoken = await self.auth.requestAuthToken()
- await self.auth.confirmAuthToken(authtoken)
+ authtoken = await self.auth.request_auth_token()
+ await self.auth.confirm_auth_token(authtoken)
except HmipConnectionError:
return False
return authtoken
async def get_auth(self, hass: HomeAssistant, hapid, pin):
"""Create a HomematicIP access point object."""
- auth = AsyncAuth(hass.loop, async_get_clientsession(hass))
+ context = await build_context_async(hass, hapid, None)
+ connection = RestConnection(
+ context,
+ log_status_exceptions=False,
+ httpx_client_session=get_async_client(hass),
+ )
+ # hass.loop
+ auth = Auth(connection, context.client_auth_token, hapid)
+
try:
- await auth.init(hapid)
- if pin:
- auth.pin = pin
- await auth.connectionRequest("HomeAssistant")
+ auth.set_pin(pin)
+ result = await auth.connection_request(hapid)
+ _LOGGER.debug("Connection request result: %s", result)
except HmipConnectionError:
return None
return auth
@@ -156,7 +181,7 @@ class HomematicipHAP:
async def get_state(self) -> None:
"""Update HMIP state and tell Home Assistant."""
- await self.home.get_current_state()
+ await self.home.get_current_state_async()
self.update_all()
def get_state_finished(self, future) -> None:
@@ -187,8 +212,8 @@ class HomematicipHAP:
retry_delay = 2 ** min(tries, 8)
try:
- await self.home.get_current_state()
- hmip_events = await self.home.enable_events()
+ await self.home.get_current_state_async()
+ hmip_events = self.home.enable_events()
tries = 0
await hmip_events
except HmipConnectionError:
@@ -219,7 +244,7 @@ class HomematicipHAP:
self._ws_close_requested = True
if self._retry_task is not None:
self._retry_task.cancel()
- await self.home.disable_events()
+ await self.home.disable_events_async()
_LOGGER.debug("Closed connection to HomematicIP cloud server")
await self.hass.config_entries.async_unload_platforms(
self.config_entry, PLATFORMS
@@ -246,17 +271,17 @@ class HomematicipHAP:
name: str | None,
) -> AsyncHome:
"""Create a HomematicIP access point object."""
- home = AsyncHome(hass.loop, async_get_clientsession(hass))
+ home = AsyncHome()
home.name = name
# Use the title of the config entry as title for the home.
home.label = self.config_entry.title
home.modelType = "HomematicIP Cloud Home"
- home.set_auth_token(authtoken)
try:
- await home.init(hapid)
- await home.get_current_state()
+ context = await build_context_async(hass, hapid, authtoken)
+ home.init_with_context(context, True, get_async_client(hass))
+ await home.get_current_state_async()
except HmipConnectionError as err:
raise HmipcConnectionError from err
home.on_update(self.async_update)
diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py
index cf051103a10..338599b9a14 100644
--- a/homeassistant/components/homematicip_cloud/light.py
+++ b/homeassistant/components/homematicip_cloud/light.py
@@ -4,18 +4,18 @@ from __future__ import annotations
from typing import Any
-from homematicip.aio.device import (
- AsyncBrandDimmer,
- AsyncBrandSwitchMeasuring,
- AsyncBrandSwitchNotificationLight,
- AsyncDimmer,
- AsyncDinRailDimmer3,
- AsyncFullFlushDimmer,
- AsyncPluggableDimmer,
- AsyncWiredDimmer3,
-)
from homematicip.base.enums import OpticalSignalBehaviour, RGBColorState
from homematicip.base.functionalChannels import NotificationLightChannel
+from homematicip.device import (
+ BrandDimmer,
+ BrandSwitchMeasuring,
+ BrandSwitchNotificationLight,
+ Dimmer,
+ DinRailDimmer3,
+ FullFlushDimmer,
+ PluggableDimmer,
+ WiredDimmer3,
+)
from packaging.version import Version
from homeassistant.components.light import (
@@ -30,7 +30,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import HomematicipGenericEntity
@@ -40,15 +40,15 @@ from .hap import HomematicipHAP
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP Cloud lights from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id]
entities: list[HomematicipGenericEntity] = []
for device in hap.home.devices:
- if isinstance(device, AsyncBrandSwitchMeasuring):
+ if isinstance(device, BrandSwitchMeasuring):
entities.append(HomematicipLightMeasuring(hap, device))
- elif isinstance(device, AsyncBrandSwitchNotificationLight):
+ elif isinstance(device, BrandSwitchNotificationLight):
device_version = Version(device.firmwareVersion)
entities.append(HomematicipLight(hap, device))
@@ -65,14 +65,14 @@ async def async_setup_entry(
entity_class(hap, device, device.bottomLightChannelIndex, "Bottom")
)
- elif isinstance(device, (AsyncWiredDimmer3, AsyncDinRailDimmer3)):
+ elif isinstance(device, (WiredDimmer3, DinRailDimmer3)):
entities.extend(
HomematicipMultiDimmer(hap, device, channel=channel)
for channel in range(1, 4)
)
elif isinstance(
device,
- (AsyncDimmer, AsyncPluggableDimmer, AsyncBrandDimmer, AsyncFullFlushDimmer),
+ (Dimmer, PluggableDimmer, BrandDimmer, FullFlushDimmer),
):
entities.append(HomematicipDimmer(hap, device))
@@ -96,11 +96,11 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
- await self._device.turn_on()
+ await self._device.turn_on_async()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
- await self._device.turn_off()
+ await self._device.turn_off_async()
class HomematicipLightMeasuring(HomematicipLight):
@@ -141,15 +141,15 @@ class HomematicipMultiDimmer(HomematicipGenericEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the dimmer on."""
if ATTR_BRIGHTNESS in kwargs:
- await self._device.set_dim_level(
+ await self._device.set_dim_level_async(
kwargs[ATTR_BRIGHTNESS] / 255.0, self._channel
)
else:
- await self._device.set_dim_level(1, self._channel)
+ await self._device.set_dim_level_async(1, self._channel)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the dimmer off."""
- await self._device.set_dim_level(0, self._channel)
+ await self._device.set_dim_level_async(0, self._channel)
class HomematicipDimmer(HomematicipMultiDimmer, LightEntity):
@@ -239,7 +239,7 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity):
dim_level = brightness / 255.0
transition = kwargs.get(ATTR_TRANSITION, 0.5)
- await self._device.set_rgb_dim_level_with_time(
+ await self._device.set_rgb_dim_level_with_time_async(
channelIndex=self._channel,
rgb=simple_rgb_color,
dimLevel=dim_level,
@@ -252,7 +252,7 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity):
simple_rgb_color = self._func_channel.simpleRGBColorState
transition = kwargs.get(ATTR_TRANSITION, 0.5)
- await self._device.set_rgb_dim_level_with_time(
+ await self._device.set_rgb_dim_level_with_time_async(
channelIndex=self._channel,
rgb=simple_rgb_color,
dimLevel=0.0,
diff --git a/homeassistant/components/homematicip_cloud/lock.py b/homeassistant/components/homematicip_cloud/lock.py
index b00f42fc844..04461682f8d 100644
--- a/homeassistant/components/homematicip_cloud/lock.py
+++ b/homeassistant/components/homematicip_cloud/lock.py
@@ -5,13 +5,13 @@ from __future__ import annotations
import logging
from typing import Any
-from homematicip.aio.device import AsyncDoorLockDrive
from homematicip.base.enums import LockState, MotorState
+from homematicip.device import DoorLockDrive
from homeassistant.components.lock import LockEntity, LockEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import HomematicipGenericEntity
@@ -37,7 +37,7 @@ DEVICE_DLD_ATTRIBUTES = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP locks from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id]
@@ -45,7 +45,7 @@ async def async_setup_entry(
async_add_entities(
HomematicipDoorLockDrive(hap, device)
for device in hap.home.devices
- if isinstance(device, AsyncDoorLockDrive)
+ if isinstance(device, DoorLockDrive)
)
@@ -75,17 +75,17 @@ class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity):
@handle_errors
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
- return await self._device.set_lock_state(LockState.LOCKED)
+ return await self._device.set_lock_state_async(LockState.LOCKED)
@handle_errors
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device."""
- return await self._device.set_lock_state(LockState.UNLOCKED)
+ return await self._device.set_lock_state_async(LockState.UNLOCKED)
@handle_errors
async def async_open(self, **kwargs: Any) -> None:
"""Open the door latch."""
- return await self._device.set_lock_state(LockState.OPEN)
+ return await self._device.set_lock_state_async(LockState.OPEN)
@property
def extra_state_attributes(self) -> dict[str, Any]:
diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json
index 414ba37709e..b1d631e7e6a 100644
--- a/homeassistant/components/homematicip_cloud/manifest.json
+++ b/homeassistant/components/homematicip_cloud/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
"iot_class": "cloud_push",
"loggers": ["homematicip"],
- "requirements": ["homematicip==1.1.7"]
+ "requirements": ["homematicip==2.0.0"]
}
diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py
index 9ed9b33d7c7..bddac78df1c 100644
--- a/homeassistant/components/homematicip_cloud/sensor.py
+++ b/homeassistant/components/homematicip_cloud/sensor.py
@@ -5,39 +5,39 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
-from homematicip.aio.device import (
- AsyncBrandSwitchMeasuring,
- AsyncEnergySensorsInterface,
- AsyncFloorTerminalBlock6,
- AsyncFloorTerminalBlock10,
- AsyncFloorTerminalBlock12,
- AsyncFullFlushSwitchMeasuring,
- AsyncHeatingThermostat,
- AsyncHeatingThermostatCompact,
- AsyncHeatingThermostatEvo,
- AsyncHomeControlAccessPoint,
- AsyncLightSensor,
- AsyncMotionDetectorIndoor,
- AsyncMotionDetectorOutdoor,
- AsyncMotionDetectorPushButton,
- AsyncPassageDetector,
- AsyncPlugableSwitchMeasuring,
- AsyncPresenceDetectorIndoor,
- AsyncRoomControlDeviceAnalog,
- AsyncTemperatureDifferenceSensor2,
- AsyncTemperatureHumiditySensorDisplay,
- AsyncTemperatureHumiditySensorOutdoor,
- AsyncTemperatureHumiditySensorWithoutDisplay,
- AsyncWeatherSensor,
- AsyncWeatherSensorPlus,
- AsyncWeatherSensorPro,
- AsyncWiredFloorTerminalBlock12,
-)
from homematicip.base.enums import FunctionalChannelType, ValveState
from homematicip.base.functionalChannels import (
FloorTerminalBlockMechanicChannel,
FunctionalChannel,
)
+from homematicip.device import (
+ BrandSwitchMeasuring,
+ EnergySensorsInterface,
+ FloorTerminalBlock6,
+ FloorTerminalBlock10,
+ FloorTerminalBlock12,
+ FullFlushSwitchMeasuring,
+ HeatingThermostat,
+ HeatingThermostatCompact,
+ HeatingThermostatEvo,
+ HomeControlAccessPoint,
+ LightSensor,
+ MotionDetectorIndoor,
+ MotionDetectorOutdoor,
+ MotionDetectorPushButton,
+ PassageDetector,
+ PlugableSwitchMeasuring,
+ PresenceDetectorIndoor,
+ RoomControlDeviceAnalog,
+ TemperatureDifferenceSensor2,
+ TemperatureHumiditySensorDisplay,
+ TemperatureHumiditySensorOutdoor,
+ TemperatureHumiditySensorWithoutDisplay,
+ WeatherSensor,
+ WeatherSensorPlus,
+ WeatherSensorPro,
+ WiredFloorTerminalBlock12,
+)
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -57,7 +57,7 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
@@ -96,20 +96,20 @@ ILLUMINATION_DEVICE_ATTRIBUTES = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP Cloud sensors from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id]
entities: list[HomematicipGenericEntity] = []
for device in hap.home.devices:
- if isinstance(device, AsyncHomeControlAccessPoint):
+ if isinstance(device, HomeControlAccessPoint):
entities.append(HomematicipAccesspointDutyCycle(hap, device))
if isinstance(
device,
(
- AsyncHeatingThermostat,
- AsyncHeatingThermostatCompact,
- AsyncHeatingThermostatEvo,
+ HeatingThermostat,
+ HeatingThermostatCompact,
+ HeatingThermostatEvo,
),
):
entities.append(HomematicipHeatingThermostat(hap, device))
@@ -117,55 +117,53 @@ async def async_setup_entry(
if isinstance(
device,
(
- AsyncTemperatureHumiditySensorDisplay,
- AsyncTemperatureHumiditySensorWithoutDisplay,
- AsyncTemperatureHumiditySensorOutdoor,
- AsyncWeatherSensor,
- AsyncWeatherSensorPlus,
- AsyncWeatherSensorPro,
+ TemperatureHumiditySensorDisplay,
+ TemperatureHumiditySensorWithoutDisplay,
+ TemperatureHumiditySensorOutdoor,
+ WeatherSensor,
+ WeatherSensorPlus,
+ WeatherSensorPro,
),
):
entities.append(HomematicipTemperatureSensor(hap, device))
entities.append(HomematicipHumiditySensor(hap, device))
- elif isinstance(device, (AsyncRoomControlDeviceAnalog,)):
+ elif isinstance(device, (RoomControlDeviceAnalog,)):
entities.append(HomematicipTemperatureSensor(hap, device))
if isinstance(
device,
(
- AsyncLightSensor,
- AsyncMotionDetectorIndoor,
- AsyncMotionDetectorOutdoor,
- AsyncMotionDetectorPushButton,
- AsyncPresenceDetectorIndoor,
- AsyncWeatherSensor,
- AsyncWeatherSensorPlus,
- AsyncWeatherSensorPro,
+ LightSensor,
+ MotionDetectorIndoor,
+ MotionDetectorOutdoor,
+ MotionDetectorPushButton,
+ PresenceDetectorIndoor,
+ WeatherSensor,
+ WeatherSensorPlus,
+ WeatherSensorPro,
),
):
entities.append(HomematicipIlluminanceSensor(hap, device))
if isinstance(
device,
(
- AsyncPlugableSwitchMeasuring,
- AsyncBrandSwitchMeasuring,
- AsyncFullFlushSwitchMeasuring,
+ PlugableSwitchMeasuring,
+ BrandSwitchMeasuring,
+ FullFlushSwitchMeasuring,
),
):
entities.append(HomematicipPowerSensor(hap, device))
entities.append(HomematicipEnergySensor(hap, device))
- if isinstance(
- device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro)
- ):
+ if isinstance(device, (WeatherSensor, WeatherSensorPlus, WeatherSensorPro)):
entities.append(HomematicipWindspeedSensor(hap, device))
- if isinstance(device, (AsyncWeatherSensorPlus, AsyncWeatherSensorPro)):
+ if isinstance(device, (WeatherSensorPlus, WeatherSensorPro)):
entities.append(HomematicipTodayRainSensor(hap, device))
- if isinstance(device, AsyncPassageDetector):
+ if isinstance(device, PassageDetector):
entities.append(HomematicipPassageDetectorDeltaCounter(hap, device))
- if isinstance(device, AsyncTemperatureDifferenceSensor2):
+ if isinstance(device, TemperatureDifferenceSensor2):
entities.append(HomematicpTemperatureExternalSensorCh1(hap, device))
entities.append(HomematicpTemperatureExternalSensorCh2(hap, device))
entities.append(HomematicpTemperatureExternalSensorDelta(hap, device))
- if isinstance(device, AsyncEnergySensorsInterface):
+ if isinstance(device, EnergySensorsInterface):
for ch in get_channels_from_device(
device, FunctionalChannelType.ENERGY_SENSORS_INTERFACE_CHANNEL
):
@@ -194,10 +192,10 @@ async def async_setup_entry(
if isinstance(
device,
(
- AsyncFloorTerminalBlock6,
- AsyncFloorTerminalBlock10,
- AsyncFloorTerminalBlock12,
- AsyncWiredFloorTerminalBlock12,
+ FloorTerminalBlock6,
+ FloorTerminalBlock10,
+ FloorTerminalBlock12,
+ WiredFloorTerminalBlock12,
),
):
entities.extend(
diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py
index 7a4dfd4916f..4518c7736eb 100644
--- a/homeassistant/components/homematicip_cloud/services.py
+++ b/homeassistant/components/homematicip_cloud/services.py
@@ -5,10 +5,10 @@ from __future__ import annotations
import logging
from pathlib import Path
-from homematicip.aio.device import AsyncSwitchMeasuring
-from homematicip.aio.group import AsyncHeatingGroup
-from homematicip.aio.home import AsyncHome
+from homematicip.async_home import AsyncHome
from homematicip.base.helpers import handle_config
+from homematicip.device import SwitchMeasuring
+from homematicip.group import HeatingGroup
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE
@@ -233,10 +233,10 @@ async def _async_activate_eco_mode_with_duration(
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
- await home.activate_absence_with_duration(duration)
+ await home.activate_absence_with_duration_async(duration)
else:
for hap in hass.data[DOMAIN].values():
- await hap.home.activate_absence_with_duration(duration)
+ await hap.home.activate_absence_with_duration_async(duration)
async def _async_activate_eco_mode_with_period(
@@ -247,10 +247,10 @@ async def _async_activate_eco_mode_with_period(
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
- await home.activate_absence_with_period(endtime)
+ await home.activate_absence_with_period_async(endtime)
else:
for hap in hass.data[DOMAIN].values():
- await hap.home.activate_absence_with_period(endtime)
+ await hap.home.activate_absence_with_period_async(endtime)
async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) -> None:
@@ -260,30 +260,30 @@ async def _async_activate_vacation(hass: HomeAssistant, service: ServiceCall) ->
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
- await home.activate_vacation(endtime, temperature)
+ await home.activate_vacation_async(endtime, temperature)
else:
for hap in hass.data[DOMAIN].values():
- await hap.home.activate_vacation(endtime, temperature)
+ await hap.home.activate_vacation_async(endtime, temperature)
async def _async_deactivate_eco_mode(hass: HomeAssistant, service: ServiceCall) -> None:
"""Service to deactivate eco mode."""
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
- await home.deactivate_absence()
+ await home.deactivate_absence_async()
else:
for hap in hass.data[DOMAIN].values():
- await hap.home.deactivate_absence()
+ await hap.home.deactivate_absence_async()
async def _async_deactivate_vacation(hass: HomeAssistant, service: ServiceCall) -> None:
"""Service to deactivate vacation."""
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
- await home.deactivate_vacation()
+ await home.deactivate_vacation_async()
else:
for hap in hass.data[DOMAIN].values():
- await hap.home.deactivate_vacation()
+ await hap.home.deactivate_vacation_async()
async def _set_active_climate_profile(
@@ -297,12 +297,12 @@ async def _set_active_climate_profile(
if entity_id_list != "all":
for entity_id in entity_id_list:
group = hap.hmip_device_by_entity_id.get(entity_id)
- if group and isinstance(group, AsyncHeatingGroup):
- await group.set_active_profile(climate_profile_index)
+ if group and isinstance(group, HeatingGroup):
+ await group.set_active_profile_async(climate_profile_index)
else:
for group in hap.home.groups:
- if isinstance(group, AsyncHeatingGroup):
- await group.set_active_profile(climate_profile_index)
+ if isinstance(group, HeatingGroup):
+ await group.set_active_profile_async(climate_profile_index)
async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> None:
@@ -323,7 +323,7 @@ async def _async_dump_hap_config(hass: HomeAssistant, service: ServiceCall) -> N
path = Path(config_path)
config_file = path / file_name
- json_state = await hap.home.download_configuration()
+ json_state = await hap.home.download_configuration_async()
json_state = handle_config(json_state, anonymize)
config_file.write_text(json_state, encoding="utf8")
@@ -337,12 +337,12 @@ async def _async_reset_energy_counter(hass: HomeAssistant, service: ServiceCall)
if entity_id_list != "all":
for entity_id in entity_id_list:
device = hap.hmip_device_by_entity_id.get(entity_id)
- if device and isinstance(device, AsyncSwitchMeasuring):
- await device.reset_energy_counter()
+ if device and isinstance(device, SwitchMeasuring):
+ await device.reset_energy_counter_async()
else:
for device in hap.home.devices:
- if isinstance(device, AsyncSwitchMeasuring):
- await device.reset_energy_counter()
+ if isinstance(device, SwitchMeasuring):
+ await device.reset_energy_counter_async()
async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall):
@@ -351,10 +351,10 @@ async def _async_set_home_cooling_mode(hass: HomeAssistant, service: ServiceCall
if hapid := service.data.get(ATTR_ACCESSPOINT_ID):
if home := _get_home(hass, hapid):
- await home.set_cooling(cooling)
+ await home.set_cooling_async(cooling)
else:
for hap in hass.data[DOMAIN].values():
- await hap.home.set_cooling(cooling)
+ await hap.home.set_cooling_async(cooling)
def _get_home(hass: HomeAssistant, hapid: str) -> AsyncHome | None:
diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json
index 37deace7ebf..7b1b08ac4e2 100644
--- a/homeassistant/components/homematicip_cloud/strings.json
+++ b/homeassistant/components/homematicip_cloud/strings.json
@@ -3,6 +3,7 @@
"step": {
"init": {
"title": "Pick Homematic IP access point",
+ "description": "If you are about to register a **Homematic IP HCU1**, please press the button on top of the device before you continue.\n\nThe registration process must be completed within 5 minutes.",
"data": {
"hapid": "Access point ID (SGTIN)",
"pin": "[%key:common::config_flow::data::pin%]",
@@ -34,7 +35,7 @@
"services": {
"activate_eco_mode_with_duration": {
"name": "Activate eco mode with duration",
- "description": "Activates eco mode with period.",
+ "description": "Activates the eco mode for a specified duration.",
"fields": {
"duration": {
"name": "Duration",
@@ -48,7 +49,7 @@
},
"activate_eco_mode_with_period": {
"name": "Activate eco more with period",
- "description": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_duration::description%]",
+ "description": "Activates the eco mode until a given time.",
"fields": {
"endtime": {
"name": "Endtime",
@@ -62,7 +63,7 @@
},
"activate_vacation": {
"name": "Activate vacation",
- "description": "Activates the vacation mode until the given time.",
+ "description": "Activates the vacation mode until a given time.",
"fields": {
"endtime": {
"name": "[%key:component::homematicip_cloud::services::activate_eco_mode_with_period::fields::endtime::name%]",
diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py
index 70bf14631cb..2de02fb22a5 100644
--- a/homeassistant/components/homematicip_cloud/switch.py
+++ b/homeassistant/components/homematicip_cloud/switch.py
@@ -4,28 +4,28 @@ from __future__ import annotations
from typing import Any
-from homematicip.aio.device import (
- AsyncBrandSwitch2,
- AsyncBrandSwitchMeasuring,
- AsyncDinRailSwitch,
- AsyncDinRailSwitch4,
- AsyncFullFlushInputSwitch,
- AsyncFullFlushSwitchMeasuring,
- AsyncHeatingSwitch2,
- AsyncMultiIOBox,
- AsyncOpenCollector8Module,
- AsyncPlugableSwitch,
- AsyncPlugableSwitchMeasuring,
- AsyncPrintedCircuitBoardSwitch2,
- AsyncPrintedCircuitBoardSwitchBattery,
- AsyncWiredSwitch8,
+from homematicip.device import (
+ BrandSwitch2,
+ BrandSwitchMeasuring,
+ DinRailSwitch,
+ DinRailSwitch4,
+ FullFlushInputSwitch,
+ FullFlushSwitchMeasuring,
+ HeatingSwitch2,
+ MultiIOBox,
+ OpenCollector8Module,
+ PlugableSwitch,
+ PlugableSwitchMeasuring,
+ PrintedCircuitBoardSwitch2,
+ PrintedCircuitBoardSwitchBattery,
+ WiredSwitch8,
)
-from homematicip.aio.group import AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup
+from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import ATTR_GROUP_MEMBER_UNREACHABLE, HomematicipGenericEntity
@@ -35,33 +35,31 @@ from .hap import HomematicipHAP
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP switch from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id]
entities: list[HomematicipGenericEntity] = [
HomematicipGroupSwitch(hap, group)
for group in hap.home.groups
- if isinstance(group, (AsyncExtendedLinkedSwitchingGroup, AsyncSwitchingGroup))
+ if isinstance(group, (ExtendedLinkedSwitchingGroup, SwitchingGroup))
]
for device in hap.home.devices:
- if isinstance(device, AsyncBrandSwitchMeasuring):
+ if isinstance(device, BrandSwitchMeasuring):
# BrandSwitchMeasuring inherits PlugableSwitchMeasuring
# This entity is implemented in the light platform and will
# not be added in the switch platform
pass
- elif isinstance(
- device, (AsyncPlugableSwitchMeasuring, AsyncFullFlushSwitchMeasuring)
- ):
+ elif isinstance(device, (PlugableSwitchMeasuring, FullFlushSwitchMeasuring)):
entities.append(HomematicipSwitchMeasuring(hap, device))
- elif isinstance(device, AsyncWiredSwitch8):
+ elif isinstance(device, WiredSwitch8):
entities.extend(
HomematicipMultiSwitch(hap, device, channel=channel)
for channel in range(1, 9)
)
- elif isinstance(device, AsyncDinRailSwitch):
+ elif isinstance(device, DinRailSwitch):
entities.append(HomematicipMultiSwitch(hap, device, channel=1))
- elif isinstance(device, AsyncDinRailSwitch4):
+ elif isinstance(device, DinRailSwitch4):
entities.extend(
HomematicipMultiSwitch(hap, device, channel=channel)
for channel in range(1, 5)
@@ -69,13 +67,13 @@ async def async_setup_entry(
elif isinstance(
device,
(
- AsyncPlugableSwitch,
- AsyncPrintedCircuitBoardSwitchBattery,
- AsyncFullFlushInputSwitch,
+ PlugableSwitch,
+ PrintedCircuitBoardSwitchBattery,
+ FullFlushInputSwitch,
),
):
entities.append(HomematicipSwitch(hap, device))
- elif isinstance(device, AsyncOpenCollector8Module):
+ elif isinstance(device, OpenCollector8Module):
entities.extend(
HomematicipMultiSwitch(hap, device, channel=channel)
for channel in range(1, 9)
@@ -83,10 +81,10 @@ async def async_setup_entry(
elif isinstance(
device,
(
- AsyncBrandSwitch2,
- AsyncPrintedCircuitBoardSwitch2,
- AsyncHeatingSwitch2,
- AsyncMultiIOBox,
+ BrandSwitch2,
+ PrintedCircuitBoardSwitch2,
+ HeatingSwitch2,
+ MultiIOBox,
),
):
entities.extend(
@@ -119,11 +117,11 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
- await self._device.turn_on(self._channel)
+ await self._device.turn_on_async(self._channel)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
- await self._device.turn_off(self._channel)
+ await self._device.turn_off_async(self._channel)
class HomematicipSwitch(HomematicipMultiSwitch, SwitchEntity):
@@ -168,11 +166,11 @@ class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the group on."""
- await self._device.turn_on()
+ await self._device.turn_on_async()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the group off."""
- await self._device.turn_off()
+ await self._device.turn_off_async()
class HomematicipSwitchMeasuring(HomematicipSwitch):
diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py
index cbe7c2845b8..78e86ec652c 100644
--- a/homeassistant/components/homematicip_cloud/weather.py
+++ b/homeassistant/components/homematicip_cloud/weather.py
@@ -2,12 +2,8 @@
from __future__ import annotations
-from homematicip.aio.device import (
- AsyncWeatherSensor,
- AsyncWeatherSensorPlus,
- AsyncWeatherSensorPro,
-)
from homematicip.base.enums import WeatherCondition
+from homematicip.device import WeatherSensor, WeatherSensorPlus, WeatherSensorPro
from homeassistant.components.weather import (
ATTR_CONDITION_CLOUDY,
@@ -25,7 +21,7 @@ from homeassistant.components.weather import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import HomematicipGenericEntity
@@ -53,15 +49,15 @@ HOME_WEATHER_CONDITION = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the HomematicIP weather sensor from a config entry."""
hap = hass.data[DOMAIN][config_entry.unique_id]
entities: list[HomematicipGenericEntity] = []
for device in hap.home.devices:
- if isinstance(device, AsyncWeatherSensorPro):
+ if isinstance(device, WeatherSensorPro):
entities.append(HomematicipWeatherSensorPro(hap, device))
- elif isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus)):
+ elif isinstance(device, (WeatherSensor, WeatherSensorPlus)):
entities.append(HomematicipWeatherSensor(hap, device))
entities.append(HomematicipHomeWeather(hap))
diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py
index d4484ee4be3..5a36cdb71f2 100644
--- a/homeassistant/components/homewizard/button.py
+++ b/homeassistant/components/homewizard/button.py
@@ -3,7 +3,7 @@
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
@@ -15,7 +15,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeWizardConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Identify button."""
if entry.runtime_data.data.device.supports_identify():
diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py
index 6bcc51f939e..68dc54aef0e 100644
--- a/homeassistant/components/homewizard/config_flow.py
+++ b/homeassistant/components/homewizard/config_flow.py
@@ -23,8 +23,10 @@ import voluptuous as vol
from homeassistant.components import onboarding
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN
+from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import instance_id
from homeassistant.helpers.selector import TextSelector
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -88,7 +90,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
# Tell device we want a token, user must now press the button within 30 seconds
# The first attempt will always fail, but this opens the window to press the button
- token = await async_request_token(self.ip_address)
+ token = await async_request_token(self.hass, self.ip_address)
errors: dict[str, str] | None = None
if token is None:
@@ -250,7 +252,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] | None = None
- token = await async_request_token(self.ip_address)
+ token = await async_request_token(self.hass, self.ip_address)
if user_input is not None:
if token is None:
@@ -353,7 +355,7 @@ async def async_try_connect(ip_address: str, token: str | None = None) -> Device
await energy_api.close()
-async def async_request_token(ip_address: str) -> str | None:
+async def async_request_token(hass: HomeAssistant, ip_address: str) -> str | None:
"""Try to request a token from the device.
This method is used to request a token from the device,
@@ -362,8 +364,12 @@ async def async_request_token(ip_address: str) -> str | None:
api = HomeWizardEnergyV2(ip_address)
+ # Get a part of the unique id to make the token unique
+ # This is to prevent token conflicts when multiple HA instances are used
+ uuid = await instance_id.async_get(hass)
+
try:
- return await api.get_token("home-assistant")
+ return await api.get_token(f"home-assistant#{uuid[:6]}")
except DisabledError:
return None
finally:
diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py
index e936657f254..a703043a63b 100644
--- a/homeassistant/components/homewizard/number.py
+++ b/homeassistant/components/homewizard/number.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.number import NumberEntity
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
@@ -17,7 +17,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeWizardConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up numbers for device."""
if entry.runtime_data.data.device.supports_state():
diff --git a/homeassistant/components/homewizard/repairs.py b/homeassistant/components/homewizard/repairs.py
index 4c9a03b493f..60790202032 100644
--- a/homeassistant/components/homewizard/repairs.py
+++ b/homeassistant/components/homewizard/repairs.py
@@ -47,7 +47,7 @@ class MigrateToV2ApiRepairFlow(RepairsFlow):
# Tell device we want a token, user must now press the button within 30 seconds
# The first attempt will always fail, but this opens the window to press the button
- token = await async_request_token(ip_address)
+ token = await async_request_token(self.hass, ip_address)
errors: dict[str, str] | None = None
if token is None:
diff --git a/homeassistant/components/homewizard/sensor.py b/homeassistant/components/homewizard/sensor.py
index 5f3133fa9ba..dd557532240 100644
--- a/homeassistant/components/homewizard/sensor.py
+++ b/homeassistant/components/homewizard/sensor.py
@@ -33,7 +33,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
@@ -690,7 +690,7 @@ EXTERNAL_SENSORS = {
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeWizardConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize sensors."""
diff --git a/homeassistant/components/homewizard/switch.py b/homeassistant/components/homewizard/switch.py
index 9f6b3ddd81f..1930b40583d 100644
--- a/homeassistant/components/homewizard/switch.py
+++ b/homeassistant/components/homewizard/switch.py
@@ -16,7 +16,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomeWizardConfigEntry, HWEnergyDeviceUpdateCoordinator
from .entity import HomeWizardEntity
@@ -69,7 +69,7 @@ SWITCHES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeWizardConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches."""
async_add_entities(
diff --git a/homeassistant/components/homeworks/binary_sensor.py b/homeassistant/components/homeworks/binary_sensor.py
index f1ba3c02835..9bdea75479d 100644
--- a/homeassistant/components/homeworks/binary_sensor.py
+++ b/homeassistant/components/homeworks/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeworksData, HomeworksKeypad
from .const import (
@@ -31,7 +31,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homeworks binary sensors."""
data: HomeworksData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/homeworks/button.py b/homeassistant/components/homeworks/button.py
index 6a13573ac88..d76c18985e9 100644
--- a/homeassistant/components/homeworks/button.py
+++ b/homeassistant/components/homeworks/button.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeworksData
from .const import (
@@ -27,7 +27,9 @@ from .entity import HomeworksEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homeworks buttons."""
data: HomeworksData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py
index ac52c1f4974..f07758bbace 100644
--- a/homeassistant/components/homeworks/light.py
+++ b/homeassistant/components/homeworks/light.py
@@ -13,7 +13,7 @@ from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeworksData
from .const import CONF_ADDR, CONF_CONTROLLER_ID, CONF_DIMMERS, CONF_RATE, DOMAIN
@@ -23,7 +23,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homeworks lights."""
data: HomeworksData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/homeworks/strings.json b/homeassistant/components/homeworks/strings.json
index 10cc2e61fb9..3ec4945957b 100644
--- a/homeassistant/components/homeworks/strings.json
+++ b/homeassistant/components/homeworks/strings.json
@@ -14,7 +14,7 @@
},
"step": {
"import_finish": {
- "description": "The existing YAML configuration has succesfully been imported.\n\nYou can now remove the `homeworks` configuration from your configuration.yaml file."
+ "description": "The existing YAML configuration has successfully been imported.\n\nYou can now remove the `homeworks` configuration from your configuration.yaml file."
},
"import_controller_name": {
"description": "Lutron Homeworks is no longer configured through configuration.yaml.\n\nPlease fill in the form to import the existing configuration to the UI.",
@@ -57,7 +57,7 @@
},
"exceptions": {
"invalid_controller_id": {
- "message": "Invalid controller_id \"{controller_id}\", expected one of \"{controller_ids}\""
+ "message": "Invalid controller ID \"{controller_id}\", expected one of \"{controller_ids}\""
}
},
"options": {
diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py
index 1df5eb9601b..5fe84aadd75 100644
--- a/homeassistant/components/honeywell/climate.py
+++ b/homeassistant/components/honeywell/climate.py
@@ -36,7 +36,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
from . import HoneywellConfigEntry, HoneywellData
@@ -98,7 +98,7 @@ SCAN_INTERVAL = datetime.timedelta(seconds=30)
async def async_setup_entry(
hass: HomeAssistant,
entry: HoneywellConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell thermostat."""
cool_away_temp = entry.options.get(CONF_COOL_AWAY_TEMPERATURE)
diff --git a/homeassistant/components/honeywell/humidifier.py b/homeassistant/components/honeywell/humidifier.py
index e94ba465c30..77776f84a2e 100644
--- a/homeassistant/components/honeywell/humidifier.py
+++ b/homeassistant/components/honeywell/humidifier.py
@@ -15,7 +15,7 @@ from homeassistant.components.humidifier import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HoneywellConfigEntry
from .const import DOMAIN
@@ -73,7 +73,7 @@ HUMIDIFIERS: dict[str, HoneywellHumidifierEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HoneywellConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Honeywell (de)humidifier dynamically."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/honeywell/sensor.py b/homeassistant/components/honeywell/sensor.py
index a9109d5d557..75ac6b1b6d3 100644
--- a/homeassistant/components/honeywell/sensor.py
+++ b/homeassistant/components/honeywell/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import HoneywellConfigEntry
@@ -81,7 +81,7 @@ SENSOR_TYPES: tuple[HoneywellSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HoneywellConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell thermostat."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/honeywell/strings.json b/homeassistant/components/honeywell/strings.json
index 2538e7101a1..67295ec5802 100644
--- a/homeassistant/components/honeywell/strings.json
+++ b/homeassistant/components/honeywell/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "description": "Please enter the credentials used to log into mytotalconnectcomfort.com.",
+ "description": "Please enter the credentials used to log in to mytotalconnectcomfort.com.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
@@ -55,7 +55,7 @@
"preset_mode": {
"state": {
"hold": "Hold",
- "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
+ "away": "[%key:common::state::not_home%]",
"none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]"
}
}
diff --git a/homeassistant/components/honeywell/switch.py b/homeassistant/components/honeywell/switch.py
index 3602dd1ba10..06c79bf4b1d 100644
--- a/homeassistant/components/honeywell/switch.py
+++ b/homeassistant/components/honeywell/switch.py
@@ -15,7 +15,7 @@ from homeassistant.components.switch import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HoneywellConfigEntry, HoneywellData
from .const import DOMAIN
@@ -34,7 +34,7 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: HoneywellConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell switches."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/http/headers.py b/homeassistant/components/http/headers.py
index ebc0594e15a..fdb325c7b74 100644
--- a/homeassistant/components/http/headers.py
+++ b/homeassistant/components/http/headers.py
@@ -3,25 +3,34 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
+from typing import Final
+from aiohttp import hdrs
from aiohttp.web import Application, Request, StreamResponse, middleware
from aiohttp.web_exceptions import HTTPException
+from multidict import CIMultiDict, istr
from homeassistant.core import callback
+REFERRER_POLICY: Final[istr] = istr("Referrer-Policy")
+X_CONTENT_TYPE_OPTIONS: Final[istr] = istr("X-Content-Type-Options")
+X_FRAME_OPTIONS: Final[istr] = istr("X-Frame-Options")
+
@callback
def setup_headers(app: Application, use_x_frame_options: bool) -> None:
"""Create headers middleware for the app."""
- added_headers = {
- "Referrer-Policy": "no-referrer",
- "X-Content-Type-Options": "nosniff",
- "Server": "", # Empty server header, to prevent aiohttp of setting one.
- }
+ added_headers = CIMultiDict(
+ {
+ REFERRER_POLICY: "no-referrer",
+ X_CONTENT_TYPE_OPTIONS: "nosniff",
+ hdrs.SERVER: "", # Empty server header, to prevent aiohttp of setting one.
+ }
+ )
if use_x_frame_options:
- added_headers["X-Frame-Options"] = "SAMEORIGIN"
+ added_headers[X_FRAME_OPTIONS] = "SAMEORIGIN"
@middleware
async def headers_middleware(
diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py
index a5a60d8406d..be9d02e45fd 100644
--- a/homeassistant/components/huawei_lte/__init__.py
+++ b/homeassistant/components/huawei_lte/__init__.py
@@ -88,7 +88,7 @@ from .utils import get_device_macs, non_verifying_requests_session
_LOGGER = logging.getLogger(__name__)
-SCAN_INTERVAL = timedelta(seconds=10)
+SCAN_INTERVAL = timedelta(seconds=30)
NOTIFY_SCHEMA = vol.Any(
None,
diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py
index 06b859cea84..c3434dd0b64 100644
--- a/homeassistant/components/huawei_lte/binary_sensor.py
+++ b/homeassistant/components/huawei_lte/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
@@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
router = hass.data[DOMAIN].routers[config_entry.entry_id]
diff --git a/homeassistant/components/huawei_lte/button.py b/homeassistant/components/huawei_lte/button.py
index 55b009d25bf..44b35d51dd4 100644
--- a/homeassistant/components/huawei_lte/button.py
+++ b/homeassistant/components/huawei_lte/button.py
@@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: entity_platform.AddEntitiesCallback,
+ async_add_entities: entity_platform.AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Huawei LTE buttons."""
router = hass.data[DOMAIN].routers[config_entry.entry_id]
diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py
index 96e160ece7b..4ca9e7531e3 100644
--- a/homeassistant/components/huawei_lte/config_flow.py
+++ b/homeassistant/components/huawei_lte/config_flow.py
@@ -178,8 +178,8 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
except Timeout:
_LOGGER.warning("Connection timeout", exc_info=True)
errors[CONF_URL] = "connection_timeout"
- except Exception: # noqa: BLE001
- _LOGGER.warning("Unknown error connecting to device", exc_info=True)
+ except Exception:
+ _LOGGER.exception("Unknown error connecting to device")
errors[CONF_URL] = "unknown"
return conn
@@ -188,8 +188,8 @@ class ConfigFlowHandler(ConfigFlow, domain=DOMAIN):
try:
conn.close()
conn.requests_session.close()
- except Exception: # noqa: BLE001
- _LOGGER.debug("Disconnect error", exc_info=True)
+ except Exception:
+ _LOGGER.exception("Disconnect error")
async def async_step_user(
self, user_input: dict[str, Any] | None = None
diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py
index df849d4f712..83e82bf17ff 100644
--- a/homeassistant/components/huawei_lte/device_tracker.py
+++ b/homeassistant/components/huawei_lte/device_tracker.py
@@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Router
from .const import (
@@ -53,7 +53,7 @@ def _get_hosts(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
@@ -128,7 +128,7 @@ def _is_us(host: _HostType) -> bool:
@callback
def async_add_new_entities(
router: Router,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
tracked: set[str],
) -> None:
"""Add new entities that are not already being tracked."""
diff --git a/homeassistant/components/huawei_lte/entity.py b/homeassistant/components/huawei_lte/entity.py
index 99d7ca112c4..b69d2e79fb6 100644
--- a/homeassistant/components/huawei_lte/entity.py
+++ b/homeassistant/components/huawei_lte/entity.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from collections.abc import Callable
from datetime import timedelta
from homeassistant.helpers.device_registry import DeviceInfo
@@ -25,7 +24,6 @@ class HuaweiLteBaseEntity(Entity):
def __init__(self, router: Router) -> None:
"""Initialize."""
self.router = router
- self._unsub_handlers: list[Callable] = []
@property
def _device_unique_id(self) -> str:
@@ -48,7 +46,7 @@ class HuaweiLteBaseEntity(Entity):
async def async_added_to_hass(self) -> None:
"""Connect to update signals."""
- self._unsub_handlers.append(
+ self.async_on_remove(
async_dispatcher_connect(self.hass, UPDATE_SIGNAL, self._async_maybe_update)
)
@@ -57,12 +55,6 @@ class HuaweiLteBaseEntity(Entity):
if config_entry_unique_id == self.router.config_entry.unique_id:
self.async_schedule_update_ha_state(True)
- async def async_will_remove_from_hass(self) -> None:
- """Invoke unsubscription handlers."""
- for unsub in self._unsub_handlers:
- unsub()
- self._unsub_handlers.clear()
-
class HuaweiLteBaseEntityWithDevice(HuaweiLteBaseEntity):
"""Base entity with device info."""
diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json
index 6720d6718ef..ce5316553ed 100644
--- a/homeassistant/components/huawei_lte/manifest.json
+++ b/homeassistant/components/huawei_lte/manifest.json
@@ -9,7 +9,7 @@
"requirements": [
"huawei-lte-api==1.10.0",
"stringcase==1.2.0",
- "url-normalize==1.4.3"
+ "url-normalize==2.2.0"
],
"ssdp": [
{
diff --git a/homeassistant/components/huawei_lte/select.py b/homeassistant/components/huawei_lte/select.py
index d8a16ae2f79..3df6fa53320 100644
--- a/homeassistant/components/huawei_lte/select.py
+++ b/homeassistant/components/huawei_lte/select.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED
from . import Router
@@ -38,7 +38,7 @@ class HuaweiSelectEntityDescription(SelectEntityDescription):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
router = hass.data[DOMAIN].routers[config_entry.entry_id]
diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py
index 86965e89dd0..3543433ca45 100644
--- a/homeassistant/components/huawei_lte/sensor.py
+++ b/homeassistant/components/huawei_lte/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import Router
@@ -754,7 +754,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
router = hass.data[DOMAIN].routers[config_entry.entry_id]
diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json
index 879c7215562..912bc174dd5 100644
--- a/homeassistant/components/huawei_lte/strings.json
+++ b/homeassistant/components/huawei_lte/strings.json
@@ -272,8 +272,8 @@
"operator_search_mode": {
"name": "Operator search mode",
"state": {
- "0": "Auto",
- "1": "Manual"
+ "0": "[%key:common::state::auto%]",
+ "1": "[%key:common::state::manual%]"
}
},
"preferred_network_mode": {
diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py
index 07fd89d0b6c..ac8bca4234c 100644
--- a/homeassistant/components/huawei_lte/switch.py
+++ b/homeassistant/components/huawei_lte/switch.py
@@ -13,7 +13,7 @@ from homeassistant.components.switch import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
@@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
router = hass.data[DOMAIN].routers[config_entry.entry_id]
diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py
index 2cb8e8b5d90..ecaa6576775 100644
--- a/homeassistant/components/hue/binary_sensor.py
+++ b/homeassistant/components/hue/binary_sensor.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .bridge import HueBridge
from .const import DOMAIN
@@ -15,7 +15,7 @@ from .v2.binary_sensor import async_setup_entry as setup_entry_v2
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensor entities."""
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py
index 64f3ccba9f9..249f81687c0 100644
--- a/homeassistant/components/hue/event.py
+++ b/homeassistant/components/hue/event.py
@@ -16,7 +16,7 @@ from homeassistant.components.event import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .bridge import HueBridge
from .const import DEFAULT_BUTTON_EVENT_TYPES, DEVICE_SPECIFIC_EVENT_TYPES, DOMAIN
@@ -26,7 +26,7 @@ from .v2.entity import HueBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up event platform from Hue button resources."""
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hue/icons.json b/homeassistant/components/hue/icons.json
index 31464308b0a..646c420f1fe 100644
--- a/homeassistant/components/hue/icons.json
+++ b/homeassistant/components/hue/icons.json
@@ -1,4 +1,28 @@
{
+ "entity": {
+ "light": {
+ "hue_light": {
+ "state_attributes": {
+ "effect": {
+ "state": {
+ "candle": "mdi:candle",
+ "sparkle": "mdi:shimmer",
+ "glisten": "mdi:creation",
+ "sunrise": "mdi:weather-sunset-up",
+ "sunset": "mdi:weather-sunset",
+ "fire": "mdi:fire",
+ "prism": "mdi:triangle-outline",
+ "opal": "mdi:diamond-stone",
+ "underwater": "mdi:waves",
+ "cosmos": "mdi:star-shooting",
+ "sunbeam": "mdi:spotlight-beam",
+ "enchant": "mdi:magic-staff"
+ }
+ }
+ }
+ }
+ }
+ },
"services": {
"hue_activate_scene": {
"service": "mdi:palette"
diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py
index c3168b5c8c1..9906c9bffa4 100644
--- a/homeassistant/components/hue/light.py
+++ b/homeassistant/components/hue/light.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .bridge import HueBridge
from .const import DOMAIN
@@ -16,7 +16,7 @@ from .v2.light import async_setup_entry as setup_entry_v2
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up light entities."""
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json
index 22f1d3991e7..8bc3d84bd50 100644
--- a/homeassistant/components/hue/manifest.json
+++ b/homeassistant/components/hue/manifest.json
@@ -10,6 +10,6 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiohue"],
- "requirements": ["aiohue==4.7.3"],
+ "requirements": ["aiohue==4.7.4"],
"zeroconf": ["_hue._tcp.local."]
}
diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py
index 1d83804820d..0b9eb4efbd6 100644
--- a/homeassistant/components/hue/scene.py
+++ b/homeassistant/components/hue/scene.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
@@ -34,7 +34,7 @@ ATTR_BRIGHTNESS = "brightness"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up scene platform from Hue group scenes."""
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py
index 45cff053aef..227742fdbab 100644
--- a/homeassistant/components/hue/sensor.py
+++ b/homeassistant/components/hue/sensor.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .bridge import HueBridge
from .const import DOMAIN
@@ -15,7 +15,7 @@ from .v2.sensor import async_setup_entry as setup_entry_v2
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor entities."""
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json
index 2f7f2e55561..3326dd1043f 100644
--- a/homeassistant/components/hue/strings.json
+++ b/homeassistant/components/hue/strings.json
@@ -11,7 +11,7 @@
}
},
"manual": {
- "title": "Manual configure a Hue bridge",
+ "title": "Manually configure a Hue bridge",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
@@ -46,8 +46,8 @@
"button_2": "Second button",
"button_3": "Third button",
"button_4": "Fourth button",
- "double_buttons_1_3": "First and Third buttons",
- "double_buttons_2_4": "Second and Fourth buttons",
+ "double_buttons_1_3": "First and third button",
+ "double_buttons_2_4": "Second and fourth button",
"dim_down": "Dim down",
"dim_up": "Dim up",
"turn_off": "[%key:common::action::turn_off%]",
@@ -102,6 +102,28 @@
}
}
},
+ "light": {
+ "hue_light": {
+ "state_attributes": {
+ "effect": {
+ "state": {
+ "candle": "Candle",
+ "sparkle": "Sparkle",
+ "glisten": "Glisten",
+ "sunrise": "Sunrise",
+ "sunset": "Sunset",
+ "fire": "Fire",
+ "prism": "Prism",
+ "opal": "Opal",
+ "underwater": "Underwater",
+ "cosmos": "Cosmos",
+ "sunbeam": "Sunbeam",
+ "enchant": "Enchant"
+ }
+ }
+ }
+ }
+ },
"sensor": {
"zigbee_connectivity": {
"name": "Zigbee connectivity",
@@ -175,5 +197,11 @@
}
}
}
+ },
+ "issues": {
+ "deprecated_effect_none": {
+ "title": "Light turned on with deprecated effect",
+ "description": "A light was turned on with the deprecated effect `None`. This has been replaced with `off`. Please update any automations, scenes, or scripts that use this effect."
+ }
}
}
diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py
index b4bc57acf2d..b6b21686d25 100644
--- a/homeassistant/components/hue/switch.py
+++ b/homeassistant/components/hue/switch.py
@@ -22,7 +22,7 @@ from homeassistant.components.switch import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .bridge import HueBridge
from .const import DOMAIN
@@ -32,7 +32,7 @@ from .v2.entity import HueBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hue switch platform from Hue resources."""
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py
index 5054ab6e817..6e4c7f98973 100644
--- a/homeassistant/components/hue/v2/binary_sensor.py
+++ b/homeassistant/components/hue/v2/binary_sensor.py
@@ -30,7 +30,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from ..bridge import HueBridge
from ..const import DOMAIN
@@ -49,7 +49,7 @@ type ControllerType = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hue Sensors from Config Entry."""
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py
index 25a027f9ebe..7bb3d28e962 100644
--- a/homeassistant/components/hue/v2/device.py
+++ b/homeassistant/components/hue/v2/device.py
@@ -94,7 +94,12 @@ async def async_setup_devices(bridge: HueBridge):
add_device(hue_resource)
# create/update all current devices found in controllers
- known_devices = [add_device(hue_device) for hue_device in dev_controller]
+ # sort the devices to ensure bridges are added first
+ hue_devices = list(dev_controller)
+ hue_devices.sort(
+ key=lambda dev: dev.metadata.archetype != DeviceArchetypes.BRIDGE_V2
+ )
+ known_devices = [add_device(hue_device) for hue_device in hue_devices]
known_devices += [add_device(hue_room) for hue_room in api.groups.room]
known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone]
diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py
index 17cd20b55aa..2f9f195df97 100644
--- a/homeassistant/components/hue/v2/group.py
+++ b/homeassistant/components/hue/v2/group.py
@@ -26,7 +26,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from ..bridge import HueBridge
@@ -42,7 +42,7 @@ from .helpers import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hue groups on light platform."""
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py
index 86d8cc93e54..8eb7ec8936e 100644
--- a/homeassistant/components/hue/v2/light.py
+++ b/homeassistant/components/hue/v2/light.py
@@ -18,6 +18,7 @@ from homeassistant.components.light import (
ATTR_FLASH,
ATTR_TRANSITION,
ATTR_XY_COLOR,
+ EFFECT_OFF,
FLASH_SHORT,
ColorMode,
LightEntity,
@@ -27,7 +28,8 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.util import color as color_util
from ..bridge import HueBridge
@@ -39,16 +41,18 @@ from .helpers import (
normalize_hue_transition,
)
-EFFECT_NONE = "None"
FALLBACK_MIN_KELVIN = 6500
FALLBACK_MAX_KELVIN = 2000
FALLBACK_KELVIN = 5800 # halfway
+# HA 2025.4 replaced the deprecated effect "None" with HA default "off"
+DEPRECATED_EFFECT_NONE = "None"
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hue Light from Config Entry."""
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
@@ -75,7 +79,7 @@ class HueLight(HueBaseEntity, LightEntity):
_fixed_color_mode: ColorMode | None = None
entity_description = LightEntityDescription(
- key="hue_light", has_entity_name=True, name=None
+ key="hue_light", translation_key="hue_light", has_entity_name=True, name=None
)
def __init__(
@@ -107,7 +111,9 @@ class HueLight(HueBaseEntity, LightEntity):
self._attr_effect_list = []
if effects := resource.effects:
self._attr_effect_list = [
- x.value for x in effects.status_values if x != EffectStatus.NO_EFFECT
+ x.value
+ for x in effects.status_values
+ if x not in (EffectStatus.NO_EFFECT, EffectStatus.UNKNOWN)
]
if timed_effects := resource.timed_effects:
self._attr_effect_list += [
@@ -116,7 +122,7 @@ class HueLight(HueBaseEntity, LightEntity):
if x != TimedEffectStatus.NO_EFFECT
]
if len(self._attr_effect_list) > 0:
- self._attr_effect_list.insert(0, EFFECT_NONE)
+ self._attr_effect_list.insert(0, EFFECT_OFF)
self._attr_supported_features |= LightEntityFeature.EFFECT
@property
@@ -209,7 +215,7 @@ class HueLight(HueBaseEntity, LightEntity):
if timed_effects := self.resource.timed_effects:
if timed_effects.status != TimedEffectStatus.NO_EFFECT:
return timed_effects.status.value
- return EFFECT_NONE
+ return EFFECT_OFF
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
@@ -231,12 +237,29 @@ class HueLight(HueBaseEntity, LightEntity):
self._color_temp_active = color_temp is not None
flash = kwargs.get(ATTR_FLASH)
effect = effect_str = kwargs.get(ATTR_EFFECT)
- if effect_str in (EFFECT_NONE, EFFECT_NONE.lower()):
- # ignore effect if set to "None" and we have no effect active
- # the special effect "None" is only used to stop an active effect
+ if effect_str == DEPRECATED_EFFECT_NONE:
+ # deprecated effect "None" is now "off"
+ effect_str = EFFECT_OFF
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ "deprecated_effect_none",
+ breaks_in_ha_version="2025.10.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key="deprecated_effect_none",
+ )
+ self.logger.warning(
+ "Detected deprecated effect 'None' in %s, use 'off' instead. "
+ "This will stop working in HA 2025.10",
+ self.entity_id,
+ )
+ if effect_str == EFFECT_OFF:
+ # ignore effect if set to "off" and we have no effect active
+ # the special effect "off" is only used to stop an active effect
# but sending it while no effect is active can actually result in issues
# https://github.com/home-assistant/core/issues/122165
- effect = None if self.effect == EFFECT_NONE else EffectStatus.NO_EFFECT
+ effect = None if self.effect == EFFECT_OFF else EffectStatus.NO_EFFECT
elif effect_str is not None:
# work out if we got a regular effect or timed effect
effect = EffectStatus(effect_str)
diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py
index bdf1db6df2e..ae6e456a8b4 100644
--- a/homeassistant/components/hue/v2/sensor.py
+++ b/homeassistant/components/hue/v2/sensor.py
@@ -28,7 +28,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LIGHT_LUX, PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from ..bridge import HueBridge
from ..const import DOMAIN
@@ -46,7 +46,7 @@ type ControllerType = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hue Sensors from Config Entry."""
bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py
index c024e3030fa..91c953b2182 100644
--- a/homeassistant/components/huisbaasje/sensor.py
+++ b/homeassistant/components/huisbaasje/sensor.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -219,7 +219,7 @@ SENSORS_INFO = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
coordinator: DataUpdateCoordinator[dict[str, dict[str, Any]]] = hass.data[DOMAIN][
diff --git a/homeassistant/components/huisbaasje/strings.json b/homeassistant/components/huisbaasje/strings.json
index de112f7519f..3958e6a8903 100644
--- a/homeassistant/components/huisbaasje/strings.json
+++ b/homeassistant/components/huisbaasje/strings.json
@@ -26,25 +26,25 @@
"name": "Current power in peak"
},
"current_power_off_peak": {
- "name": "Current power in off peak"
+ "name": "Current power in off-peak"
},
"current_power_out_peak": {
"name": "Current power out peak"
},
"current_power_out_off_peak": {
- "name": "Current power out off peak"
+ "name": "Current power out off-peak"
},
"energy_consumption_peak_today": {
"name": "Energy consumption peak today"
},
"energy_consumption_off_peak_today": {
- "name": "Energy consumption off peak today"
+ "name": "Energy consumption off-peak today"
},
"energy_production_peak_today": {
"name": "Energy production peak today"
},
"energy_production_off_peak_today": {
- "name": "Energy production off peak today"
+ "name": "Energy production off-peak today"
},
"energy_today": {
"name": "Energy today"
diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json
index 753368dc572..6c0c691c705 100644
--- a/homeassistant/components/humidifier/strings.json
+++ b/homeassistant/components/humidifier/strings.json
@@ -62,15 +62,15 @@
"mode": {
"name": "Mode",
"state": {
- "normal": "Normal",
- "eco": "Eco",
- "away": "Away",
+ "normal": "[%key:common::state::normal%]",
+ "home": "[%key:common::state::home%]",
+ "away": "[%key:common::state::not_home%]",
+ "auto": "[%key:common::state::auto%]",
+ "baby": "Baby",
"boost": "Boost",
"comfort": "Comfort",
- "home": "[%key:common::state::home%]",
- "sleep": "Sleep",
- "auto": "Auto",
- "baby": "Baby"
+ "eco": "Eco",
+ "sleep": "Sleep"
}
}
}
@@ -89,7 +89,7 @@
"fields": {
"mode": {
"name": "Mode",
- "description": "Operation mode. For example, _normal_, _eco_, or _away_. For a list of possible values, refer to the integration documentation."
+ "description": "Operation mode. For example, \"normal\", \"eco\", or \"away\". For a list of possible values, refer to the integration documentation."
}
}
},
diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py
index adb3e177a8e..c0bcac3a7df 100644
--- a/homeassistant/components/hunterdouglas_powerview/button.py
+++ b/homeassistant/components/hunterdouglas_powerview/button.py
@@ -22,7 +22,7 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PowerviewShadeUpdateCoordinator
from .entity import ShadeEntity
@@ -74,7 +74,7 @@ BUTTONS_SHADE: Final = [
async def async_setup_entry(
hass: HomeAssistant,
entry: PowerviewConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the hunter douglas advanced feature buttons."""
pv_entry = entry.runtime_data
diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py
index 197fb4e6223..3f36a57891c 100644
--- a/homeassistant/components/hunterdouglas_powerview/cover.py
+++ b/homeassistant/components/hunterdouglas_powerview/cover.py
@@ -26,7 +26,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from .const import STATE_ATTRIBUTE_ROOM_NAME
@@ -50,7 +50,7 @@ SCAN_INTERVAL = timedelta(minutes=10)
async def async_setup_entry(
hass: HomeAssistant,
entry: PowerviewConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the hunter douglas shades."""
pv_entry = entry.runtime_data
diff --git a/homeassistant/components/hunterdouglas_powerview/number.py b/homeassistant/components/hunterdouglas_powerview/number.py
index fb8c9f76d79..216cdb2642a 100644
--- a/homeassistant/components/hunterdouglas_powerview/number.py
+++ b/homeassistant/components/hunterdouglas_powerview/number.py
@@ -14,7 +14,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PowerviewShadeUpdateCoordinator
from .entity import ShadeEntity
@@ -54,7 +54,7 @@ NUMBERS: Final = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PowerviewConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the hunter douglas number entities."""
pv_entry = entry.runtime_data
diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py
index 2aaa255c5ab..5016b590f91 100644
--- a/homeassistant/components/hunterdouglas_powerview/scene.py
+++ b/homeassistant/components/hunterdouglas_powerview/scene.py
@@ -10,7 +10,7 @@ from aiopvapi.resources.scene import Scene as PvScene
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import STATE_ATTRIBUTE_ROOM_NAME
from .coordinator import PowerviewShadeUpdateCoordinator
@@ -25,7 +25,7 @@ RESYNC_DELAY = 60
async def async_setup_entry(
hass: HomeAssistant,
entry: PowerviewConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up powerview scene entries."""
pv_entry = entry.runtime_data
diff --git a/homeassistant/components/hunterdouglas_powerview/select.py b/homeassistant/components/hunterdouglas_powerview/select.py
index db850a0ddbf..932ff3ce3bd 100644
--- a/homeassistant/components/hunterdouglas_powerview/select.py
+++ b/homeassistant/components/hunterdouglas_powerview/select.py
@@ -12,7 +12,7 @@ from aiopvapi.resources.shade import BaseShade
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PowerviewShadeUpdateCoordinator
from .entity import ShadeEntity
@@ -54,7 +54,7 @@ DROPDOWNS: Final = [
async def async_setup_entry(
hass: HomeAssistant,
entry: PowerviewConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the hunter douglas select entities."""
pv_entry = entry.runtime_data
diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py
index f5e3ddd5e12..6ebf8e2b278 100644
--- a/homeassistant/components/hunterdouglas_powerview/sensor.py
+++ b/homeassistant/components/hunterdouglas_powerview/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PowerviewShadeUpdateCoordinator
from .entity import ShadeEntity
@@ -79,7 +79,7 @@ SENSORS: Final = [
async def async_setup_entry(
hass: HomeAssistant,
entry: PowerviewConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the hunter douglas sensor entities."""
pv_entry = entry.runtime_data
diff --git a/homeassistant/components/husqvarna_automower/binary_sensor.py b/homeassistant/components/husqvarna_automower/binary_sensor.py
index 907d34e812a..1e5b9fac990 100644
--- a/homeassistant/components/husqvarna_automower/binary_sensor.py
+++ b/homeassistant/components/husqvarna_automower/binary_sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.components.script import scripts_with_entity
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
@@ -71,7 +71,7 @@ MOWER_BINARY_SENSOR_TYPES: tuple[AutomowerBinarySensorEntityDescription, ...] =
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensor platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/husqvarna_automower/button.py b/homeassistant/components/husqvarna_automower/button.py
index 7e6e581cdf1..1f7ed7127e0 100644
--- a/homeassistant/components/husqvarna_automower/button.py
+++ b/homeassistant/components/husqvarna_automower/button.py
@@ -10,7 +10,7 @@ from aioautomower.session import AutomowerSession
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator
@@ -54,7 +54,7 @@ MOWER_BUTTON_TYPES: tuple[AutomowerButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/husqvarna_automower/calendar.py b/homeassistant/components/husqvarna_automower/calendar.py
index 9e2ea037afb..26e939ec7d9 100644
--- a/homeassistant/components/husqvarna_automower/calendar.py
+++ b/homeassistant/components/husqvarna_automower/calendar.py
@@ -7,7 +7,7 @@ from aioautomower.model import make_name_string
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import AutomowerConfigEntry
@@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lawn mower platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/husqvarna_automower/config_flow.py b/homeassistant/components/husqvarna_automower/config_flow.py
index 7efed529453..31ca5eef0cd 100644
--- a/homeassistant/components/husqvarna_automower/config_flow.py
+++ b/homeassistant/components/husqvarna_automower/config_flow.py
@@ -54,7 +54,8 @@ class HusqvarnaConfigFlowHandler(
automower_api = AutomowerSession(AsyncConfigFlowAuth(websession, token), tz)
try:
status_data = await automower_api.get_status()
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
if status_data == {}:
return self.async_abort(reason="no_mower_connected")
diff --git a/homeassistant/components/husqvarna_automower/coordinator.py b/homeassistant/components/husqvarna_automower/coordinator.py
index 819ee41a43d..c23ca508916 100644
--- a/homeassistant/components/husqvarna_automower/coordinator.py
+++ b/homeassistant/components/husqvarna_automower/coordinator.py
@@ -13,7 +13,7 @@ from aioautomower.exceptions import (
HusqvarnaTimeoutError,
HusqvarnaWSServerHandshakeError,
)
-from aioautomower.model import MowerAttributes
+from aioautomower.model import MowerDictionary
from aioautomower.session import AutomowerSession
from homeassistant.config_entries import ConfigEntry
@@ -32,7 +32,7 @@ DEFAULT_RECONNECT_TIME = 2 # Define a default reconnect time
type AutomowerConfigEntry = ConfigEntry[AutomowerDataUpdateCoordinator]
-class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttributes]]):
+class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
"""Class to manage fetching Husqvarna data."""
config_entry: AutomowerConfigEntry
@@ -61,7 +61,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
self._zones_last_update: dict[str, set[str]] = {}
self._areas_last_update: dict[str, set[int]] = {}
- async def _async_update_data(self) -> dict[str, MowerAttributes]:
+ async def _async_update_data(self) -> MowerDictionary:
"""Subscribe for websocket and poll data from the API."""
if not self.ws_connected:
await self.api.connect()
@@ -84,7 +84,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
return data
@callback
- def callback(self, ws_data: dict[str, MowerAttributes]) -> None:
+ def callback(self, ws_data: MowerDictionary) -> None:
"""Process websocket callbacks and write them to the DataUpdateCoordinator."""
self.async_set_updated_data(ws_data)
@@ -119,7 +119,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
"reconnect_task",
)
- def _async_add_remove_devices(self, data: dict[str, MowerAttributes]) -> None:
+ def _async_add_remove_devices(self, data: MowerDictionary) -> None:
"""Add new device, remove non-existing device."""
current_devices = set(data)
@@ -136,6 +136,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
# Process new device
new_devices = current_devices - self._devices_last_update
if new_devices:
+ self.data = data
_LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices)))
self._add_new_devices(new_devices)
@@ -159,9 +160,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
for mower_callback in self.new_devices_callbacks:
mower_callback(new_devices)
- def _async_add_remove_stay_out_zones(
- self, data: dict[str, MowerAttributes]
- ) -> None:
+ def _async_add_remove_stay_out_zones(self, data: MowerDictionary) -> None:
"""Add new stay-out zones, remove non-existing stay-out zones."""
current_zones = {
mower_id: set(mower_data.stay_out_zones.zones)
@@ -207,7 +206,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
return current_zones
- def _async_add_remove_work_areas(self, data: dict[str, MowerAttributes]) -> None:
+ def _async_add_remove_work_areas(self, data: MowerDictionary) -> None:
"""Add new work areas, remove non-existing work areas."""
current_areas = {
mower_id: set(mower_data.work_areas)
diff --git a/homeassistant/components/husqvarna_automower/device_tracker.py b/homeassistant/components/husqvarna_automower/device_tracker.py
index 2fd59b63014..78fad7c3610 100644
--- a/homeassistant/components/husqvarna_automower/device_tracker.py
+++ b/homeassistant/components/husqvarna_automower/device_tracker.py
@@ -2,7 +2,7 @@
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator
@@ -15,7 +15,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/husqvarna_automower/lawn_mower.py b/homeassistant/components/husqvarna_automower/lawn_mower.py
index dd75a8b9bc4..ee6007f089b 100644
--- a/homeassistant/components/husqvarna_automower/lawn_mower.py
+++ b/homeassistant/components/husqvarna_automower/lawn_mower.py
@@ -15,7 +15,7 @@ from homeassistant.components.lawn_mower import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AutomowerConfigEntry
from .const import DOMAIN
@@ -49,7 +49,7 @@ OVERRIDE_MODES = [MOW, PARK]
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lawn mower platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json
index 0eabf5ec0d6..d26cc18c127 100644
--- a/homeassistant/components/husqvarna_automower/manifest.json
+++ b/homeassistant/components/husqvarna_automower/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
- "requirements": ["aioautomower==2025.1.1"]
+ "requirements": ["aioautomower==2025.4.0"]
}
diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py
index d3666494646..9ed00113d4b 100644
--- a/homeassistant/components/husqvarna_automower/number.py
+++ b/homeassistant/components/husqvarna_automower/number.py
@@ -11,7 +11,7 @@ from aioautomower.session import AutomowerSession
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator
@@ -44,7 +44,7 @@ async def async_set_work_area_cutting_height(
) -> None:
"""Set cutting height for work area."""
await coordinator.api.commands.workarea_settings(
- mower_id, int(cheight), work_area_id
+ mower_id, work_area_id, cutting_height=int(cheight)
)
@@ -107,7 +107,7 @@ WORK_AREA_NUMBER_TYPES: tuple[WorkAreaNumberEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/husqvarna_automower/select.py b/homeassistant/components/husqvarna_automower/select.py
index 03b1ac02587..9124a0705e1 100644
--- a/homeassistant/components/husqvarna_automower/select.py
+++ b/homeassistant/components/husqvarna_automower/select.py
@@ -8,7 +8,7 @@ from aioautomower.model import HeadlightModes
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator
@@ -29,7 +29,7 @@ HEADLIGHT_MODES: list = [
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up select platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/husqvarna_automower/sensor.py b/homeassistant/components/husqvarna_automower/sensor.py
index a2f4b5f4bab..5ad8ad91b48 100644
--- a/homeassistant/components/husqvarna_automower/sensor.py
+++ b/homeassistant/components/husqvarna_automower/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfTime
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import AutomowerConfigEntry
@@ -40,8 +40,7 @@ PARALLEL_UPDATES = 0
ATTR_WORK_AREA_ID_ASSIGNMENT = "work_area_id_assignment"
-ERROR_KEY_LIST = [
- "no_error",
+ERROR_KEYS = [
"alarm_mower_in_motion",
"alarm_mower_lifted",
"alarm_mower_stopped",
@@ -50,13 +49,11 @@ ERROR_KEY_LIST = [
"alarm_outside_geofence",
"angular_sensor_problem",
"battery_problem",
- "battery_problem",
"battery_restriction_due_to_ambient_temperature",
"can_error",
"charging_current_too_high",
"charging_station_blocked",
"charging_system_problem",
- "charging_system_problem",
"collision_sensor_defect",
"collision_sensor_error",
"collision_sensor_problem_front",
@@ -67,24 +64,18 @@ ERROR_KEY_LIST = [
"connection_changed",
"connection_not_changed",
"connectivity_problem",
- "connectivity_problem",
- "connectivity_problem",
- "connectivity_problem",
- "connectivity_problem",
- "connectivity_problem",
"connectivity_settings_restored",
"cutting_drive_motor_1_defect",
"cutting_drive_motor_2_defect",
"cutting_drive_motor_3_defect",
"cutting_height_blocked",
- "cutting_height_problem",
"cutting_height_problem_curr",
"cutting_height_problem_dir",
"cutting_height_problem_drive",
+ "cutting_height_problem",
"cutting_motor_problem",
"cutting_stopped_slope_too_steep",
"cutting_system_blocked",
- "cutting_system_blocked",
"cutting_system_imbalance_warning",
"cutting_system_major_imbalance",
"destination_not_reachable",
@@ -92,13 +83,9 @@ ERROR_KEY_LIST = [
"docking_sensor_defect",
"electronic_problem",
"empty_battery",
- MowerStates.ERROR.lower(),
- MowerStates.ERROR_AT_POWER_UP.lower(),
- MowerStates.FATAL_ERROR.lower(),
"folding_cutting_deck_sensor_defect",
"folding_sensor_activated",
"geofence_problem",
- "geofence_problem",
"gps_navigation_problem",
"guide_1_not_found",
"guide_2_not_found",
@@ -116,7 +103,6 @@ ERROR_KEY_LIST = [
"lift_sensor_defect",
"lifted",
"limited_cutting_height_range",
- "limited_cutting_height_range",
"loop_sensor_defect",
"loop_sensor_problem_front",
"loop_sensor_problem_left",
@@ -129,6 +115,7 @@ ERROR_KEY_LIST = [
"no_accurate_position_from_satellites",
"no_confirmed_position",
"no_drive",
+ "no_error",
"no_loop_signal",
"no_power_in_charging_station",
"no_response_from_charger",
@@ -139,9 +126,6 @@ ERROR_KEY_LIST = [
"safety_function_faulty",
"settings_restored",
"sim_card_locked",
- "sim_card_locked",
- "sim_card_locked",
- "sim_card_locked",
"sim_card_not_found",
"sim_card_requires_pin",
"slipped_mower_has_slipped_situation_not_solved_with_moving_pattern",
@@ -151,13 +135,6 @@ ERROR_KEY_LIST = [
"stuck_in_charging_station",
"switch_cord_problem",
"temporary_battery_problem",
- "temporary_battery_problem",
- "temporary_battery_problem",
- "temporary_battery_problem",
- "temporary_battery_problem",
- "temporary_battery_problem",
- "temporary_battery_problem",
- "temporary_battery_problem",
"tilt_sensor_problem",
"too_high_discharge_current",
"too_high_internal_current",
@@ -189,11 +166,19 @@ ERROR_KEY_LIST = [
"zone_generator_problem",
]
-ERROR_STATES = {
- MowerStates.ERROR,
+ERROR_STATES = [
MowerStates.ERROR_AT_POWER_UP,
+ MowerStates.ERROR,
MowerStates.FATAL_ERROR,
-}
+ MowerStates.OFF,
+ MowerStates.STOPPED,
+ MowerStates.WAIT_POWER_UP,
+ MowerStates.WAIT_UPDATING,
+]
+
+ERROR_KEY_LIST = list(
+ dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES])
+)
RESTRICTED_REASONS: list = [
RestrictedReasons.ALL_WORK_AREAS_COMPLETED,
@@ -227,12 +212,16 @@ def _get_work_area_names(data: MowerAttributes) -> list[str]:
@callback
def _get_current_work_area_name(data: MowerAttributes) -> str:
"""Return the name of the current work area."""
- if data.mower.work_area_id is None:
- return STATE_NO_WORK_AREA_ACTIVE
if TYPE_CHECKING:
# Sensor does not get created if values are None
assert data.work_areas is not None
- return data.work_areas[data.mower.work_area_id].name
+ if (
+ data.mower.work_area_id is not None
+ and data.mower.work_area_id in data.work_areas
+ ):
+ return data.work_areas[data.mower.work_area_id].name
+
+ return STATE_NO_WORK_AREA_ACTIVE
@callback
@@ -288,6 +277,7 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
AutomowerSensorEntityDescription(
key="cutting_blade_usage_time",
translation_key="cutting_blade_usage_time",
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -295,6 +285,19 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
exists_fn=lambda data: data.statistics.cutting_blade_usage_time is not None,
value_fn=attrgetter("statistics.cutting_blade_usage_time"),
),
+ AutomowerSensorEntityDescription(
+ key="downtime",
+ translation_key="downtime",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.TOTAL,
+ device_class=SensorDeviceClass.DURATION,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_display_precision=0,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ exists_fn=lambda data: data.statistics.downtime is not None,
+ value_fn=attrgetter("statistics.downtime"),
+ ),
AutomowerSensorEntityDescription(
key="total_charging_time",
translation_key="total_charging_time",
@@ -367,6 +370,19 @@ MOWER_SENSOR_TYPES: tuple[AutomowerSensorEntityDescription, ...] = (
exists_fn=lambda data: data.statistics.total_drive_distance is not None,
value_fn=attrgetter("statistics.total_drive_distance"),
),
+ AutomowerSensorEntityDescription(
+ key="uptime",
+ translation_key="uptime",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.TOTAL,
+ device_class=SensorDeviceClass.DURATION,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_display_precision=0,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
+ exists_fn=lambda data: data.statistics.uptime is not None,
+ value_fn=attrgetter("statistics.uptime"),
+ ),
AutomowerSensorEntityDescription(
key="next_start_timestamp",
translation_key="next_start_timestamp",
@@ -430,7 +446,7 @@ WORK_AREA_SENSOR_TYPES: tuple[WorkAreaSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json
index 9bd0bb06b3e..015d322c481 100644
--- a/homeassistant/components/husqvarna_automower/strings.json
+++ b/homeassistant/components/husqvarna_automower/strings.json
@@ -106,10 +106,10 @@
"cutting_drive_motor_2_defect": "Cutting drive motor 2 defect",
"cutting_drive_motor_3_defect": "Cutting drive motor 3 defect",
"cutting_height_blocked": "Cutting height blocked",
- "cutting_height_problem": "Cutting height problem",
"cutting_height_problem_curr": "Cutting height problem, curr",
"cutting_height_problem_dir": "Cutting height problem, dir",
"cutting_height_problem_drive": "Cutting height problem, drive",
+ "cutting_height_problem": "Cutting height problem",
"cutting_motor_problem": "Cutting motor problem",
"cutting_stopped_slope_too_steep": "Cutting stopped - slope too steep",
"cutting_system_blocked": "Cutting system blocked",
@@ -120,8 +120,8 @@
"docking_sensor_defect": "Docking sensor defect",
"electronic_problem": "Electronic problem",
"empty_battery": "Empty battery",
- "error": "Error",
"error_at_power_up": "Error at power up",
+ "error": "[%key:common::state::error%]",
"fatal_error": "Fatal error",
"folding_cutting_deck_sensor_defect": "Folding cutting deck sensor defect",
"folding_sensor_activated": "Folding sensor activated",
@@ -159,6 +159,7 @@
"no_loop_signal": "No loop signal",
"no_power_in_charging_station": "No power in charging station",
"no_response_from_charger": "No response from charger",
+ "off": "[%key:common::state::off%]",
"outside_working_area": "Outside working area",
"poor_signal_quality": "Poor signal quality",
"reference_station_communication_problem": "Reference station communication problem",
@@ -172,6 +173,7 @@
"slope_too_steep": "Slope too steep",
"sms_could_not_be_sent": "SMS could not be sent",
"stop_button_problem": "STOP button problem",
+ "stopped": "[%key:common::state::stopped%]",
"stuck_in_charging_station": "Stuck in charging station",
"switch_cord_problem": "Switch cord problem",
"temporary_battery_problem": "Temporary battery problem",
@@ -187,6 +189,8 @@
"unexpected_cutting_height_adj": "Unexpected cutting height adjustment",
"unexpected_error": "Unexpected error",
"upside_down": "Upside down",
+ "wait_power_up": "Wait power up",
+ "wait_updating": "Wait updating",
"weak_gps_signal": "Weak GPS signal",
"wheel_drive_problem_left": "Left wheel drive problem",
"wheel_drive_problem_rear_left": "Rear left wheel drive problem",
@@ -221,6 +225,9 @@
"cutting_blade_usage_time": {
"name": "Cutting blade usage time"
},
+ "downtime": {
+ "name": "Downtime"
+ },
"restricted_reason": {
"name": "Restricted reason",
"state": {
@@ -263,6 +270,9 @@
"demo": "Demo"
}
},
+ "uptime": {
+ "name": "Uptime"
+ },
"work_area": {
"name": "Work area",
"state": {
diff --git a/homeassistant/components/husqvarna_automower/switch.py b/homeassistant/components/husqvarna_automower/switch.py
index d55d51b42fe..69a3e670eda 100644
--- a/homeassistant/components/husqvarna_automower/switch.py
+++ b/homeassistant/components/husqvarna_automower/switch.py
@@ -7,7 +7,7 @@ from aioautomower.model import MowerModes, StayOutZones, Zone
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AutomowerConfigEntry
from .coordinator import AutomowerDataUpdateCoordinator
@@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: AutomowerConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py
index 980efc6f069..4b239394c2d 100644
--- a/homeassistant/components/husqvarna_automower_ble/lawn_mower.py
+++ b/homeassistant/components/husqvarna_automower_ble/lawn_mower.py
@@ -12,7 +12,7 @@ from homeassistant.components.lawn_mower import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER
from .coordinator import HusqvarnaCoordinator
@@ -22,7 +22,7 @@ from .entity import HusqvarnaAutomowerBleEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AutomowerLawnMower integration from a config entry."""
coordinator: HusqvarnaCoordinator = config_entry.runtime_data
diff --git a/homeassistant/components/huum/climate.py b/homeassistant/components/huum/climate.py
index 7e0e4ce5ef1..84173260d04 100644
--- a/homeassistant/components/huum/climate.py
+++ b/homeassistant/components/huum/climate.py
@@ -20,7 +20,7 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTempera
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Huum sauna with config flow."""
huum_handler = hass.data.setdefault(DOMAIN, {})[entry.entry_id]
diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py
index 913c61f91b4..622a8436e04 100644
--- a/homeassistant/components/hvv_departures/binary_sensor.py
+++ b/homeassistant/components/hvv_departures/binary_sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -30,7 +30,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary_sensor platform."""
hub = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py
index 6ad61295d04..667893db8f2 100644
--- a/homeassistant/components/hvv_departures/sensor.py
+++ b/homeassistant/components/hvv_departures/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.const import ATTR_ID, CONF_OFFSET
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from homeassistant.util.dt import get_time_zone, utcnow
@@ -42,7 +42,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
hub = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py
index ee5a8a66610..ce4d7a8f8c2 100644
--- a/homeassistant/components/hydrawise/__init__.py
+++ b/homeassistant/components/hydrawise/__init__.py
@@ -39,10 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
app_id=APP_ID,
)
- main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, hydrawise)
+ main_coordinator = HydrawiseMainDataUpdateCoordinator(hass, config_entry, hydrawise)
await main_coordinator.async_config_entry_first_refresh()
water_use_coordinator = HydrawiseWaterUseDataUpdateCoordinator(
- hass, hydrawise, main_coordinator
+ hass, config_entry, hydrawise, main_coordinator
)
await water_use_coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = (
diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py
index 83e8a8325f9..b2862930933 100644
--- a/homeassistant/components/hydrawise/binary_sensor.py
+++ b/homeassistant/components/hydrawise/binary_sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .const import DOMAIN, SERVICE_RESUME, SERVICE_START_WATERING, SERVICE_SUSPEND
@@ -78,7 +78,7 @@ SCHEMA_SUSPEND: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Hydrawise binary_sensor platform."""
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py
index 4721a9fb154..35d816b341b 100644
--- a/homeassistant/components/hydrawise/coordinator.py
+++ b/homeassistant/components/hydrawise/coordinator.py
@@ -7,6 +7,7 @@ from dataclasses import dataclass, field
from pydrawise import HydrawiseBase
from pydrawise.schema import Controller, ControllerWaterUseSummary, Sensor, User, Zone
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.dt import now
@@ -39,6 +40,7 @@ class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[HydrawiseData]):
"""Base class for Hydrawise Data Update Coordinators."""
api: HydrawiseBase
+ config_entry: ConfigEntry
class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
@@ -49,9 +51,17 @@ class HydrawiseMainDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
integration are updated in a timely manner.
"""
- def __init__(self, hass: HomeAssistant, api: HydrawiseBase) -> None:
+ def __init__(
+ self, hass: HomeAssistant, config_entry: ConfigEntry, api: HydrawiseBase
+ ) -> None:
"""Initialize HydrawiseDataUpdateCoordinator."""
- super().__init__(hass, LOGGER, name=DOMAIN, update_interval=MAIN_SCAN_INTERVAL)
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=config_entry,
+ name=DOMAIN,
+ update_interval=MAIN_SCAN_INTERVAL,
+ )
self.api = api
async def _async_update_data(self) -> HydrawiseData:
@@ -82,6 +92,7 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
def __init__(
self,
hass: HomeAssistant,
+ config_entry: ConfigEntry,
api: HydrawiseBase,
main_coordinator: HydrawiseMainDataUpdateCoordinator,
) -> None:
@@ -89,6 +100,7 @@ class HydrawiseWaterUseDataUpdateCoordinator(HydrawiseDataUpdateCoordinator):
super().__init__(
hass,
LOGGER,
+ config_entry=config_entry,
name=f"{DOMAIN} water use",
update_interval=WATER_USE_SCAN_INTERVAL,
)
diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json
index 73423882e4a..0c355c34a71 100644
--- a/homeassistant/components/hydrawise/manifest.json
+++ b/homeassistant/components/hydrawise/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/hydrawise",
"iot_class": "cloud_polling",
"loggers": ["pydrawise"],
- "requirements": ["pydrawise==2025.2.0"]
+ "requirements": ["pydrawise==2025.3.0"]
}
diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py
index 96cc16832da..60bc1d7dc63 100644
--- a/homeassistant/components/hydrawise/sensor.py
+++ b/homeassistant/components/hydrawise/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -131,7 +131,7 @@ FLOW_MEASUREMENT_KEYS = [x.key for x in FLOW_CONTROLLER_SENSORS]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Hydrawise sensor platform."""
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py
index 62cd81a0481..bc6b31e6d2e 100644
--- a/homeassistant/components/hydrawise/switch.py
+++ b/homeassistant/components/hydrawise/switch.py
@@ -16,7 +16,7 @@ from homeassistant.components.switch import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DEFAULT_WATERING_TIME, DOMAIN
@@ -63,7 +63,7 @@ SWITCH_KEYS: list[str] = [desc.key for desc in SWITCH_TYPES]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Hydrawise switch platform."""
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hydrawise/valve.py b/homeassistant/components/hydrawise/valve.py
index 37f196bc054..13aff22ccbf 100644
--- a/homeassistant/components/hydrawise/valve.py
+++ b/homeassistant/components/hydrawise/valve.py
@@ -14,7 +14,7 @@ from homeassistant.components.valve import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import HydrawiseUpdateCoordinators
@@ -31,7 +31,7 @@ VALVE_TYPES: tuple[ValveEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Hydrawise valve platform."""
coordinators: HydrawiseUpdateCoordinators = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py
index 23ce2715140..1260be20eb2 100644
--- a/homeassistant/components/hyperion/camera.py
+++ b/homeassistant/components/hyperion/camera.py
@@ -32,7 +32,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import (
get_hyperion_device_id,
@@ -54,7 +54,7 @@ IMAGE_STREAM_JPG_SENTINEL = "data:image/jpg;base64,"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Hyperion platform from config entry."""
entry_data = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py
index 40d093430a5..f8932a682ab 100644
--- a/homeassistant/components/hyperion/light.py
+++ b/homeassistant/components/hyperion/light.py
@@ -25,7 +25,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from . import (
@@ -76,7 +76,7 @@ ICON_EFFECT = "mdi:lava-lamp"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Hyperion platform from config entry."""
diff --git a/homeassistant/components/hyperion/sensor.py b/homeassistant/components/hyperion/sensor.py
index ad972806ae5..42b41acea96 100644
--- a/homeassistant/components/hyperion/sensor.py
+++ b/homeassistant/components/hyperion/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import (
get_hyperion_device_id,
@@ -63,7 +63,7 @@ def _sensor_unique_id(server_id: str, instance_num: int, suffix: str) -> str:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Hyperion platform from config entry."""
entry_data = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py
index 94cbf2aba29..8b66783e889 100644
--- a/homeassistant/components/hyperion/switch.py
+++ b/homeassistant/components/hyperion/switch.py
@@ -34,7 +34,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from . import (
@@ -90,7 +90,7 @@ def _component_to_translation_key(component: str) -> str:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Hyperion platform from config entry."""
entry_data = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py
index 4ae3787dc1d..e203f892c35 100644
--- a/homeassistant/components/ialarm/alarm_control_panel.py
+++ b/homeassistant/components/ialarm/alarm_control_panel.py
@@ -10,7 +10,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DATA_COORDINATOR, DOMAIN
@@ -18,7 +18,9 @@ from .coordinator import IAlarmDataUpdateCoordinator
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a iAlarm alarm control panel based on a config entry."""
coordinator: IAlarmDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py
index 9e173dc36e0..8fe9d77fbe8 100644
--- a/homeassistant/components/iaqualink/binary_sensor.py
+++ b/homeassistant/components/iaqualink/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN as AQUALINK_DOMAIN
from .entity import AqualinkEntity
@@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up discovered binary sensors."""
async_add_entities(
diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py
index 53d1bce80de..d30700898c8 100644
--- a/homeassistant/components/iaqualink/climate.py
+++ b/homeassistant/components/iaqualink/climate.py
@@ -18,7 +18,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import refresh_system
from .const import DOMAIN as AQUALINK_DOMAIN
@@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up discovered switches."""
async_add_entities(
diff --git a/homeassistant/components/iaqualink/entity.py b/homeassistant/components/iaqualink/entity.py
index 437611e5a5f..d0176ed8bfe 100644
--- a/homeassistant/components/iaqualink/entity.py
+++ b/homeassistant/components/iaqualink/entity.py
@@ -32,7 +32,6 @@ class AqualinkEntity(Entity):
manufacturer=dev.manufacturer,
model=dev.model,
name=dev.label,
- via_device=(DOMAIN, dev.system.serial),
)
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py
index 59172c13576..e515c482158 100644
--- a/homeassistant/components/iaqualink/light.py
+++ b/homeassistant/components/iaqualink/light.py
@@ -16,7 +16,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import refresh_system
from .const import DOMAIN as AQUALINK_DOMAIN
@@ -29,7 +29,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up discovered lights."""
async_add_entities(
diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json
index 2531632075c..a0742865438 100644
--- a/homeassistant/components/iaqualink/manifest.json
+++ b/homeassistant/components/iaqualink/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/iaqualink",
"iot_class": "cloud_polling",
"loggers": ["iaqualink"],
- "requirements": ["iaqualink==0.5.0", "h2==4.1.0"],
+ "requirements": ["iaqualink==0.5.3", "h2==4.2.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py
index 881adb420bf..1b453f28d8f 100644
--- a/homeassistant/components/iaqualink/sensor.py
+++ b/homeassistant/components/iaqualink/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN as AQUALINK_DOMAIN
from .entity import AqualinkEntity
@@ -23,7 +23,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up discovered sensors."""
async_add_entities(
diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py
index 601c5701a4a..e746cbb4f4b 100644
--- a/homeassistant/components/iaqualink/switch.py
+++ b/homeassistant/components/iaqualink/switch.py
@@ -9,7 +9,7 @@ from iaqualink.device import AqualinkSwitch
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import refresh_system
from .const import DOMAIN as AQUALINK_DOMAIN
@@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up discovered switches."""
async_add_entities(
diff --git a/homeassistant/components/ibeacon/device_tracker.py b/homeassistant/components/ibeacon/device_tracker.py
index d002cb10f44..0d2ee0137cc 100644
--- a/homeassistant/components/ibeacon/device_tracker.py
+++ b/homeassistant/components/ibeacon/device_tracker.py
@@ -9,7 +9,7 @@ from homeassistant.components.device_tracker.config_entry import BaseTrackerEnti
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IBeaconConfigEntry
from .const import SIGNAL_IBEACON_DEVICE_NEW
@@ -20,7 +20,7 @@ from .entity import IBeaconEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: IBeaconConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for iBeacon Tracker component."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ibeacon/sensor.py b/homeassistant/components/ibeacon/sensor.py
index f73aef4b803..7e1fd371128 100644
--- a/homeassistant/components/ibeacon/sensor.py
+++ b/homeassistant/components/ibeacon/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IBeaconConfigEntry
from .const import SIGNAL_IBEACON_DEVICE_NEW
@@ -69,7 +69,7 @@ SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: IBeaconConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for iBeacon Tracker component."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py
index 11a18a10020..ca194143852 100644
--- a/homeassistant/components/icloud/device_tracker.py
+++ b/homeassistant/components/icloud/device_tracker.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .account import IcloudAccount, IcloudDevice
from .const import (
@@ -21,7 +21,9 @@ from .const import (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for iCloud component."""
account: IcloudAccount = hass.data[DOMAIN][entry.unique_id]
diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py
index 53c9765f6cc..533605b8c7b 100644
--- a/homeassistant/components/icloud/sensor.py
+++ b/homeassistant/components/icloud/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.const import PERCENTAGE
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from .account import IcloudAccount, IcloudDevice
@@ -18,7 +18,9 @@ from .const import DOMAIN
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for iCloud component."""
account: IcloudAccount = hass.data[DOMAIN][entry.unique_id]
diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json
index adc96043d66..fc78e8c2ba6 100644
--- a/homeassistant/components/icloud/strings.json
+++ b/homeassistant/components/icloud/strings.json
@@ -46,7 +46,7 @@
"services": {
"update": {
"name": "Update",
- "description": "Updates iCloud devices.",
+ "description": "Asks for a state update of all devices linked to an iCloud account.",
"fields": {
"account": {
"name": "Account",
diff --git a/homeassistant/components/idasen_desk/button.py b/homeassistant/components/idasen_desk/button.py
index afd2f72917c..2e63e5e7cb8 100644
--- a/homeassistant/components/idasen_desk/button.py
+++ b/homeassistant/components/idasen_desk/button.py
@@ -8,7 +8,7 @@ from typing import Any, Final
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import IdasenDeskConfigEntry, IdasenDeskCoordinator
from .entity import IdasenDeskEntity
@@ -44,7 +44,7 @@ BUTTONS: Final = [
async def async_setup_entry(
hass: HomeAssistant,
entry: IdasenDeskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set buttons for device."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py
index b99eb67d8f5..b451f4d0156 100644
--- a/homeassistant/components/idasen_desk/cover.py
+++ b/homeassistant/components/idasen_desk/cover.py
@@ -14,7 +14,7 @@ from homeassistant.components.cover import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import IdasenDeskConfigEntry, IdasenDeskCoordinator
from .entity import IdasenDeskEntity
@@ -23,7 +23,7 @@ from .entity import IdasenDeskEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: IdasenDeskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the cover platform for Idasen Desk."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/idasen_desk/sensor.py b/homeassistant/components/idasen_desk/sensor.py
index f4ba163b123..22680b4fa7f 100644
--- a/homeassistant/components/idasen_desk/sensor.py
+++ b/homeassistant/components/idasen_desk/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import IdasenDeskConfigEntry, IdasenDeskCoordinator
from .entity import IdasenDeskEntity
@@ -43,7 +43,7 @@ SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: IdasenDeskConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Idasen Desk sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json
index 7486973638b..ff0cb5b8ae6 100644
--- a/homeassistant/components/idasen_desk/strings.json
+++ b/homeassistant/components/idasen_desk/strings.json
@@ -7,7 +7,7 @@
"address": "Device"
},
"data_description": {
- "address": "The bluetooth device for the desk."
+ "address": "The Bluetooth device for the desk."
}
}
},
@@ -26,10 +26,10 @@
"entity": {
"button": {
"connect": {
- "name": "Connect"
+ "name": "[%key:common::action::connect%]"
},
"disconnect": {
- "name": "Disconnect"
+ "name": "[%key:common::action::disconnect%]"
}
},
"sensor": {
diff --git a/homeassistant/components/igloohome/__init__.py b/homeassistant/components/igloohome/__init__.py
index 5e5e21452cf..a3907fcbcf3 100644
--- a/homeassistant/components/igloohome/__init__.py
+++ b/homeassistant/components/igloohome/__init__.py
@@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-PLATFORMS: list[Platform] = [Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR]
@dataclass
@@ -35,7 +35,6 @@ type IgloohomeConfigEntry = ConfigEntry[IgloohomeRuntimeData]
async def async_setup_entry(hass: HomeAssistant, entry: IgloohomeConfigEntry) -> bool:
"""Set up igloohome from a config entry."""
-
authentication = IgloohomeAuth(
session=async_get_clientsession(hass),
client_id=entry.data[CONF_CLIENT_ID],
diff --git a/homeassistant/components/igloohome/lock.py b/homeassistant/components/igloohome/lock.py
new file mode 100644
index 00000000000..b434c055145
--- /dev/null
+++ b/homeassistant/components/igloohome/lock.py
@@ -0,0 +1,91 @@
+"""Implementation of the lock platform."""
+
+from datetime import timedelta
+
+from aiohttp import ClientError
+from igloohome_api import (
+ BRIDGE_JOB_LOCK,
+ BRIDGE_JOB_UNLOCK,
+ DEVICE_TYPE_LOCK,
+ Api as IgloohomeApi,
+ ApiException,
+ GetDeviceInfoResponse,
+)
+
+from homeassistant.components.lock import LockEntity, LockEntityFeature
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import IgloohomeConfigEntry
+from .entity import IgloohomeBaseEntity
+from .utils import get_linked_bridge
+
+# Scan interval set to allow Lock entity update the bridge linked to it.
+SCAN_INTERVAL = timedelta(hours=1)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: IgloohomeConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up lock entities."""
+ async_add_entities(
+ IgloohomeLockEntity(
+ api_device_info=device,
+ api=entry.runtime_data.api,
+ bridge_id=str(bridge),
+ )
+ for device in entry.runtime_data.devices
+ if device.type == DEVICE_TYPE_LOCK
+ and (bridge := get_linked_bridge(device.deviceId, entry.runtime_data.devices))
+ is not None
+ )
+
+
+class IgloohomeLockEntity(IgloohomeBaseEntity, LockEntity):
+ """Implementation of a device that has locking capabilities."""
+
+ # Operating on assumed state because there is no API to query the state.
+ _attr_assumed_state = True
+ _attr_supported_features = LockEntityFeature.OPEN
+ _attr_name = None
+
+ def __init__(
+ self, api_device_info: GetDeviceInfoResponse, api: IgloohomeApi, bridge_id: str
+ ) -> None:
+ """Initialize the class."""
+ super().__init__(
+ api_device_info=api_device_info,
+ api=api,
+ unique_key="lock",
+ )
+ self.bridge_id = bridge_id
+
+ async def async_lock(self, **kwargs):
+ """Lock this lock."""
+ try:
+ await self.api.create_bridge_proxied_job(
+ self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_LOCK
+ )
+ except (ApiException, ClientError) as err:
+ raise HomeAssistantError from err
+
+ async def async_unlock(self, **kwargs):
+ """Unlock this lock."""
+ try:
+ await self.api.create_bridge_proxied_job(
+ self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_UNLOCK
+ )
+ except (ApiException, ClientError) as err:
+ raise HomeAssistantError from err
+
+ async def async_open(self, **kwargs):
+ """Open (unlatch) this lock."""
+ try:
+ await self.api.create_bridge_proxied_job(
+ self.api_device_info.deviceId, self.bridge_id, BRIDGE_JOB_UNLOCK
+ )
+ except (ApiException, ClientError) as err:
+ raise HomeAssistantError from err
diff --git a/homeassistant/components/igloohome/sensor.py b/homeassistant/components/igloohome/sensor.py
index 7f25798e454..10a9ece6771 100644
--- a/homeassistant/components/igloohome/sensor.py
+++ b/homeassistant/components/igloohome/sensor.py
@@ -8,7 +8,7 @@ from igloohome_api import Api as IgloohomeApi, ApiException, GetDeviceInfoRespon
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IgloohomeConfigEntry
from .entity import IgloohomeBaseEntity
@@ -22,7 +22,7 @@ SCAN_INTERVAL = timedelta(hours=1)
async def async_setup_entry(
hass: HomeAssistant,
entry: IgloohomeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor entities."""
diff --git a/homeassistant/components/igloohome/utils.py b/homeassistant/components/igloohome/utils.py
new file mode 100644
index 00000000000..be17912b8b8
--- /dev/null
+++ b/homeassistant/components/igloohome/utils.py
@@ -0,0 +1,16 @@
+"""House utility functions."""
+
+from igloohome_api import DEVICE_TYPE_BRIDGE, GetDeviceInfoResponse
+
+
+def get_linked_bridge(
+ device_id: str, devices: list[GetDeviceInfoResponse]
+) -> str | None:
+ """Return the ID of the bridge that is linked to the device. None if no bridge is linked."""
+ bridges = (bridge for bridge in devices if bridge.type == DEVICE_TYPE_BRIDGE)
+ for bridge in bridges:
+ if device_id in (
+ linked_device.deviceId for linked_device in bridge.linkedDevices
+ ):
+ return bridge.deviceId
+ return None
diff --git a/homeassistant/components/ihc/entity.py b/homeassistant/components/ihc/entity.py
index f90b2ee943c..8847ffc9f49 100644
--- a/homeassistant/components/ihc/entity.py
+++ b/homeassistant/components/ihc/entity.py
@@ -54,7 +54,7 @@ class IHCEntity(Entity):
self.ihc_note = ""
self.ihc_position = ""
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Add callback for IHC changes."""
_LOGGER.debug("Adding IHC entity notify event: %s", self.ihc_id)
self.ihc_controller.add_notify_event(self.ihc_id, self.on_ihc_change, True)
diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json
index e43377a3230..bc01476d509 100644
--- a/homeassistant/components/image_upload/manifest.json
+++ b/homeassistant/components/image_upload/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/image_upload",
"integration_type": "system",
"quality_scale": "internal",
- "requirements": ["Pillow==11.1.0"]
+ "requirements": ["Pillow==11.2.1"]
}
diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py
index 74f7a86c0d6..34d3f43eb69 100644
--- a/homeassistant/components/imap/coordinator.py
+++ b/homeassistant/components/imap/coordinator.py
@@ -280,7 +280,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
if self.custom_event_template is not None:
try:
data["custom"] = self.custom_event_template.async_render(
- data, parse_result=True
+ data | {"text": message.text}, parse_result=True
)
_LOGGER.debug(
"IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s",
diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py
index 60892388252..01009e3d17b 100644
--- a/homeassistant/components/imap/sensor.py
+++ b/homeassistant/components/imap/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import CONF_USERNAME, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import ImapConfigEntry
@@ -30,7 +30,9 @@ IMAP_MAIL_COUNT_DESCRIPTION = SensorEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ImapConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ImapConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Imap sensor."""
diff --git a/homeassistant/components/imeon_inverter/__init__.py b/homeassistant/components/imeon_inverter/__init__.py
new file mode 100644
index 00000000000..0676731f375
--- /dev/null
+++ b/homeassistant/components/imeon_inverter/__init__.py
@@ -0,0 +1,31 @@
+"""Initialize the Imeon component."""
+
+from __future__ import annotations
+
+import logging
+
+from homeassistant.core import HomeAssistant
+
+from .const import PLATFORMS
+from .coordinator import InverterConfigEntry, InverterCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: InverterConfigEntry) -> bool:
+ """Handle the creation of a new config entry for the integration (asynchronous)."""
+
+ # Create the corresponding HUB
+ coordinator = InverterCoordinator(hass, entry)
+ await coordinator.async_config_entry_first_refresh()
+ entry.runtime_data = coordinator
+
+ # Call for HUB creation then each entity as a List
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: InverterConfigEntry) -> bool:
+ """Handle entry unloading."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/imeon_inverter/config_flow.py b/homeassistant/components/imeon_inverter/config_flow.py
new file mode 100644
index 00000000000..fadb2c65446
--- /dev/null
+++ b/homeassistant/components/imeon_inverter/config_flow.py
@@ -0,0 +1,114 @@
+"""Config flow for Imeon integration."""
+
+import logging
+from typing import Any
+from urllib.parse import urlparse
+
+from imeon_inverter_api.inverter import Inverter
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers.service_info.ssdp import (
+ ATTR_UPNP_MODEL_NUMBER,
+ ATTR_UPNP_SERIAL,
+ SsdpServiceInfo,
+)
+from homeassistant.helpers.typing import VolDictType
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class ImeonInverterConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle the initial setup flow for Imeon Inverters."""
+
+ _host: str | None = None
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the user step for creating a new configuration entry."""
+
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ # User have to provide the hostname if device is not discovered
+ host = self._host or user_input[CONF_HOST]
+
+ async with Inverter(host) as client:
+ try:
+ # Check connection
+ if await client.login(
+ user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
+ ):
+ serial = await client.get_serial()
+
+ else:
+ errors["base"] = "invalid_auth"
+
+ except TimeoutError:
+ errors["base"] = "cannot_connect"
+
+ except ValueError as e:
+ if "Host invalid" in str(e):
+ errors["base"] = "invalid_host"
+
+ elif "Route invalid" in str(e):
+ errors["base"] = "invalid_route"
+
+ else:
+ errors["base"] = "unknown"
+ _LOGGER.exception(
+ "Unexpected error occurred while connecting to the Imeon"
+ )
+
+ if not errors:
+ # Check if entry already exists
+ await self.async_set_unique_id(serial, raise_on_progress=False)
+ self._abort_if_unique_id_configured()
+
+ # Create a new configuration entry if login succeeds
+ return self.async_create_entry(
+ title=f"Imeon {serial}", data={CONF_HOST: host, **user_input}
+ )
+
+ host_schema: VolDictType = (
+ {vol.Required(CONF_HOST): str} if not self._host else {}
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ **host_schema,
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+ ),
+ errors=errors,
+ )
+
+ async def async_step_ssdp(
+ self, discovery_info: SsdpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle a SSDP discovery."""
+
+ host = str(urlparse(discovery_info.ssdp_location).hostname)
+ serial = discovery_info.upnp.get(ATTR_UPNP_SERIAL, "")
+
+ if not serial:
+ return self.async_abort(reason="cannot_connect")
+
+ await self.async_set_unique_id(serial)
+ self._abort_if_unique_id_configured(updates={CONF_HOST: host})
+
+ self._host = host
+
+ self.context["title_placeholders"] = {
+ "model": discovery_info.upnp.get(ATTR_UPNP_MODEL_NUMBER, ""),
+ "serial": serial,
+ }
+
+ return await self.async_step_user()
diff --git a/homeassistant/components/imeon_inverter/const.py b/homeassistant/components/imeon_inverter/const.py
new file mode 100644
index 00000000000..c71a8c72d11
--- /dev/null
+++ b/homeassistant/components/imeon_inverter/const.py
@@ -0,0 +1,9 @@
+"""Constant for Imeon component."""
+
+from homeassistant.const import Platform
+
+DOMAIN = "imeon_inverter"
+TIMEOUT = 20
+PLATFORMS = [
+ Platform.SENSOR,
+]
diff --git a/homeassistant/components/imeon_inverter/coordinator.py b/homeassistant/components/imeon_inverter/coordinator.py
new file mode 100644
index 00000000000..8342240b9ff
--- /dev/null
+++ b/homeassistant/components/imeon_inverter/coordinator.py
@@ -0,0 +1,97 @@
+"""Coordinator for Imeon integration."""
+
+from __future__ import annotations
+
+from asyncio import timeout
+from datetime import timedelta
+import logging
+
+from aiohttp import ClientError
+from imeon_inverter_api.inverter import Inverter
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import TIMEOUT
+
+HUBNAME = "imeon_inverter_hub"
+INTERVAL = timedelta(seconds=60)
+_LOGGER = logging.getLogger(__name__)
+
+type InverterConfigEntry = ConfigEntry[InverterCoordinator]
+
+
+# HUB CREATION #
+class InverterCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]):
+ """Each inverter is it's own HUB, thus it's own data set.
+
+ This allows this integration to handle as many
+ inverters as possible in parallel.
+ """
+
+ config_entry: InverterConfigEntry
+
+ # Implement methods to fetch and update data
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: InverterConfigEntry,
+ ) -> None:
+ """Initialize data update coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=HUBNAME,
+ update_interval=INTERVAL,
+ config_entry=entry,
+ )
+
+ self._api = Inverter(entry.data[CONF_HOST])
+
+ @property
+ def api(self) -> Inverter:
+ """Return the inverter object."""
+ return self._api
+
+ async def _async_setup(self) -> None:
+ """Set up the coordinator."""
+ async with timeout(TIMEOUT):
+ await self._api.login(
+ self.config_entry.data[CONF_USERNAME],
+ self.config_entry.data[CONF_PASSWORD],
+ )
+
+ await self._api.init()
+
+ async def _async_update_data(self) -> dict[str, str | float | int]:
+ """Fetch and store newest data from API.
+
+ This is the place to where entities can get their data.
+ It also includes the login process.
+ """
+
+ data: dict[str, str | float | int] = {}
+
+ async with timeout(TIMEOUT):
+ await self._api.login(
+ self.config_entry.data[CONF_USERNAME],
+ self.config_entry.data[CONF_PASSWORD],
+ )
+
+ # Fetch data using distant API
+ try:
+ await self._api.update()
+ except (ValueError, ClientError) as e:
+ raise UpdateFailed(e) from e
+
+ # Store data
+ for key, val in self._api.storage.items():
+ if key == "timeline":
+ data[key] = val
+ else:
+ for sub_key, sub_val in val.items():
+ data[f"{key}_{sub_key}"] = sub_val
+
+ return data
diff --git a/homeassistant/components/imeon_inverter/icons.json b/homeassistant/components/imeon_inverter/icons.json
new file mode 100644
index 00000000000..1c74cf4c745
--- /dev/null
+++ b/homeassistant/components/imeon_inverter/icons.json
@@ -0,0 +1,159 @@
+{
+ "entity": {
+ "sensor": {
+ "battery_autonomy": {
+ "default": "mdi:battery-clock"
+ },
+ "battery_charge_time": {
+ "default": "mdi:battery-charging"
+ },
+ "battery_power": {
+ "default": "mdi:battery"
+ },
+ "battery_soc": {
+ "default": "mdi:battery-charging-100"
+ },
+ "battery_stored": {
+ "default": "mdi:battery"
+ },
+ "grid_current_l1": {
+ "default": "mdi:current-ac"
+ },
+ "grid_current_l2": {
+ "default": "mdi:current-ac"
+ },
+ "grid_current_l3": {
+ "default": "mdi:current-ac"
+ },
+ "grid_frequency": {
+ "default": "mdi:sine-wave"
+ },
+ "grid_voltage_l1": {
+ "default": "mdi:flash"
+ },
+ "grid_voltage_l2": {
+ "default": "mdi:flash"
+ },
+ "grid_voltage_l3": {
+ "default": "mdi:flash"
+ },
+ "input_power_l1": {
+ "default": "mdi:power-socket"
+ },
+ "input_power_l2": {
+ "default": "mdi:power-socket"
+ },
+ "input_power_l3": {
+ "default": "mdi:power-socket"
+ },
+ "input_power_total": {
+ "default": "mdi:power-plug"
+ },
+ "inverter_charging_current_limit": {
+ "default": "mdi:current-dc"
+ },
+ "inverter_injection_power_limit": {
+ "default": "mdi:power-socket"
+ },
+ "meter_power": {
+ "default": "mdi:power-plug"
+ },
+ "meter_power_protocol": {
+ "default": "mdi:protocol"
+ },
+ "output_current_l1": {
+ "default": "mdi:current-ac"
+ },
+ "output_current_l2": {
+ "default": "mdi:current-ac"
+ },
+ "output_current_l3": {
+ "default": "mdi:current-ac"
+ },
+ "output_frequency": {
+ "default": "mdi:sine-wave"
+ },
+ "output_power_l1": {
+ "default": "mdi:power-socket"
+ },
+ "output_power_l2": {
+ "default": "mdi:power-socket"
+ },
+ "output_power_l3": {
+ "default": "mdi:power-socket"
+ },
+ "output_power_total": {
+ "default": "mdi:power-plug"
+ },
+ "output_voltage_l1": {
+ "default": "mdi:flash"
+ },
+ "output_voltage_l2": {
+ "default": "mdi:flash"
+ },
+ "output_voltage_l3": {
+ "default": "mdi:flash"
+ },
+ "pv_consumed": {
+ "default": "mdi:solar-power"
+ },
+ "pv_injected": {
+ "default": "mdi:solar-power"
+ },
+ "pv_power_1": {
+ "default": "mdi:solar-power"
+ },
+ "pv_power_2": {
+ "default": "mdi:solar-power"
+ },
+ "pv_power_total": {
+ "default": "mdi:solar-power"
+ },
+ "temp_air_temperature": {
+ "default": "mdi:thermometer"
+ },
+ "temp_component_temperature": {
+ "default": "mdi:thermometer"
+ },
+ "monitoring_building_consumption": {
+ "default": "mdi:home-lightning-bolt"
+ },
+ "monitoring_economy_factor": {
+ "default": "mdi:chart-bar"
+ },
+ "monitoring_grid_consumption": {
+ "default": "mdi:transmission-tower"
+ },
+ "monitoring_grid_injection": {
+ "default": "mdi:transmission-tower-export"
+ },
+ "monitoring_grid_power_flow": {
+ "default": "mdi:power-plug"
+ },
+ "monitoring_self_consumption": {
+ "default": "mdi:percent"
+ },
+ "monitoring_self_sufficiency": {
+ "default": "mdi:percent"
+ },
+ "monitoring_solar_production": {
+ "default": "mdi:solar-power"
+ },
+ "monitoring_minute_building_consumption": {
+ "default": "mdi:home-lightning-bolt"
+ },
+ "monitoring_minute_grid_consumption": {
+ "default": "mdi:transmission-tower"
+ },
+ "monitoring_minute_grid_injection": {
+ "default": "mdi:transmission-tower-export"
+ },
+ "monitoring_minute_grid_power_flow": {
+ "default": "mdi:power-plug"
+ },
+ "monitoring_minute_solar_production": {
+ "default": "mdi:solar-power"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/imeon_inverter/manifest.json b/homeassistant/components/imeon_inverter/manifest.json
new file mode 100644
index 00000000000..1398521dc45
--- /dev/null
+++ b/homeassistant/components/imeon_inverter/manifest.json
@@ -0,0 +1,18 @@
+{
+ "domain": "imeon_inverter",
+ "name": "Imeon Inverter",
+ "codeowners": ["@Imeon-Energy"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/imeon_inverter",
+ "integration_type": "device",
+ "iot_class": "local_polling",
+ "quality_scale": "bronze",
+ "requirements": ["imeon_inverter_api==0.3.12"],
+ "ssdp": [
+ {
+ "manufacturer": "IMEON",
+ "deviceType": "urn:schemas-upnp-org:device:Basic:1",
+ "st": "upnp:rootdevice"
+ }
+ ]
+}
diff --git a/homeassistant/components/imeon_inverter/quality_scale.yaml b/homeassistant/components/imeon_inverter/quality_scale.yaml
new file mode 100644
index 00000000000..6e364977697
--- /dev/null
+++ b/homeassistant/components/imeon_inverter/quality_scale.yaml
@@ -0,0 +1,71 @@
+rules:
+ # Bronze
+ config-flow: done
+ test-before-configure: done
+ unique-config-entry: done
+ config-flow-test-coverage: done
+ runtime-data: done
+ test-before-setup: done
+ appropriate-polling: done
+ entity-unique-id: done
+ has-entity-name: done
+ entity-event-setup:
+ status: exempt
+ comment: This integration doesn't have sensors that subscribe to events.
+ dependency-transparency: done
+ action-setup:
+ status: exempt
+ comment: This integration does not have any service for now.
+ common-modules: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-actions:
+ status: exempt
+ comment: This integration does not have any service for now.
+ brands: done
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: This integration does not have any service for now.
+ config-entry-unloading: todo
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: done
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: done
+ discovery: done
+ 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:
+ status: exempt
+ comment: Device type integration.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: Currently no issues.
+ stale-devices:
+ status: exempt
+ comment: Device type integration.
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/imeon_inverter/sensor.py b/homeassistant/components/imeon_inverter/sensor.py
new file mode 100644
index 00000000000..b7a01c3cf17
--- /dev/null
+++ b/homeassistant/components/imeon_inverter/sensor.py
@@ -0,0 +1,464 @@
+"""Imeon inverter sensor support."""
+
+import logging
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ PERCENTAGE,
+ EntityCategory,
+ UnitOfElectricCurrent,
+ UnitOfElectricPotential,
+ UnitOfEnergy,
+ UnitOfFrequency,
+ UnitOfPower,
+ UnitOfTemperature,
+ UnitOfTime,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import InverterCoordinator
+
+type InverterConfigEntry = ConfigEntry[InverterCoordinator]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+ENTITY_DESCRIPTIONS = (
+ # Battery
+ SensorEntityDescription(
+ key="battery_autonomy",
+ translation_key="battery_autonomy",
+ native_unit_of_measurement=UnitOfTime.HOURS,
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="battery_charge_time",
+ translation_key="battery_charge_time",
+ native_unit_of_measurement=UnitOfTime.HOURS,
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ key="battery_power",
+ translation_key="battery_power",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="battery_soc",
+ translation_key="battery_soc",
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.BATTERY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="battery_stored",
+ translation_key="battery_stored",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY_STORAGE,
+ state_class=SensorStateClass.TOTAL,
+ ),
+ # Grid
+ SensorEntityDescription(
+ key="grid_current_l1",
+ translation_key="grid_current_l1",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="grid_current_l2",
+ translation_key="grid_current_l2",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="grid_current_l3",
+ translation_key="grid_current_l3",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="grid_frequency",
+ translation_key="grid_frequency",
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ device_class=SensorDeviceClass.FREQUENCY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="grid_voltage_l1",
+ translation_key="grid_voltage_l1",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="grid_voltage_l2",
+ translation_key="grid_voltage_l2",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="grid_voltage_l3",
+ translation_key="grid_voltage_l3",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ # AC Input
+ SensorEntityDescription(
+ key="input_power_l1",
+ translation_key="input_power_l1",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="input_power_l2",
+ translation_key="input_power_l2",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="input_power_l3",
+ translation_key="input_power_l3",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="input_power_total",
+ translation_key="input_power_total",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ # Inverter settings
+ SensorEntityDescription(
+ key="inverter_charging_current_limit",
+ translation_key="inverter_charging_current_limit",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="inverter_injection_power_limit",
+ translation_key="inverter_injection_power_limit",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ # Meter
+ SensorEntityDescription(
+ key="meter_power",
+ translation_key="meter_power",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="meter_power_protocol",
+ translation_key="meter_power_protocol",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ # AC Output
+ SensorEntityDescription(
+ key="output_current_l1",
+ translation_key="output_current_l1",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="output_current_l2",
+ translation_key="output_current_l2",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="output_current_l3",
+ translation_key="output_current_l3",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="output_frequency",
+ translation_key="output_frequency",
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ device_class=SensorDeviceClass.FREQUENCY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="output_power_l1",
+ translation_key="output_power_l1",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="output_power_l2",
+ translation_key="output_power_l2",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="output_power_l3",
+ translation_key="output_power_l3",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="output_power_total",
+ translation_key="output_power_total",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="output_voltage_l1",
+ translation_key="output_voltage_l1",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="output_voltage_l2",
+ translation_key="output_voltage_l2",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="output_voltage_l3",
+ translation_key="output_voltage_l3",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ # Solar Panel
+ SensorEntityDescription(
+ key="pv_consumed",
+ translation_key="pv_consumed",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ key="pv_injected",
+ translation_key="pv_injected",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ ),
+ SensorEntityDescription(
+ key="pv_power_1",
+ translation_key="pv_power_1",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="pv_power_2",
+ translation_key="pv_power_2",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="pv_power_total",
+ translation_key="pv_power_total",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ # Temperature
+ SensorEntityDescription(
+ key="temp_air_temperature",
+ translation_key="temp_air_temperature",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key="temp_component_temperature",
+ translation_key="temp_component_temperature",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ # Monitoring (data over the last 24 hours)
+ SensorEntityDescription(
+ key="monitoring_building_consumption",
+ translation_key="monitoring_building_consumption",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="monitoring_economy_factor",
+ translation_key="monitoring_economy_factor",
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ state_class=SensorStateClass.TOTAL,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="monitoring_grid_consumption",
+ translation_key="monitoring_grid_consumption",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="monitoring_grid_injection",
+ translation_key="monitoring_grid_injection",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="monitoring_grid_power_flow",
+ translation_key="monitoring_grid_power_flow",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="monitoring_self_consumption",
+ translation_key="monitoring_self_consumption",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="monitoring_self_sufficiency",
+ translation_key="monitoring_self_sufficiency",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="monitoring_solar_production",
+ translation_key="monitoring_solar_production",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ suggested_display_precision=2,
+ ),
+ # Monitoring (instant minute data)
+ SensorEntityDescription(
+ key="monitoring_minute_building_consumption",
+ translation_key="monitoring_minute_building_consumption",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="monitoring_minute_grid_consumption",
+ translation_key="monitoring_minute_grid_consumption",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="monitoring_minute_grid_injection",
+ translation_key="monitoring_minute_grid_injection",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="monitoring_minute_grid_power_flow",
+ translation_key="monitoring_minute_grid_power_flow",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=2,
+ ),
+ SensorEntityDescription(
+ key="monitoring_minute_solar_production",
+ translation_key="monitoring_minute_solar_production",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=2,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: InverterConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Create each sensor for a given config entry."""
+
+ coordinator = entry.runtime_data
+
+ # Init sensor entities
+ async_add_entities(
+ InverterSensor(coordinator, entry, description)
+ for description in ENTITY_DESCRIPTIONS
+ )
+
+
+class InverterSensor(CoordinatorEntity[InverterCoordinator], SensorEntity):
+ """A sensor that returns numerical values with units."""
+
+ _attr_has_entity_name = True
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+
+ def __init__(
+ self,
+ coordinator: InverterCoordinator,
+ entry: InverterConfigEntry,
+ description: SensorEntityDescription,
+ ) -> None:
+ """Pass coordinator to CoordinatorEntity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._inverter = coordinator.api.inverter
+ self.data_key = description.key
+ assert entry.unique_id
+ self._attr_unique_id = f"{entry.unique_id}_{self.data_key}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, entry.unique_id)},
+ name="Imeon inverter",
+ manufacturer="Imeon Energy",
+ model=self._inverter.get("inverter"),
+ sw_version=self._inverter.get("software"),
+ )
+
+ @property
+ def native_value(self) -> StateType | None:
+ """Value of the sensor."""
+ return self.coordinator.data.get(self.data_key)
diff --git a/homeassistant/components/imeon_inverter/strings.json b/homeassistant/components/imeon_inverter/strings.json
new file mode 100644
index 00000000000..48604e01273
--- /dev/null
+++ b/homeassistant/components/imeon_inverter/strings.json
@@ -0,0 +1,187 @@
+{
+ "config": {
+ "flow_title": "Imeon {model} ({serial})",
+ "step": {
+ "user": {
+ "title": "Add Imeon inverter",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "host": "The hostname or IP of your inverter",
+ "username": "The username of your OS One account",
+ "password": "The password of your OS One account"
+ }
+ }
+ },
+ "abort": {
+ "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_host": "[%key:common::config_flow::error::invalid_host%]",
+ "invalid_route": "Unable to request the API, make sure 'API Module' is enabled on your device",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "battery_autonomy": {
+ "name": "Battery autonomy"
+ },
+ "battery_charge_time": {
+ "name": "Battery charge time"
+ },
+ "battery_power": {
+ "name": "Battery power"
+ },
+ "battery_soc": {
+ "name": "Battery state of charge"
+ },
+ "battery_stored": {
+ "name": "Battery stored"
+ },
+ "grid_current_l1": {
+ "name": "Grid current L1"
+ },
+ "grid_current_l2": {
+ "name": "Grid current L2"
+ },
+ "grid_current_l3": {
+ "name": "Grid current L3"
+ },
+ "grid_frequency": {
+ "name": "Grid frequency"
+ },
+ "grid_voltage_l1": {
+ "name": "Grid voltage L1"
+ },
+ "grid_voltage_l2": {
+ "name": "Grid voltage L2"
+ },
+ "grid_voltage_l3": {
+ "name": "Grid voltage L3"
+ },
+ "input_power_l1": {
+ "name": "Input power L1"
+ },
+ "input_power_l2": {
+ "name": "Input power L2"
+ },
+ "input_power_l3": {
+ "name": "Input power L3"
+ },
+ "input_power_total": {
+ "name": "Input power total"
+ },
+ "inverter_charging_current_limit": {
+ "name": "Charging current limit"
+ },
+ "inverter_injection_power_limit": {
+ "name": "Injection power limit"
+ },
+ "meter_power": {
+ "name": "Meter power"
+ },
+ "meter_power_protocol": {
+ "name": "Meter power protocol"
+ },
+ "output_current_l1": {
+ "name": "Output current L1"
+ },
+ "output_current_l2": {
+ "name": "Output current L2"
+ },
+ "output_current_l3": {
+ "name": "Output current L3"
+ },
+ "output_frequency": {
+ "name": "Output frequency"
+ },
+ "output_power_l1": {
+ "name": "Output power L1"
+ },
+ "output_power_l2": {
+ "name": "Output power L2"
+ },
+ "output_power_l3": {
+ "name": "Output power L3"
+ },
+ "output_power_total": {
+ "name": "Output power total"
+ },
+ "output_voltage_l1": {
+ "name": "Output voltage L1"
+ },
+ "output_voltage_l2": {
+ "name": "Output voltage L2"
+ },
+ "output_voltage_l3": {
+ "name": "Output voltage L3"
+ },
+ "pv_consumed": {
+ "name": "PV consumed"
+ },
+ "pv_injected": {
+ "name": "PV injected"
+ },
+ "pv_power_1": {
+ "name": "PV power 1"
+ },
+ "pv_power_2": {
+ "name": "PV power 2"
+ },
+ "pv_power_total": {
+ "name": "PV power total"
+ },
+ "temp_air_temperature": {
+ "name": "Air temperature"
+ },
+ "temp_component_temperature": {
+ "name": "Component temperature"
+ },
+ "monitoring_building_consumption": {
+ "name": "Monitoring building consumption"
+ },
+ "monitoring_economy_factor": {
+ "name": "Monitoring economy factor"
+ },
+ "monitoring_grid_consumption": {
+ "name": "Monitoring grid consumption"
+ },
+ "monitoring_grid_injection": {
+ "name": "Monitoring grid injection"
+ },
+ "monitoring_grid_power_flow": {
+ "name": "Monitoring grid power flow"
+ },
+ "monitoring_self_consumption": {
+ "name": "Monitoring self consumption"
+ },
+ "monitoring_self_sufficiency": {
+ "name": "Monitoring self sufficiency"
+ },
+ "monitoring_solar_production": {
+ "name": "Monitoring solar production"
+ },
+ "monitoring_minute_building_consumption": {
+ "name": "Monitoring building consumption (minute)"
+ },
+ "monitoring_minute_grid_consumption": {
+ "name": "Monitoring grid consumption (minute)"
+ },
+ "monitoring_minute_grid_injection": {
+ "name": "Monitoring grid injection (minute)"
+ },
+ "monitoring_minute_grid_power_flow": {
+ "name": "Monitoring grid power flow (minute)"
+ },
+ "monitoring_minute_solar_production": {
+ "name": "Monitoring solar production (minute)"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py
index f9524316570..4bceee51f8e 100644
--- a/homeassistant/components/imgw_pib/__init__.py
+++ b/homeassistant/components/imgw_pib/__init__.py
@@ -38,7 +38,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> b
hydrological_details=False,
)
except (ClientError, TimeoutError, ApiError) as err:
- raise ConfigEntryNotReady from err
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={
+ "entry": entry.title,
+ "error": repr(err),
+ },
+ ) from err
coordinator = ImgwPibDataUpdateCoordinator(hass, entry, imgwpib, station_id)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/imgw_pib/config_flow.py b/homeassistant/components/imgw_pib/config_flow.py
index 558528fcbef..805bfa2ccb3 100644
--- a/homeassistant/components/imgw_pib/config_flow.py
+++ b/homeassistant/components/imgw_pib/config_flow.py
@@ -50,7 +50,7 @@ class ImgwPibFlowHandler(ConfigFlow, domain=DOMAIN):
hydrological_data = await imgwpib.get_hydrological_data()
except (ClientError, TimeoutError, ApiError):
errors["base"] = "cannot_connect"
- except Exception: # pylint: disable=broad-except
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
diff --git a/homeassistant/components/imgw_pib/coordinator.py b/homeassistant/components/imgw_pib/coordinator.py
index fbe470ca953..f74878d672c 100644
--- a/homeassistant/components/imgw_pib/coordinator.py
+++ b/homeassistant/components/imgw_pib/coordinator.py
@@ -63,4 +63,11 @@ class ImgwPibDataUpdateCoordinator(DataUpdateCoordinator[HydrologicalData]):
try:
return await self.imgwpib.get_hydrological_data()
except ApiError as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ translation_placeholders={
+ "entry": self.config_entry.title,
+ "error": repr(err),
+ },
+ ) from err
diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json
index 0ecc1b4b7d0..e2d6e2bf584 100644
--- a/homeassistant/components/imgw_pib/manifest.json
+++ b/homeassistant/components/imgw_pib/manifest.json
@@ -5,5 +5,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/imgw_pib",
"iot_class": "cloud_polling",
- "requirements": ["imgw_pib==1.0.9"]
+ "quality_scale": "silver",
+ "requirements": ["imgw_pib==1.0.10"]
}
diff --git a/homeassistant/components/imgw_pib/quality_scale.yaml b/homeassistant/components/imgw_pib/quality_scale.yaml
new file mode 100644
index 00000000000..6634c915255
--- /dev/null
+++ b/homeassistant/components/imgw_pib/quality_scale.yaml
@@ -0,0 +1,88 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: The integration does not register services.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: The integration does not register services.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ 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: The integration does not register services.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options to configure.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: No authentication required.
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: The integration is a cloud service and thus does not support discovery.
+ discovery:
+ status: exempt
+ comment: The integration is a cloud service and thus does not support discovery.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices:
+ status: exempt
+ comment: This is a service, which doesn't integrate with any devices.
+ docs-supported-functions: todo
+ docs-troubleshooting:
+ status: exempt
+ comment: No known issues that could be resolved by the user.
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: This integration has a fixed single service.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: This integration does not have any entities that should disabled by default.
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow:
+ status: exempt
+ comment: Only parameter that could be changed station_id would force a new config entry.
+ repair-issues:
+ status: exempt
+ comment: This integration doesn't have any cases where raising an issue is needed.
+ stale-devices:
+ status: exempt
+ comment: This integration has a fixed single service.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py
index 332c3bcedf8..7871006b2ae 100644
--- a/homeassistant/components/imgw_pib/sensor.py
+++ b/homeassistant/components/imgw_pib/sensor.py
@@ -17,14 +17,15 @@ from homeassistant.components.sensor import (
from homeassistant.const import UnitOfLength, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .coordinator import ImgwPibConfigEntry, ImgwPibDataUpdateCoordinator
from .entity import ImgwPibEntity
-PARALLEL_UPDATES = 1
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -59,7 +60,7 @@ SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ImgwPibConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a IMGW-PIB sensor entity from a config_entry."""
coordinator = entry.runtime_data.coordinator
diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json
index 9a17dcf7087..33cd3cb3917 100644
--- a/homeassistant/components/imgw_pib/strings.json
+++ b/homeassistant/components/imgw_pib/strings.json
@@ -4,6 +4,9 @@
"user": {
"data": {
"station_id": "Hydrological station"
+ },
+ "data_description": {
+ "station_id": "Select a hydrological station from the list."
}
}
},
@@ -25,5 +28,13 @@
"name": "Water temperature"
}
}
+ },
+ "exceptions": {
+ "cannot_connect": {
+ "message": "An error occurred while connecting to the IMGW-PIB API for {entry}: {error}"
+ },
+ "update_error": {
+ "message": "An error occurred while retrieving data from the IMGW-PIB API for {entry}: {error}"
+ }
}
}
diff --git a/homeassistant/components/improv_ble/__init__.py b/homeassistant/components/improv_ble/__init__.py
index 985684cb5b8..ff40b65a8d0 100644
--- a/homeassistant/components/improv_ble/__init__.py
+++ b/homeassistant/components/improv_ble/__init__.py
@@ -1 +1,11 @@
"""The Improv BLE integration."""
+
+from __future__ import annotations
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up improv_ble from a config entry."""
+ raise NotImplementedError
diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py
index 22f2bf3623c..0dcefba6428 100644
--- a/homeassistant/components/improv_ble/config_flow.py
+++ b/homeassistant/components/improv_ble/config_flow.py
@@ -83,12 +83,9 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovery_info = self._discovered_devices[address]
return await self.async_step_start_improv()
- current_addresses = self._async_current_ids()
for discovery in bluetooth.async_discovered_service_info(self.hass):
- if (
- discovery.address in current_addresses
- or discovery.address in self._discovered_devices
- or not device_filter(discovery.advertisement)
+ if discovery.address in self._discovered_devices or not device_filter(
+ discovery.advertisement
):
continue
self._discovered_devices[discovery.address] = discovery
@@ -364,6 +361,18 @@ class ImprovBLEConfigFlow(ConfigFlow, domain=DOMAIN):
assert self._provision_result is not None
result = self._provision_result
+ if result["type"] == "abort" and result["reason"] in (
+ "provision_successful",
+ "provision_successful_url",
+ ):
+ # Delete ignored config entry, if it exists
+ address = self.context["unique_id"]
+ current_entries = self._async_current_entries(include_ignore=True)
+ for entry in current_entries:
+ if entry.unique_id == address:
+ _LOGGER.debug("Removing ignored entry: %s", entry)
+ await self.hass.config_entries.async_remove(entry.entry_id)
+ break
self._provision_result = None
return result
diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py
index 323ba7e6eee..356cee82e57 100644
--- a/homeassistant/components/incomfort/binary_sensor.py
+++ b/homeassistant/components/incomfort/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import InComfortConfigEntry, InComfortDataCoordinator
from .entity import IncomfortBoilerEntity
@@ -70,7 +70,7 @@ SENSOR_TYPES: tuple[IncomfortBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: InComfortConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an InComfort/InTouch binary_sensor entity."""
incomfort_coordinator = entry.runtime_data
diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py
index 3a4b4e56fd5..c10cbe5be5b 100644
--- a/homeassistant/components/incomfort/climate.py
+++ b/homeassistant/components/incomfort/climate.py
@@ -12,10 +12,10 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.const import ATTR_TEMPERATURE, EntityCategory, UnitOfTemperature
+from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
from .coordinator import InComfortConfigEntry, InComfortDataCoordinator
@@ -27,7 +27,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: InComfortConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up InComfort/InTouch climate devices."""
incomfort_coordinator = entry.runtime_data
@@ -43,7 +43,6 @@ async def async_setup_entry(
class InComfortClimate(IncomfortEntity, ClimateEntity):
"""Representation of an InComfort/InTouch climate device."""
- _attr_entity_category = EntityCategory.CONFIG
_attr_min_temp = 5.0
_attr_max_temp = 30.0
_attr_name = None
diff --git a/homeassistant/components/incomfort/config_flow.py b/homeassistant/components/incomfort/config_flow.py
index 875bc25bd2f..027c3ad4691 100644
--- a/homeassistant/components/incomfort/config_flow.py
+++ b/homeassistant/components/incomfort/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
+import logging
from typing import Any
from incomfortclient import InvalidGateway, InvalidHeaterList
@@ -31,6 +32,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_LEGACY_SETPOINT_STATUS, DOMAIN
from .coordinator import InComfortConfigEntry, async_connect_gateway
+_LOGGER = logging.getLogger(__name__)
TITLE = "Intergas InComfort/Intouch Lan2RF gateway"
CONFIG_SCHEMA = vol.Schema(
@@ -88,7 +90,8 @@ async def async_try_connect_gateway(
return {"base": "no_heaters"}
except TimeoutError:
return {"base": "timeout_error"}
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
return {"base": "unknown"}
return None
diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json
index d02b1d27554..825f198dd30 100644
--- a/homeassistant/components/incomfort/manifest.json
+++ b/homeassistant/components/incomfort/manifest.json
@@ -10,5 +10,6 @@
"documentation": "https://www.home-assistant.io/integrations/incomfort",
"iot_class": "local_polling",
"loggers": ["incomfortclient"],
+ "quality_scale": "platinum",
"requirements": ["incomfort-client==0.6.7"]
}
diff --git a/homeassistant/components/incomfort/quality_scale.yaml b/homeassistant/components/incomfort/quality_scale.yaml
new file mode 100644
index 00000000000..f5af3c9d061
--- /dev/null
+++ b/homeassistant/components/incomfort/quality_scale.yaml
@@ -0,0 +1,77 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No actions implemented.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No actions implemented.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: done
+ comment: |
+ Entities are set up dand updated through the datacoordimator.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions: done
+ reauthentication-flow: done
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters: done
+
+ # Gold
+ entity-translations: done
+ entity-device-class: done
+ devices: done
+ entity-category: done
+ entity-disabled-by-default: done
+ discovery: done
+ stale-devices:
+ status: exempt
+ comment: >
+ There is a maximum of 3 heaters that can be discovered by the gateway.
+ The user must remove manually any heeater devices that have been replaced.
+ diagnostics: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ dynamic-devices: done
+ discovery-update-info: done
+ repair-issues:
+ status: exempt
+ comment: |
+ No current issues to repair.
+ docs-use-cases: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations:
+ status: done
+ comment: There are no known limmitations,
+ docs-troubleshooting: done
+ docs-examples: done
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py
index 8507e9f9ebf..e344fb01aae 100644
--- a/homeassistant/components/incomfort/sensor.py
+++ b/homeassistant/components/incomfort/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory, UnitOfPressure, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import InComfortConfigEntry, InComfortDataCoordinator
@@ -67,7 +67,7 @@ SENSOR_TYPES: tuple[IncomfortSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: InComfortConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up InComfort/InTouch sensor entities."""
incomfort_coordinator = entry.runtime_data
diff --git a/homeassistant/components/incomfort/strings.json b/homeassistant/components/incomfort/strings.json
index 15e28b6e0b9..6a07849b01d 100644
--- a/homeassistant/components/incomfort/strings.json
+++ b/homeassistant/components/incomfort/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "description": "Set up new Intergas gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.",
+ "description": "Set up new Intergas gateway. Note that some older systems might not accept credentials to be set up. For newer devices authentication is required.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"username": "[%key:common::config_flow::data::username%]",
@@ -10,8 +10,8 @@
},
"data_description": {
"host": "Hostname or IP-address of the Intergas gateway.",
- "username": "The username to log into the gateway. This is `admin` in most cases.",
- "password": "The password to log into the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices."
+ "username": "The username to log in to the gateway. This is `admin` in most cases.",
+ "password": "The password to log in to the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices."
}
},
"dhcp_auth": {
@@ -22,8 +22,8 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "username": "The username to log into the gateway. This is `admin` in most cases.",
- "password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices."
+ "username": "[%key:component::incomfort::config::step::user::data_description::username%]",
+ "password": "[%key:component::incomfort::config::step::user::data_description::password%]"
}
},
"dhcp_confirm": {
@@ -118,7 +118,7 @@
"tapwater_int": "Tap water internal",
"sensor_test": "Sensor test",
"central_heating": "Central heating",
- "standby": "Stand-by",
+ "standby": "[%key:common::state::standby%]",
"postrun_boyler": "Post run boiler",
"service": "Service",
"tapwater": "Tap water",
diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py
index 334fc187538..2a2c7cc47da 100644
--- a/homeassistant/components/incomfort/water_heater.py
+++ b/homeassistant/components/incomfort/water_heater.py
@@ -10,7 +10,7 @@ from incomfortclient import Heater as InComfortHeater
from homeassistant.components.water_heater import WaterHeaterEntity
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import InComfortConfigEntry, InComfortDataCoordinator
from .entity import IncomfortBoilerEntity
@@ -25,7 +25,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: InComfortConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an InComfort/InTouch water_heater device."""
incomfort_coordinator = entry.runtime_data
diff --git a/homeassistant/components/inkbird/__init__.py b/homeassistant/components/inkbird/__init__.py
index c715c64599a..8daa94f2f6d 100644
--- a/homeassistant/components/inkbird/__init__.py
+++ b/homeassistant/components/inkbird/__init__.py
@@ -2,49 +2,36 @@
from __future__ import annotations
-import logging
+from typing import Any
-from inkbird_ble import INKBIRDBluetoothDeviceData
-
-from homeassistant.components.bluetooth import BluetoothScanningMode
-from homeassistant.components.bluetooth.passive_update_processor import (
- PassiveBluetoothProcessorCoordinator,
-)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
+from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE
+from .coordinator import INKBIRDActiveBluetoothProcessorCoordinator
+
+INKBIRDConfigEntry = ConfigEntry[INKBIRDActiveBluetoothProcessorCoordinator]
PLATFORMS: list[Platform] = [Platform.SENSOR]
-_LOGGER = logging.getLogger(__name__)
-
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> bool:
"""Set up INKBIRD BLE device from a config entry."""
- address = entry.unique_id
- assert address is not None
- data = INKBIRDBluetoothDeviceData()
- coordinator = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = (
- PassiveBluetoothProcessorCoordinator(
- hass,
- _LOGGER,
- address=address,
- mode=BluetoothScanningMode.ACTIVE,
- update_method=data.update,
- )
+ assert entry.unique_id is not None
+ device_type: str | None = entry.data.get(CONF_DEVICE_TYPE)
+ device_data: dict[str, Any] | None = entry.data.get(CONF_DEVICE_DATA)
+ coordinator = INKBIRDActiveBluetoothProcessorCoordinator(
+ hass, entry, device_type, device_data
)
+ await coordinator.async_init()
+ entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(
- coordinator.async_start()
- ) # only start after all platforms have had a chance to subscribe
+ # only start after all platforms have had a chance to subscribe
+ entry.async_on_unload(coordinator.async_start())
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: INKBIRDConfigEntry) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/inkbird/config_flow.py b/homeassistant/components/inkbird/config_flow.py
index 09dd31a9cf6..9ce20baaeda 100644
--- a/homeassistant/components/inkbird/config_flow.py
+++ b/homeassistant/components/inkbird/config_flow.py
@@ -14,7 +14,7 @@ from homeassistant.components.bluetooth import (
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
-from .const import DOMAIN
+from .const import CONF_DEVICE_TYPE, DOMAIN
class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -26,7 +26,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_device: DeviceData | None = None
- self._discovered_devices: dict[str, str] = {}
+ self._discovered_devices: dict[str, tuple[str, str]] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
@@ -51,7 +51,10 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
discovery_info = self._discovery_info
title = device.title or device.get_device_name() or discovery_info.name
if user_input is not None:
- return self.async_create_entry(title=title, data={})
+ return self.async_create_entry(
+ title=title,
+ data={CONF_DEVICE_TYPE: str(self._discovered_device.device_type)},
+ )
self._set_confirm_only()
placeholders = {"name": title}
@@ -68,8 +71,9 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
+ title, device_type = self._discovered_devices[address]
return self.async_create_entry(
- title=self._discovered_devices[address], data={}
+ title=title, data={CONF_DEVICE_TYPE: device_type}
)
current_addresses = self._async_current_ids(include_ignore=False)
@@ -80,7 +84,8 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
device = DeviceData()
if device.supported(discovery_info):
self._discovered_devices[address] = (
- device.title or device.get_device_name() or discovery_info.name
+ device.title or device.get_device_name() or discovery_info.name,
+ str(device.device_type),
)
if not self._discovered_devices:
diff --git a/homeassistant/components/inkbird/const.py b/homeassistant/components/inkbird/const.py
index 9d0e1638958..b20e1af8de1 100644
--- a/homeassistant/components/inkbird/const.py
+++ b/homeassistant/components/inkbird/const.py
@@ -1,3 +1,6 @@
"""Constants for the INKBIRD Bluetooth integration."""
DOMAIN = "inkbird"
+
+CONF_DEVICE_TYPE = "device_type"
+CONF_DEVICE_DATA = "device_data"
diff --git a/homeassistant/components/inkbird/coordinator.py b/homeassistant/components/inkbird/coordinator.py
new file mode 100644
index 00000000000..d52ebd83595
--- /dev/null
+++ b/homeassistant/components/inkbird/coordinator.py
@@ -0,0 +1,135 @@
+"""The INKBIRD Bluetooth integration."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+import logging
+from typing import Any
+
+from inkbird_ble import INKBIRDBluetoothDeviceData, SensorUpdate
+
+from homeassistant.components.bluetooth import (
+ BluetoothScanningMode,
+ BluetoothServiceInfo,
+ BluetoothServiceInfoBleak,
+ async_ble_device_from_address,
+ async_last_service_info,
+)
+from homeassistant.components.bluetooth.active_update_processor import (
+ ActiveBluetoothProcessorCoordinator,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.event import async_track_time_interval
+
+from .const import CONF_DEVICE_DATA, CONF_DEVICE_TYPE, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+FALLBACK_POLL_INTERVAL = timedelta(seconds=180)
+
+
+class INKBIRDActiveBluetoothProcessorCoordinator(
+ ActiveBluetoothProcessorCoordinator[SensorUpdate]
+):
+ """Coordinator for INKBIRD Bluetooth devices."""
+
+ _data: INKBIRDBluetoothDeviceData
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ device_type: str | None,
+ device_data: dict[str, Any] | None,
+ ) -> None:
+ """Initialize the INKBIRD Bluetooth processor coordinator."""
+ self._entry = entry
+ self._device_type = device_type
+ self._device_data = device_data
+ address = entry.unique_id
+ assert address is not None
+ super().__init__(
+ hass=hass,
+ logger=_LOGGER,
+ address=address,
+ mode=BluetoothScanningMode.ACTIVE,
+ update_method=self._async_on_update,
+ needs_poll_method=self._async_needs_poll,
+ poll_method=self._async_poll_data,
+ )
+
+ async def async_init(self) -> None:
+ """Initialize the coordinator."""
+ self._data = INKBIRDBluetoothDeviceData(
+ self._device_type,
+ self._device_data,
+ self.async_set_updated_data,
+ self._async_device_data_changed,
+ )
+ if not self._data.uses_notify:
+ self._entry.async_on_unload(
+ async_track_time_interval(
+ self.hass, self._async_schedule_poll, FALLBACK_POLL_INTERVAL
+ )
+ )
+ return
+ if not (service_info := async_last_service_info(self.hass, self.address)):
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="no_advertisement",
+ translation_placeholders={"address": self.address},
+ )
+ await self._data.async_start(service_info, service_info.device)
+ self._entry.async_on_unload(self._data.async_stop)
+
+ async def _async_poll_data(
+ self, last_service_info: BluetoothServiceInfoBleak
+ ) -> SensorUpdate:
+ """Poll the device."""
+ return await self._data.async_poll(last_service_info.device)
+
+ @callback
+ def _async_needs_poll(
+ self, service_info: BluetoothServiceInfoBleak, last_poll: float | None
+ ) -> bool:
+ return (
+ not self.hass.is_stopping
+ and self._data.poll_needed(service_info, last_poll)
+ and bool(
+ async_ble_device_from_address(
+ self.hass, service_info.device.address, connectable=True
+ )
+ )
+ )
+
+ @callback
+ def _async_device_data_changed(self, new_device_data: dict[str, Any]) -> None:
+ """Handle device data changed."""
+ self.hass.config_entries.async_update_entry(
+ self._entry, data={**self._entry.data, CONF_DEVICE_DATA: new_device_data}
+ )
+
+ @callback
+ def _async_on_update(self, service_info: BluetoothServiceInfo) -> SensorUpdate:
+ """Handle update callback from the passive BLE processor."""
+ update = self._data.update(service_info)
+ if (
+ self._entry.data.get(CONF_DEVICE_TYPE) is None
+ and self._data.device_type is not None
+ ):
+ device_type_str = str(self._data.device_type)
+ self.hass.config_entries.async_update_entry(
+ self._entry,
+ data={**self._entry.data, CONF_DEVICE_TYPE: device_type_str},
+ )
+ return update
+
+ @callback
+ def _async_schedule_poll(self, _: datetime) -> None:
+ """Schedule a poll of the device."""
+ if self._last_service_info and self._async_needs_poll(
+ self._last_service_info, self._last_poll
+ ):
+ self._debounced_poll.async_schedule_call()
diff --git a/homeassistant/components/inkbird/manifest.json b/homeassistant/components/inkbird/manifest.json
index c1922004317..76296870846 100644
--- a/homeassistant/components/inkbird/manifest.json
+++ b/homeassistant/components/inkbird/manifest.json
@@ -21,6 +21,27 @@
{
"local_name": "tps",
"connectable": false
+ },
+ {
+ "local_name": "ITH-11-B",
+ "connectable": false
+ },
+ {
+ "local_name": "ITH-13-B",
+ "connectable": false
+ },
+ {
+ "local_name": "ITH-21-B",
+ "connectable": false
+ },
+ {
+ "local_name": "Ink@IAM-T1",
+ "connectable": true
+ },
+ {
+ "manufacturer_id": 12628,
+ "manufacturer_data_start": [65, 67, 45],
+ "connectable": true
}
],
"codeowners": ["@bdraco"],
@@ -28,5 +49,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/inkbird",
"iot_class": "local_push",
- "requirements": ["inkbird-ble==0.5.8"]
+ "requirements": ["inkbird-ble==0.13.0"]
}
diff --git a/homeassistant/components/inkbird/sensor.py b/homeassistant/components/inkbird/sensor.py
index 05b2ebbafa0..c7d80e9bc9f 100644
--- a/homeassistant/components/inkbird/sensor.py
+++ b/homeassistant/components/inkbird/sensor.py
@@ -4,12 +4,10 @@ from __future__ import annotations
from inkbird_ble import DeviceClass, DeviceKey, SensorUpdate, Units
-from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothEntityKey,
- PassiveBluetoothProcessorCoordinator,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
@@ -19,15 +17,17 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
+ CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ UnitOfPressure,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
-from .const import DOMAIN
+from . import INKBIRDConfigEntry
SENSOR_DESCRIPTIONS = {
(DeviceClass.TEMPERATURE, Units.TEMP_CELSIUS): SensorEntityDescription(
@@ -58,6 +58,18 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
+ (DeviceClass.CO2, Units.CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription(
+ key=f"{DeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}",
+ device_class=SensorDeviceClass.CO2,
+ native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ (DeviceClass.PRESSURE, Units.PRESSURE_HPA): SensorEntityDescription(
+ key=f"{DeviceClass.PRESSURE}_{Units.PRESSURE_HPA}",
+ device_class=SensorDeviceClass.PRESSURE,
+ native_unit_of_measurement=UnitOfPressure.HPA,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
}
@@ -97,20 +109,17 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
- entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: INKBIRDConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the INKBIRD BLE sensors."""
- coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
- entry.entry_id
- ]
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
entry.async_on_unload(
processor.async_add_entities_listener(
INKBIRDBluetoothSensorEntity, async_add_entities
)
)
- entry.async_on_unload(coordinator.async_register_processor(processor))
+ entry.async_on_unload(entry.runtime_data.async_register_processor(processor))
class INKBIRDBluetoothSensorEntity(
diff --git a/homeassistant/components/inkbird/strings.json b/homeassistant/components/inkbird/strings.json
index 4e12a84b653..b8490dfb92a 100644
--- a/homeassistant/components/inkbird/strings.json
+++ b/homeassistant/components/inkbird/strings.json
@@ -17,5 +17,10 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
+ },
+ "exceptions": {
+ "no_advertisement": {
+ "message": "The device with address {address} is not advertising; Make sure it is in range and powered on."
+ }
}
}
diff --git a/homeassistant/components/input_select/strings.json b/homeassistant/components/input_select/strings.json
index faa47c979a1..72fd50f7ec7 100644
--- a/homeassistant/components/input_select/strings.json
+++ b/homeassistant/components/input_select/strings.json
@@ -20,7 +20,7 @@
"services": {
"select_next": {
"name": "Next",
- "description": "Select the next option.",
+ "description": "Selects the next option.",
"fields": {
"cycle": {
"name": "Cycle",
@@ -44,7 +44,7 @@
"fields": {
"cycle": {
"name": "[%key:component::input_select::services::select_next::fields::cycle::name%]",
- "description": "[%key:component::input_select::services::select_next::fields::cycle::description%]"
+ "description": "If the option should cycle from the first to the last option on the list."
}
}
},
diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py
index abb26b7f8e8..887c8fb64a3 100644
--- a/homeassistant/components/insteon/binary_sensor.py
+++ b/homeassistant/components/insteon/binary_sensor.py
@@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SIGNAL_ADD_ENTITIES
from .entity import InsteonEntity
@@ -46,7 +46,7 @@ SENSOR_TYPES = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Insteon binary sensors from a config entry."""
diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py
index 506841e7efb..eb33e3ab88c 100644
--- a/homeassistant/components/insteon/climate.py
+++ b/homeassistant/components/insteon/climate.py
@@ -20,7 +20,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SIGNAL_ADD_ENTITIES
from .entity import InsteonEntity
@@ -55,7 +55,7 @@ FAN_MODES = {4: FAN_AUTO, 8: FAN_ONLY}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Insteon climate entities from a config entry."""
diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py
index fe4f484798d..679ee67a1de 100644
--- a/homeassistant/components/insteon/cover.py
+++ b/homeassistant/components/insteon/cover.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SIGNAL_ADD_ENTITIES
from .entity import InsteonEntity
@@ -22,7 +22,7 @@ from .utils import async_add_insteon_devices, async_add_insteon_entities
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Insteon covers from a config entry."""
diff --git a/homeassistant/components/insteon/entity.py b/homeassistant/components/insteon/entity.py
index 79e5c18a934..b7886723fdf 100644
--- a/homeassistant/components/insteon/entity.py
+++ b/homeassistant/components/insteon/entity.py
@@ -109,7 +109,7 @@ class InsteonEntity(Entity):
)
self.async_write_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register INSTEON update events."""
_LOGGER.debug(
"Tracking updates for device %s group %d name %s",
@@ -137,7 +137,7 @@ class InsteonEntity(Entity):
)
)
- async def async_will_remove_from_hass(self):
+ async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe to INSTEON update events."""
_LOGGER.debug(
"Remove tracking updates for device %s group %d name %s",
diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py
index 0f1c70b9ea8..f4e0abf3d54 100644
--- a/homeassistant/components/insteon/fan.py
+++ b/homeassistant/components/insteon/fan.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -26,7 +26,7 @@ SPEED_RANGE = (1, 255) # off is not included
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Insteon fans from a config entry."""
diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py
index d19f3cca34a..e4f09fe5689 100644
--- a/homeassistant/components/insteon/light.py
+++ b/homeassistant/components/insteon/light.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SIGNAL_ADD_ENTITIES
from .entity import InsteonEntity
@@ -22,7 +22,7 @@ MAX_BRIGHTNESS = 255
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Insteon lights from a config entry."""
diff --git a/homeassistant/components/insteon/lock.py b/homeassistant/components/insteon/lock.py
index d5f30eacbac..787b0c583cc 100644
--- a/homeassistant/components/insteon/lock.py
+++ b/homeassistant/components/insteon/lock.py
@@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SIGNAL_ADD_ENTITIES
from .entity import InsteonEntity
@@ -17,7 +17,7 @@ from .utils import async_add_insteon_devices, async_add_insteon_entities
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Insteon locks from a config entry."""
diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json
index 4a8aadb70db..3a15d667ca7 100644
--- a/homeassistant/components/insteon/strings.json
+++ b/homeassistant/components/insteon/strings.json
@@ -111,7 +111,7 @@
},
"services": {
"add_all_link": {
- "name": "Add all link",
+ "name": "Add All-Link",
"description": "Tells the Insteon Modem (IM) start All-Linking mode. Once the IM is in All-Linking mode, press the link button on the device to complete All-Linking.",
"fields": {
"group": {
@@ -120,13 +120,13 @@
},
"mode": {
"name": "[%key:common::config_flow::data::mode%]",
- "description": "Linking mode controller - IM is controller responder - IM is responder."
+ "description": "Linking mode of the Insteon Modem."
}
}
},
"delete_all_link": {
- "name": "Delete all link",
- "description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link Database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.",
+ "name": "Delete All-Link",
+ "description": "Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.",
"fields": {
"group": {
"name": "Group",
@@ -135,8 +135,8 @@
}
},
"load_all_link_database": {
- "name": "Load all link database",
- "description": "Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.",
+ "name": "Load All-Link database",
+ "description": "Loads the All-Link database for a device. WARNING - Loading a device All-Link database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -144,13 +144,13 @@
},
"reload": {
"name": "[%key:common::action::reload%]",
- "description": "Reloads all records. If true the current records are cleared from memory (does not effect the device) and the records are reloaded. If false the existing records are left in place and only missing records are added. Default is false."
+ "description": "If enabled, all current records are cleared from memory (does not effect the device) and reloaded. Otherwise the existing records are left in place and only missing records are added."
}
}
},
"print_all_link_database": {
- "name": "Print all link database",
- "description": "Prints the All-Link Database for a device. Requires that the All-Link Database is loaded into memory.",
+ "name": "Print All-Link database",
+ "description": "Prints the All-Link database for a device. Requires that the All-Link database is loaded into memory.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -159,8 +159,8 @@
}
},
"print_im_all_link_database": {
- "name": "Print IM all link database",
- "description": "Prints the All-Link Database for the INSTEON Modem (IM)."
+ "name": "Print IM All-Link database",
+ "description": "Prints the All-Link database for the INSTEON Modem (IM)."
},
"x10_all_units_off": {
"name": "X10 all units off",
diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py
index 67ce5fa8c0d..e3f7cf3d7a9 100644
--- a/homeassistant/components/insteon/switch.py
+++ b/homeassistant/components/insteon/switch.py
@@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SIGNAL_ADD_ENTITIES
from .entity import InsteonEntity
@@ -17,7 +17,7 @@ from .utils import async_add_insteon_devices, async_add_insteon_entities
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Insteon switches from a config entry."""
diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py
index 5b1d6379328..4ee859934d2 100644
--- a/homeassistant/components/insteon/utils.py
+++ b/homeassistant/components/insteon/utils.py
@@ -43,7 +43,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_CAT,
@@ -415,7 +415,7 @@ def async_add_insteon_entities(
hass: HomeAssistant,
platform: Platform,
entity_type: type[InsteonEntity],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
discovery_info: dict[str, Any],
) -> None:
"""Add an Insteon group to a platform."""
@@ -432,7 +432,7 @@ def async_add_insteon_devices(
hass: HomeAssistant,
platform: Platform,
entity_type: type[InsteonEntity],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add all entities to a platform."""
for address in devices:
diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py
index 27aa74d0785..df5342111a7 100644
--- a/homeassistant/components/integration/sensor.py
+++ b/homeassistant/components/integration/sensor.py
@@ -42,7 +42,10 @@ from homeassistant.core import (
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import (
async_call_later,
async_track_state_change_event,
@@ -234,7 +237,7 @@ class IntegrationSensorExtraStoredData(SensorExtraStoredData):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Integration - Riemann sum integral config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/intellifire/binary_sensor.py b/homeassistant/components/intellifire/binary_sensor.py
index 7d00bdfc26d..3da1d2e3dc0 100644
--- a/homeassistant/components/intellifire/binary_sensor.py
+++ b/homeassistant/components/intellifire/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IntellifireDataUpdateCoordinator
from .const import DOMAIN
@@ -152,7 +152,7 @@ INTELLIFIRE_BINARY_SENSORS: tuple[IntellifireBinarySensorEntityDescription, ...]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a IntelliFire On/Off Sensor."""
coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/intellifire/climate.py b/homeassistant/components/intellifire/climate.py
index f72df254424..f067f2a849d 100644
--- a/homeassistant/components/intellifire/climate.py
+++ b/homeassistant/components/intellifire/climate.py
@@ -13,7 +13,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IntellifireDataUpdateCoordinator
from .const import DEFAULT_THERMOSTAT_TEMP, DOMAIN, LOGGER
@@ -27,7 +27,7 @@ INTELLIFIRE_CLIMATES: tuple[ClimateEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Configure the fan entry.."""
coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/intellifire/fan.py b/homeassistant/components/intellifire/fan.py
index c5bec07faaa..174d964d357 100644
--- a/homeassistant/components/intellifire/fan.py
+++ b/homeassistant/components/intellifire/fan.py
@@ -17,7 +17,7 @@ from homeassistant.components.fan import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -58,7 +58,7 @@ INTELLIFIRE_FANS: tuple[IntellifireFanEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the fans."""
coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/intellifire/light.py b/homeassistant/components/intellifire/light.py
index 5f25b5de823..0cf5c7774ed 100644
--- a/homeassistant/components/intellifire/light.py
+++ b/homeassistant/components/intellifire/light.py
@@ -17,7 +17,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LOGGER
from .coordinator import IntellifireDataUpdateCoordinator
@@ -85,7 +85,7 @@ class IntellifireLight(IntellifireEntity, LightEntity):
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the fans."""
coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/intellifire/number.py b/homeassistant/components/intellifire/number.py
index 17ed3b7bd27..0776835833e 100644
--- a/homeassistant/components/intellifire/number.py
+++ b/homeassistant/components/intellifire/number.py
@@ -11,7 +11,7 @@ from homeassistant.components.number import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LOGGER
from .coordinator import IntellifireDataUpdateCoordinator
@@ -21,7 +21,7 @@ from .entity import IntellifireEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the fans."""
coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/intellifire/sensor.py b/homeassistant/components/intellifire/sensor.py
index eaff89d08e7..7763fb1b9b2 100644
--- a/homeassistant/components/intellifire/sensor.py
+++ b/homeassistant/components/intellifire/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from .const import DOMAIN
@@ -141,7 +141,9 @@ INTELLIFIRE_SENSORS: tuple[IntellifireSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Define setup entry call."""
diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py
index ac6096497b6..2185ad47cae 100644
--- a/homeassistant/components/intellifire/switch.py
+++ b/homeassistant/components/intellifire/switch.py
@@ -9,7 +9,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IntellifireDataUpdateCoordinator
from .const import DOMAIN
@@ -53,7 +53,7 @@ INTELLIFIRE_SWITCHES: tuple[IntellifireSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Configure switch entities."""
coordinator: IntellifireDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py
index a1451f8fcca..922fa376903 100644
--- a/homeassistant/components/intent/__init__.py
+++ b/homeassistant/components/intent/__init__.py
@@ -2,13 +2,15 @@
from __future__ import annotations
+from collections.abc import Collection
import logging
from typing import Any, Protocol
from aiohttp import web
import voluptuous as vol
-from homeassistant.components import http
+from homeassistant.components import http, sensor
+from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
from homeassistant.components.cover import (
ATTR_POSITION,
DOMAIN as COVER_DOMAIN,
@@ -39,7 +41,12 @@ from homeassistant.const import (
SERVICE_TURN_ON,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, State
-from homeassistant.helpers import config_validation as cv, integration_platform, intent
+from homeassistant.helpers import (
+ area_registry as ar,
+ config_validation as cv,
+ integration_platform,
+ intent,
+)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
@@ -140,6 +147,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
intent.async_register(hass, GetCurrentDateIntentHandler())
intent.async_register(hass, GetCurrentTimeIntentHandler())
intent.async_register(hass, RespondIntentHandler())
+ intent.async_register(hass, GetTemperatureIntent())
return True
@@ -444,6 +452,109 @@ class RespondIntentHandler(intent.IntentHandler):
return response
+class GetTemperatureIntent(intent.IntentHandler):
+ """Handle GetTemperature intents."""
+
+ intent_type = intent.INTENT_GET_TEMPERATURE
+ description = "Gets the current temperature of a climate device or entity"
+ slot_schema = {
+ vol.Optional("area"): intent.non_empty_string,
+ vol.Optional("name"): intent.non_empty_string,
+ vol.Optional("floor"): intent.non_empty_string,
+ vol.Optional("preferred_area_id"): cv.string,
+ vol.Optional("preferred_floor_id"): cv.string,
+ }
+ platforms = {CLIMATE_DOMAIN}
+
+ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
+ """Handle the intent."""
+ hass = intent_obj.hass
+ slots = self.async_validate_slots(intent_obj.slots)
+
+ name: str | None = None
+ if "name" in slots:
+ name = slots["name"]["value"]
+
+ area: str | None = None
+ if "area" in slots:
+ area = slots["area"]["value"]
+
+ floor_name: str | None = None
+ if "floor" in slots:
+ floor_name = slots["floor"]["value"]
+
+ match_preferences = intent.MatchTargetsPreferences(
+ area_id=slots.get("preferred_area_id", {}).get("value"),
+ floor_id=slots.get("preferred_floor_id", {}).get("value"),
+ )
+
+ if (not name) and (area or match_preferences.area_id):
+ # Look for temperature sensors assigned to an area
+ area_registry = ar.async_get(hass)
+ area_temperature_ids: dict[str, str] = {}
+
+ # Keep candidates that are registered as area temperature sensors
+ def area_candidate_filter(
+ candidate: intent.MatchTargetsCandidate,
+ possible_area_ids: Collection[str],
+ ) -> bool:
+ for area_id in possible_area_ids:
+ temperature_id = area_temperature_ids.get(area_id)
+ if (temperature_id is None) and (
+ area_entry := area_registry.async_get_area(area_id)
+ ):
+ temperature_id = area_entry.temperature_entity_id or ""
+ area_temperature_ids[area_id] = temperature_id
+
+ if candidate.state.entity_id == temperature_id:
+ return True
+
+ return False
+
+ match_constraints = intent.MatchTargetsConstraints(
+ area_name=area,
+ floor_name=floor_name,
+ domains=[sensor.DOMAIN],
+ device_classes=[sensor.SensorDeviceClass.TEMPERATURE],
+ assistant=intent_obj.assistant,
+ single_target=True,
+ )
+ match_result = intent.async_match_targets(
+ hass,
+ match_constraints,
+ match_preferences,
+ area_candidate_filter=area_candidate_filter,
+ )
+ if match_result.is_match:
+ # Found temperature sensor
+ response = intent_obj.create_response()
+ response.response_type = intent.IntentResponseType.QUERY_ANSWER
+ response.async_set_states(matched_states=match_result.states)
+ return response
+
+ # Look for climate devices
+ match_constraints = intent.MatchTargetsConstraints(
+ name=name,
+ area_name=area,
+ floor_name=floor_name,
+ domains=[CLIMATE_DOMAIN],
+ assistant=intent_obj.assistant,
+ single_target=True,
+ )
+ match_result = intent.async_match_targets(
+ hass, match_constraints, match_preferences
+ )
+ if not match_result.is_match:
+ raise intent.MatchFailedError(
+ result=match_result, constraints=match_constraints
+ )
+
+ response = intent_obj.create_response()
+ response.response_type = intent.IntentResponseType.QUERY_ANSWER
+ response.async_set_states(matched_states=match_result.states)
+ return response
+
+
async def _async_process_intent(
hass: HomeAssistant, domain: str, platform: IntentPlatformProtocol
) -> None:
diff --git a/homeassistant/components/iometer/__init__.py b/homeassistant/components/iometer/__init__.py
index bbf046e70e9..feb7ce9b8cf 100644
--- a/homeassistant/components/iometer/__init__.py
+++ b/homeassistant/components/iometer/__init__.py
@@ -12,7 +12,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import IOmeterConfigEntry, IOMeterCoordinator
-PLATFORMS: list[Platform] = [Platform.SENSOR]
+PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: IOmeterConfigEntry) -> bool:
diff --git a/homeassistant/components/iometer/binary_sensor.py b/homeassistant/components/iometer/binary_sensor.py
new file mode 100644
index 00000000000..f443c4ae94a
--- /dev/null
+++ b/homeassistant/components/iometer/binary_sensor.py
@@ -0,0 +1,87 @@
+"""IOmeter binary sensor."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import IOMeterCoordinator, IOmeterData
+from .entity import IOmeterEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class IOmeterBinarySensorDescription(BinarySensorEntityDescription):
+ """Describes Iometer binary sensor entity."""
+
+ value_fn: Callable[[IOmeterData], bool | None]
+
+
+SENSOR_TYPES: list[IOmeterBinarySensorDescription] = [
+ IOmeterBinarySensorDescription(
+ key="connection_status",
+ translation_key="connection_status",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ entity_registry_enabled_default=False,
+ value_fn=lambda data: (
+ data.status.device.core.connection_status == "connected"
+ if data.status.device.core.connection_status is not None
+ else None
+ ),
+ ),
+ IOmeterBinarySensorDescription(
+ key="attachment_status",
+ translation_key="attachment_status",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ entity_registry_enabled_default=False,
+ value_fn=lambda data: (
+ data.status.device.core.attachment_status == "attached"
+ if data.status.device.core.attachment_status is not None
+ else None
+ ),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Sensors."""
+ coordinator: IOMeterCoordinator = config_entry.runtime_data
+
+ async_add_entities(
+ IOmeterBinarySensor(
+ coordinator=coordinator,
+ description=description,
+ )
+ for description in SENSOR_TYPES
+ )
+
+
+class IOmeterBinarySensor(IOmeterEntity, BinarySensorEntity):
+ """Defines a IOmeter binary sensor."""
+
+ entity_description: IOmeterBinarySensorDescription
+
+ def __init__(
+ self,
+ coordinator: IOMeterCoordinator,
+ description: IOmeterBinarySensorDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.identifier}_{description.key}"
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return the binary sensor state."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/iometer/coordinator.py b/homeassistant/components/iometer/coordinator.py
index 708983fb28e..4050341151b 100644
--- a/homeassistant/components/iometer/coordinator.py
+++ b/homeassistant/components/iometer/coordinator.py
@@ -8,6 +8,7 @@ from iometer import IOmeterClient, IOmeterConnectionError, Reading, Status
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -31,6 +32,7 @@ class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]):
config_entry: IOmeterConfigEntry
client: IOmeterClient
+ current_fw_version: str = ""
def __init__(
self,
@@ -58,4 +60,17 @@ class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]):
except IOmeterConnectionError as error:
raise UpdateFailed(f"Error communicating with IOmeter: {error}") from error
+ fw_version = f"{status.device.core.version}/{status.device.bridge.version}"
+ if self.current_fw_version and fw_version != self.current_fw_version:
+ device_registry = dr.async_get(self.hass)
+ device_entry = device_registry.async_get_device(
+ identifiers={(DOMAIN, status.device.id)}
+ )
+ assert device_entry
+ device_registry.async_update_device(
+ device_entry.id,
+ sw_version=fw_version,
+ )
+ self.current_fw_version = fw_version
+
return IOmeterData(reading=reading, status=status)
diff --git a/homeassistant/components/iometer/entity.py b/homeassistant/components/iometer/entity.py
index 86494857e18..a52ef1c66ed 100644
--- a/homeassistant/components/iometer/entity.py
+++ b/homeassistant/components/iometer/entity.py
@@ -20,5 +20,5 @@ class IOmeterEntity(CoordinatorEntity[IOMeterCoordinator]):
identifiers={(DOMAIN, status.device.id)},
manufacturer="IOmeter GmbH",
model="IOmeter",
- sw_version=f"{status.device.core.version}/{status.device.bridge.version}",
+ sw_version=coordinator.current_fw_version,
)
diff --git a/homeassistant/components/iometer/sensor.py b/homeassistant/components/iometer/sensor.py
index 7d4c1155e8b..3dff3cc6ea9 100644
--- a/homeassistant/components/iometer/sensor.py
+++ b/homeassistant/components/iometer/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import IOMeterCoordinator, IOmeterData
@@ -111,7 +111,7 @@ SENSOR_TYPES: list[IOmeterEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Sensors."""
coordinator: IOMeterCoordinator = config_entry.runtime_data
diff --git a/homeassistant/components/iometer/strings.json b/homeassistant/components/iometer/strings.json
index 31deb16aa9c..6e149354eee 100644
--- a/homeassistant/components/iometer/strings.json
+++ b/homeassistant/components/iometer/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "description": "Setup your IOmeter device for local data",
+ "description": "Set up your IOmeter device for local data",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
@@ -60,6 +60,14 @@
"wifi_rssi": {
"name": "Signal strength Wi-Fi"
}
+ },
+ "binary_sensor": {
+ "connection_status": {
+ "name": "Core/Bridge connection status"
+ },
+ "attachment_status": {
+ "name": "Core attachment status"
+ }
}
}
}
diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py
index a97c2145919..a3c9876a884 100644
--- a/homeassistant/components/ios/sensor.py
+++ b/homeassistant/components/ios/sensor.py
@@ -14,7 +14,10 @@ from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.icon import icon_for_battery_level
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -64,7 +67,7 @@ def setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up iOS from a config entry."""
async_add_entities(
diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py
index c9af588c160..f5210f7fbba 100644
--- a/homeassistant/components/iotawatt/sensor.py
+++ b/homeassistant/components/iotawatt/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -114,7 +114,7 @@ ENTITY_DESCRIPTION_KEY_MAP: dict[str, IotaWattSensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add sensors for passed config_entry in HA."""
coordinator: IotawattUpdater = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/iotty/cover.py b/homeassistant/components/iotty/cover.py
index 31d363868db..d8b11131f4f 100644
--- a/homeassistant/components/iotty/cover.py
+++ b/homeassistant/components/iotty/cover.py
@@ -16,7 +16,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .api import IottyProxy
from .coordinator import IottyConfigEntry, IottyDataUpdateCoordinator
@@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: IottyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Activate the iotty Shutter component."""
_LOGGER.debug("Setup COVER entry id is %s", config_entry.entry_id)
diff --git a/homeassistant/components/iotty/switch.py b/homeassistant/components/iotty/switch.py
index a748ac10783..113a4439e85 100644
--- a/homeassistant/components/iotty/switch.py
+++ b/homeassistant/components/iotty/switch.py
@@ -20,7 +20,7 @@ from homeassistant.components.switch import (
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .api import IottyProxy
from .coordinator import IottyConfigEntry, IottyDataUpdateCoordinator
@@ -45,7 +45,7 @@ ENTITIES: dict[str, SwitchEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: IottyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Activate the iotty Switch component."""
_LOGGER.debug("Setup SWITCH entry id is %s", config_entry.entry_id)
diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json
index 1abd7807213..971525e013f 100644
--- a/homeassistant/components/ipma/manifest.json
+++ b/homeassistant/components/ipma/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ipma",
"iot_class": "cloud_polling",
"loggers": ["geopy", "pyipma"],
- "requirements": ["pyipma==3.0.8"]
+ "requirements": ["pyipma==3.0.9"]
}
diff --git a/homeassistant/components/ipma/sensor.py b/homeassistant/components/ipma/sensor.py
index 2a921cdbb04..78fd018cf9a 100644
--- a/homeassistant/components/ipma/sensor.py
+++ b/homeassistant/components/ipma/sensor.py
@@ -16,7 +16,7 @@ from pyipma.uv import UV
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from .const import DATA_API, DATA_LOCATION, DOMAIN, MIN_TIME_BETWEEN_UPDATES
@@ -86,7 +86,9 @@ SENSOR_TYPES: tuple[IPMASensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the IPMA sensor platform."""
api = hass.data[DOMAIN][entry.entry_id][DATA_API]
diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py
index 855587eee2e..d285f9e1ad3 100644
--- a/homeassistant/components/ipma/weather.py
+++ b/homeassistant/components/ipma/weather.py
@@ -31,7 +31,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sun import is_up
from homeassistant.util import Throttle
@@ -51,7 +51,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
api = hass.data[DOMAIN][config_entry.entry_id][DATA_API]
diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py
index 0a94795613b..99332dca0e2 100644
--- a/homeassistant/components/ipp/__init__.py
+++ b/homeassistant/components/ipp/__init__.py
@@ -2,39 +2,17 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- CONF_HOST,
- CONF_PORT,
- CONF_SSL,
- CONF_VERIFY_SSL,
- Platform,
-)
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import CONF_BASE_PATH
-from .coordinator import IPPDataUpdateCoordinator
+from .coordinator import IPPConfigEntry, IPPDataUpdateCoordinator
PLATFORMS = [Platform.SENSOR]
-type IPPConfigEntry = ConfigEntry[IPPDataUpdateCoordinator]
-
async def async_setup_entry(hass: HomeAssistant, entry: IPPConfigEntry) -> bool:
"""Set up IPP from a config entry."""
- # config flow sets this to either UUID, serial number or None
- if (device_id := entry.unique_id) is None:
- device_id = entry.entry_id
-
- coordinator = IPPDataUpdateCoordinator(
- hass,
- host=entry.data[CONF_HOST],
- port=entry.data[CONF_PORT],
- base_path=entry.data[CONF_BASE_PATH],
- tls=entry.data[CONF_SSL],
- verify_ssl=entry.data[CONF_VERIFY_SSL],
- device_id=device_id,
- )
+ coordinator = IPPDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
@@ -44,6 +22,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: IPPConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: IPPConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/ipp/coordinator.py b/homeassistant/components/ipp/coordinator.py
index 535b18bcaf0..1c3dc4d0a03 100644
--- a/homeassistant/components/ipp/coordinator.py
+++ b/homeassistant/components/ipp/coordinator.py
@@ -7,45 +7,42 @@ import logging
from pyipp import IPP, IPPError, Printer as IPPPrinter
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import DOMAIN
+from .const import CONF_BASE_PATH, DOMAIN
SCAN_INTERVAL = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
+type IPPConfigEntry = ConfigEntry[IPPDataUpdateCoordinator]
+
class IPPDataUpdateCoordinator(DataUpdateCoordinator[IPPPrinter]):
"""Class to manage fetching IPP data from single endpoint."""
- def __init__(
- self,
- hass: HomeAssistant,
- *,
- host: str,
- port: int,
- base_path: str,
- tls: bool,
- verify_ssl: bool,
- device_id: str,
- ) -> None:
+ config_entry: IPPConfigEntry
+
+ def __init__(self, hass: HomeAssistant, config_entry: IPPConfigEntry) -> None:
"""Initialize global IPP data updater."""
- self.device_id = device_id
+ self.device_id = config_entry.unique_id or config_entry.entry_id
self.ipp = IPP(
- host=host,
- port=port,
- base_path=base_path,
- tls=tls,
- verify_ssl=verify_ssl,
- session=async_get_clientsession(hass, verify_ssl),
+ host=config_entry.data[CONF_HOST],
+ port=config_entry.data[CONF_PORT],
+ base_path=config_entry.data[CONF_BASE_PATH],
+ tls=config_entry.data[CONF_SSL],
+ verify_ssl=config_entry.data[CONF_VERIFY_SSL],
+ session=async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]),
)
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
)
diff --git a/homeassistant/components/ipp/diagnostics.py b/homeassistant/components/ipp/diagnostics.py
index 9b10dc68966..cd136e78373 100644
--- a/homeassistant/components/ipp/diagnostics.py
+++ b/homeassistant/components/ipp/diagnostics.py
@@ -6,7 +6,7 @@ from typing import Any
from homeassistant.core import HomeAssistant
-from . import IPPConfigEntry
+from .coordinator import IPPConfigEntry
async def async_get_config_entry_diagnostics(
diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py
index a2792c7749b..e16819a54ff 100644
--- a/homeassistant/components/ipp/sensor.py
+++ b/homeassistant/components/ipp/sensor.py
@@ -17,10 +17,9 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import ATTR_LOCATION, PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
-from . import IPPConfigEntry
from .const import (
ATTR_COMMAND_SET,
ATTR_INFO,
@@ -32,6 +31,7 @@ from .const import (
ATTR_STATE_REASON,
ATTR_URI_SUPPORTED,
)
+from .coordinator import IPPConfigEntry
from .entity import IPPEntity
@@ -87,7 +87,7 @@ PRINTER_SENSORS: tuple[IPPSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: IPPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up IPP sensor based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json
index ac879ef0ab3..b4c092c8ae3 100644
--- a/homeassistant/components/ipp/strings.json
+++ b/homeassistant/components/ipp/strings.json
@@ -38,7 +38,7 @@
"state": {
"printing": "Printing",
"idle": "[%key:common::state::idle%]",
- "stopped": "Stopped"
+ "stopped": "[%key:common::state::stopped%]"
}
},
"uptime": {
diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py
index d04e0885454..64492c634e9 100644
--- a/homeassistant/components/iqvia/sensor.py
+++ b/homeassistant/components/iqvia/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_STATE
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
@@ -127,7 +127,9 @@ INDEX_SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up IQVIA sensors based on a config entry."""
sensors: list[ForecastSensor | IndexSensor] = [
diff --git a/homeassistant/components/iqvia/strings.json b/homeassistant/components/iqvia/strings.json
index 5dc0dea53d5..a0697a6c210 100644
--- a/homeassistant/components/iqvia/strings.json
+++ b/homeassistant/components/iqvia/strings.json
@@ -4,7 +4,7 @@
"user": {
"description": "Fill out your U.S. or Canadian ZIP code.",
"data": {
- "zip_code": "ZIP Code"
+ "zip_code": "ZIP code"
}
}
},
diff --git a/homeassistant/components/iron_os/__init__.py b/homeassistant/components/iron_os/__init__.py
index 6af6abb1436..77099e48b41 100644
--- a/homeassistant/components/iron_os/__init__.py
+++ b/homeassistant/components/iron_os/__init__.py
@@ -8,7 +8,6 @@ from typing import TYPE_CHECKING
from pynecil import IronOSUpdate, Pynecil
from homeassistant.components import bluetooth
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -19,6 +18,7 @@ from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
from .coordinator import (
+ IronOSConfigEntry,
IronOSCoordinators,
IronOSFirmwareUpdateCoordinator,
IronOSLiveDataCoordinator,
@@ -39,7 +39,6 @@ PLATFORMS: list[Platform] = [
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
-type IronOSConfigEntry = ConfigEntry[IronOSCoordinators]
IRON_OS_KEY: HassKey[IronOSFirmwareUpdateCoordinator] = HassKey(DOMAIN)
@@ -73,10 +72,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: IronOSConfigEntry) -> bo
device = Pynecil(ble_device)
- live_data = IronOSLiveDataCoordinator(hass, device)
+ live_data = IronOSLiveDataCoordinator(hass, entry, device)
await live_data.async_config_entry_first_refresh()
- settings = IronOSSettingsCoordinator(hass, device)
+ settings = IronOSSettingsCoordinator(hass, entry, device)
await settings.async_config_entry_first_refresh()
entry.runtime_data = IronOSCoordinators(
diff --git a/homeassistant/components/iron_os/binary_sensor.py b/homeassistant/components/iron_os/binary_sensor.py
index 81ba0e08c95..66e642c7aaa 100644
--- a/homeassistant/components/iron_os/binary_sensor.py
+++ b/homeassistant/components/iron_os/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IronOSConfigEntry
from .coordinator import IronOSLiveDataCoordinator
@@ -29,7 +29,7 @@ class PinecilBinarySensor(StrEnum):
async def async_setup_entry(
hass: HomeAssistant,
entry: IronOSConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors from a config entry."""
coordinator = entry.runtime_data.live_data
diff --git a/homeassistant/components/iron_os/button.py b/homeassistant/components/iron_os/button.py
index be16148a656..e069ddb1d9f 100644
--- a/homeassistant/components/iron_os/button.py
+++ b/homeassistant/components/iron_os/button.py
@@ -10,7 +10,7 @@ from pynecil import CharSetting
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IronOSConfigEntry
from .coordinator import IronOSCoordinators
@@ -53,7 +53,7 @@ BUTTON_DESCRIPTIONS: tuple[IronOSButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: IronOSConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button entities from a config entry."""
coordinators = entry.runtime_data
diff --git a/homeassistant/components/iron_os/config_flow.py b/homeassistant/components/iron_os/config_flow.py
index 444db79c926..8509577114f 100644
--- a/homeassistant/components/iron_os/config_flow.py
+++ b/homeassistant/components/iron_os/config_flow.py
@@ -61,7 +61,7 @@ class IronOSConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(title=title, data={})
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, True):
address = discovery_info.address
if (
diff --git a/homeassistant/components/iron_os/coordinator.py b/homeassistant/components/iron_os/coordinator.py
index 080fee20762..84c9b895766 100644
--- a/homeassistant/components/iron_os/coordinator.py
+++ b/homeassistant/components/iron_os/coordinator.py
@@ -8,6 +8,7 @@ from enum import Enum
import logging
from typing import cast
+from awesomeversion import AwesomeVersion
from pynecil import (
CharSetting,
CommunicationError,
@@ -34,6 +35,8 @@ SCAN_INTERVAL = timedelta(seconds=5)
SCAN_INTERVAL_GITHUB = timedelta(hours=3)
SCAN_INTERVAL_SETTINGS = timedelta(seconds=60)
+V223 = AwesomeVersion("v2.23")
+
@dataclass
class IronOSCoordinators:
@@ -43,15 +46,19 @@ class IronOSCoordinators:
settings: IronOSSettingsCoordinator
+type IronOSConfigEntry = ConfigEntry[IronOSCoordinators]
+
+
class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""IronOS base coordinator."""
device_info: DeviceInfoResponse
- config_entry: ConfigEntry
+ config_entry: IronOSConfigEntry
def __init__(
self,
hass: HomeAssistant,
+ config_entry: IronOSConfigEntry,
device: Pynecil,
update_interval: timedelta,
) -> None:
@@ -60,6 +67,7 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
super().__init__(
hass,
_LOGGER,
+ config_entry=config_entry,
name=DOMAIN,
update_interval=update_interval,
request_refresh_debouncer=Debouncer(
@@ -67,6 +75,7 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
),
)
self.device = device
+ self.v223_features = False
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -76,13 +85,17 @@ class IronOSBaseCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
except CommunicationError as e:
raise UpdateFailed("Cannot connect to device") from e
+ self.v223_features = AwesomeVersion(self.device_info.build) >= V223
+
class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]):
"""IronOS coordinator."""
- def __init__(self, hass: HomeAssistant, device: Pynecil) -> None:
+ def __init__(
+ self, hass: HomeAssistant, config_entry: IronOSConfigEntry, device: Pynecil
+ ) -> None:
"""Initialize IronOS coordinator."""
- super().__init__(hass, device=device, update_interval=SCAN_INTERVAL)
+ super().__init__(hass, config_entry, device, SCAN_INTERVAL)
async def _async_update_data(self) -> LiveDataResponse:
"""Fetch data from Device."""
@@ -109,35 +122,14 @@ class IronOSLiveDataCoordinator(IronOSBaseCoordinator[LiveDataResponse]):
return False
-class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]):
- """IronOS coordinator for retrieving update information from github."""
-
- def __init__(self, hass: HomeAssistant, github: IronOSUpdate) -> None:
- """Initialize IronOS coordinator."""
- super().__init__(
- hass,
- _LOGGER,
- config_entry=None,
- name=DOMAIN,
- update_interval=SCAN_INTERVAL_GITHUB,
- )
- self.github = github
-
- async def _async_update_data(self) -> LatestRelease:
- """Fetch data from Github."""
-
- try:
- return await self.github.latest_release()
- except UpdateException as e:
- raise UpdateFailed("Failed to check for latest IronOS update") from e
-
-
class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]):
"""IronOS coordinator."""
- def __init__(self, hass: HomeAssistant, device: Pynecil) -> None:
+ def __init__(
+ self, hass: HomeAssistant, config_entry: IronOSConfigEntry, device: Pynecil
+ ) -> None:
"""Initialize IronOS coordinator."""
- super().__init__(hass, device=device, update_interval=SCAN_INTERVAL_SETTINGS)
+ super().__init__(hass, config_entry, device, SCAN_INTERVAL_SETTINGS)
async def _async_update_data(self) -> SettingsDataResponse:
"""Fetch data from Device."""
@@ -173,3 +165,26 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]):
)
self.async_update_listeners()
await self.async_request_refresh()
+
+
+class IronOSFirmwareUpdateCoordinator(DataUpdateCoordinator[LatestRelease]):
+ """IronOS coordinator for retrieving update information from github."""
+
+ def __init__(self, hass: HomeAssistant, github: IronOSUpdate) -> None:
+ """Initialize IronOS coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=None,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL_GITHUB,
+ )
+ self.github = github
+
+ async def _async_update_data(self) -> LatestRelease:
+ """Fetch data from Github."""
+
+ try:
+ return await self.github.latest_release()
+ except UpdateException as e:
+ raise UpdateFailed("Failed to check for latest IronOS update") from e
diff --git a/homeassistant/components/iron_os/icons.json b/homeassistant/components/iron_os/icons.json
index 6410c561b9d..695b9d16849 100644
--- a/homeassistant/components/iron_os/icons.json
+++ b/homeassistant/components/iron_os/icons.json
@@ -73,6 +73,9 @@
},
"power_limit": {
"default": "mdi:flash-alert"
+ },
+ "hall_effect_sleep_time": {
+ "default": "mdi:timer-sand"
}
},
"select": {
@@ -105,6 +108,9 @@
},
"usb_pd_mode": {
"default": "mdi:meter-electric-outline"
+ },
+ "tip_type": {
+ "default": "mdi:pencil-outline"
}
},
"sensor": {
@@ -154,7 +160,16 @@
"soldering": "mdi:soldering-iron",
"sleeping": "mdi:sleep",
"settings": "mdi:menu-open",
- "debug": "mdi:bug-play"
+ "debug": "mdi:bug-play",
+ "soldering_profile": "mdi:chart-box-outline",
+ "temperature_adjust": "mdi:thermostat-box",
+ "usb_pd_debug": "mdi:bug-play",
+ "thermal_runaway": "mdi:fire-alert",
+ "startup_logo": "mdi:dots-circle",
+ "cjc_calibration": "mdi:tune-vertical",
+ "startup_warnings": "mdi:alert",
+ "initialisation_done": "mdi:check-circle",
+ "hibernating": "mdi:sleep"
}
},
"estimated_power": {
diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json
index 462e75c5b6e..58cbdaa3bc6 100644
--- a/homeassistant/components/iron_os/manifest.json
+++ b/homeassistant/components/iron_os/manifest.json
@@ -13,5 +13,6 @@
"documentation": "https://www.home-assistant.io/integrations/iron_os",
"iot_class": "local_polling",
"loggers": ["pynecil"],
- "requirements": ["pynecil==4.0.1"]
+ "quality_scale": "platinum",
+ "requirements": ["pynecil==4.1.0"]
}
diff --git a/homeassistant/components/iron_os/number.py b/homeassistant/components/iron_os/number.py
index 518c11372c4..6ad5947cb6f 100644
--- a/homeassistant/components/iron_os/number.py
+++ b/homeassistant/components/iron_os/number.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IronOSConfigEntry
from .const import MAX_TEMP, MIN_TEMP
@@ -65,6 +65,7 @@ class PinecilNumber(StrEnum):
VOLTAGE_DIV = "voltage_div"
TEMP_INCREMENT_SHORT = "temp_increment_short"
TEMP_INCREMENT_LONG = "temp_increment_long"
+ HALL_EFFECT_SLEEP_TIME = "hall_effect_sleep_time"
def multiply(value: float | None, multiplier: float) -> float | None:
@@ -323,18 +324,38 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
),
)
+PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = (
+ IronOSNumberEntityDescription(
+ key=PinecilNumber.HALL_EFFECT_SLEEP_TIME,
+ translation_key=PinecilNumber.HALL_EFFECT_SLEEP_TIME,
+ value_fn=(lambda _, settings: settings.get("hall_sleep_time")),
+ characteristic=CharSetting.HALL_SLEEP_TIME,
+ raw_value_fn=lambda value: value,
+ mode=NumberMode.BOX,
+ native_min_value=0,
+ native_max_value=60,
+ native_step=5,
+ entity_category=EntityCategory.CONFIG,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ entity_registry_enabled_default=False,
+ ),
+)
+
async def async_setup_entry(
hass: HomeAssistant,
entry: IronOSConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number entities from a config entry."""
coordinators = entry.runtime_data
+ descriptions = PINECIL_NUMBER_DESCRIPTIONS
+
+ if coordinators.live_data.v223_features:
+ descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223
async_add_entities(
- IronOSNumberEntity(coordinators, description)
- for description in PINECIL_NUMBER_DESCRIPTIONS
+ IronOSNumberEntity(coordinators, description) for description in descriptions
)
diff --git a/homeassistant/components/iron_os/quality_scale.yaml b/homeassistant/components/iron_os/quality_scale.yaml
index c80b8b5adfe..8f7eb5ff36a 100644
--- a/homeassistant/components/iron_os/quality_scale.yaml
+++ b/homeassistant/components/iron_os/quality_scale.yaml
@@ -21,8 +21,10 @@ rules:
entity-unique-id: done
has-entity-name: done
runtime-data: done
- test-before-configure: todo
- test-before-setup: todo
+ test-before-configure:
+ status: exempt
+ comment: Device is set up from a Bluetooth discovery
+ test-before-setup: done
unique-config-entry: done
# Silver
@@ -70,7 +72,9 @@ rules:
repair-issues:
status: exempt
comment: no repairs/issues
- stale-devices: todo
+ stale-devices:
+ status: exempt
+ comment: Stale devices are removed with the config entry as there is only one device per entry
# Platinum
async-dependency: done
diff --git a/homeassistant/components/iron_os/select.py b/homeassistant/components/iron_os/select.py
index e9c7f81c208..32652829531 100644
--- a/homeassistant/components/iron_os/select.py
+++ b/homeassistant/components/iron_os/select.py
@@ -17,13 +17,14 @@ from pynecil import (
ScrollSpeed,
SettingsDataResponse,
TempUnit,
+ TipType,
USBPDMode,
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IronOSConfigEntry
from .coordinator import IronOSCoordinators
@@ -53,6 +54,7 @@ class PinecilSelect(StrEnum):
LOCKING_MODE = "locking_mode"
LOGO_DURATION = "logo_duration"
USB_PD_MODE = "usb_pd_mode"
+ TIP_TYPE = "tip_type"
def enum_to_str(enum: Enum | None) -> str | None:
@@ -138,6 +140,8 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
entity_registry_enabled_default=False,
),
+)
+PINECIL_SELECT_DESCRIPTIONS_V222: tuple[IronOSSelectEntityDescription, ...] = (
IronOSSelectEntityDescription(
key=PinecilSelect.USB_PD_MODE,
translation_key=PinecilSelect.USB_PD_MODE,
@@ -149,19 +153,46 @@ PINECIL_SELECT_DESCRIPTIONS: tuple[IronOSSelectEntityDescription, ...] = (
entity_registry_enabled_default=False,
),
)
+PINECIL_SELECT_DESCRIPTIONS_V223: tuple[IronOSSelectEntityDescription, ...] = (
+ IronOSSelectEntityDescription(
+ key=PinecilSelect.USB_PD_MODE,
+ translation_key=PinecilSelect.USB_PD_MODE,
+ characteristic=CharSetting.USB_PD_MODE,
+ value_fn=lambda x: enum_to_str(x.get("usb_pd_mode")),
+ raw_value_fn=lambda value: USBPDMode[value.upper()],
+ options=[x.name.lower() for x in USBPDMode],
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ ),
+ IronOSSelectEntityDescription(
+ key=PinecilSelect.TIP_TYPE,
+ translation_key=PinecilSelect.TIP_TYPE,
+ characteristic=CharSetting.TIP_TYPE,
+ value_fn=lambda x: enum_to_str(x.get("tip_type")),
+ raw_value_fn=lambda value: TipType[value.upper()],
+ options=[x.name.lower() for x in TipType],
+ entity_category=EntityCategory.CONFIG,
+ ),
+)
async def async_setup_entry(
hass: HomeAssistant,
entry: IronOSConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up select entities from a config entry."""
- coordinator = entry.runtime_data
+ coordinators = entry.runtime_data
+ descriptions = PINECIL_SELECT_DESCRIPTIONS
+
+ descriptions += (
+ PINECIL_SELECT_DESCRIPTIONS_V223
+ if coordinators.live_data.v223_features
+ else PINECIL_SELECT_DESCRIPTIONS_V222
+ )
async_add_entities(
- IronOSSelectEntity(coordinator, description)
- for description in PINECIL_SELECT_DESCRIPTIONS
+ IronOSSelectEntity(coordinators, description) for description in descriptions
)
diff --git a/homeassistant/components/iron_os/sensor.py b/homeassistant/components/iron_os/sensor.py
index d178b46723f..79f1e54a6f4 100644
--- a/homeassistant/components/iron_os/sensor.py
+++ b/homeassistant/components/iron_os/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import IronOSConfigEntry
@@ -184,7 +184,7 @@ PINECIL_SENSOR_DESCRIPTIONS: tuple[IronOSSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: IronOSConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors from a config entry."""
coordinator = entry.runtime_data.live_data
diff --git a/homeassistant/components/iron_os/strings.json b/homeassistant/components/iron_os/strings.json
index 60168699427..22c194cf41f 100644
--- a/homeassistant/components/iron_os/strings.json
+++ b/homeassistant/components/iron_os/strings.json
@@ -94,6 +94,9 @@
},
"temp_increment_long": {
"name": "Long-press temperature step"
+ },
+ "hall_effect_sleep_time": {
+ "name": "Hall sensor sleep timeout"
}
},
"select": {
@@ -112,7 +115,7 @@
"state": {
"right_handed": "Right-handed",
"left_handed": "Left-handed",
- "auto": "Auto"
+ "auto": "[%key:common::state::auto%]"
}
},
"animation_speed": {
@@ -120,7 +123,7 @@
"state": {
"off": "[%key:common::state::off%]",
"slow": "[%key:component::iron_os::common::slow%]",
- "medium": "Medium",
+ "medium": "[%key:common::state::medium%]",
"fast": "[%key:component::iron_os::common::fast%]"
}
},
@@ -173,6 +176,15 @@
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
+ },
+ "tip_type": {
+ "name": "Soldering tip type",
+ "state": {
+ "auto": "Auto sense",
+ "ts100_long": "TS100 long/Hakko T12 tip",
+ "pine_short": "Pinecil short tip",
+ "pts200": "PTS200 short tip"
+ }
}
},
"sensor": {
@@ -223,7 +235,16 @@
"sleeping": "Sleeping",
"settings": "Settings",
"debug": "Debug",
- "boost": "Boost"
+ "boost": "Boost",
+ "soldering_profile": "Soldering profile",
+ "temperature_adjust": "Temperature adjust",
+ "usb_pd_debug": "USB PD debug",
+ "thermal_runaway": "Thermal runaway",
+ "startup_logo": "Booting",
+ "cjc_calibration": "CJC calibration",
+ "startup_warnings": "Startup warnings",
+ "initialisation_done": "Initialisation done",
+ "hibernating": "Hibernating"
}
},
"estimated_power": {
diff --git a/homeassistant/components/iron_os/switch.py b/homeassistant/components/iron_os/switch.py
index d88e8cfdcb5..124b670048a 100644
--- a/homeassistant/components/iron_os/switch.py
+++ b/homeassistant/components/iron_os/switch.py
@@ -12,7 +12,7 @@ from pynecil import CharSetting, SettingsDataResponse
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IronOSConfigEntry
from .coordinator import IronOSCoordinators
@@ -100,7 +100,7 @@ SWITCH_DESCRIPTIONS: tuple[IronOSSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: IronOSConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches from a config entry."""
diff --git a/homeassistant/components/iron_os/update.py b/homeassistant/components/iron_os/update.py
index b431d321f24..4ec626ffc2a 100644
--- a/homeassistant/components/iron_os/update.py
+++ b/homeassistant/components/iron_os/update.py
@@ -9,7 +9,7 @@ from homeassistant.components.update import (
UpdateEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IRON_OS_KEY, IronOSConfigEntry, IronOSLiveDataCoordinator
from .coordinator import IronOSFirmwareUpdateCoordinator
@@ -26,7 +26,7 @@ UPDATE_DESCRIPTION = UpdateEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: IronOSConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up IronOS update platform."""
diff --git a/homeassistant/components/iskra/sensor.py b/homeassistant/components/iskra/sensor.py
index a61951dedb9..10aa5555249 100644
--- a/homeassistant/components/iskra/sensor.py
+++ b/homeassistant/components/iskra/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
UnitOfReactivePower,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_FREQUENCY,
@@ -207,7 +207,7 @@ def get_counter_entity_description(
async def async_setup_entry(
hass: HomeAssistant,
entry: IskraConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Iskra sensors based on config_entry."""
diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py
index c46b629d2d8..3d35786186e 100644
--- a/homeassistant/components/islamic_prayer_times/sensor.py
+++ b/homeassistant/components/islamic_prayer_times/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import IslamicPrayerTimesConfigEntry
@@ -51,7 +51,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: IslamicPrayerTimesConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Islamic prayer times sensor platform."""
diff --git a/homeassistant/components/israel_rail/sensor.py b/homeassistant/components/israel_rail/sensor.py
index d0c93da3451..86b2e135cfb 100644
--- a/homeassistant/components/israel_rail/sensor.py
+++ b/homeassistant/components/israel_rail/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -73,7 +73,7 @@ SENSORS: tuple[IsraelRailSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: IsraelRailConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor from a config entry created in the integrations UI."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/iss/sensor.py b/homeassistant/components/iss/sensor.py
index f4f91f0099e..b6e98e07f8a 100644
--- a/homeassistant/components/iss/sensor.py
+++ b/homeassistant/components/iss/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
coordinator: DataUpdateCoordinator[IssData] = hass.data[DOMAIN]
diff --git a/homeassistant/components/ista_ecotrend/sensor.py b/homeassistant/components/ista_ecotrend/sensor.py
index 59fd48a5fe9..0a8ed6e9ddb 100644
--- a/homeassistant/components/ista_ecotrend/sensor.py
+++ b/homeassistant/components/ista_ecotrend/sensor.py
@@ -8,6 +8,7 @@ import datetime
from enum import StrEnum
import logging
+from homeassistant.components.recorder.models import StatisticMeanType
from homeassistant.components.recorder.models.statistics import (
StatisticData,
StatisticMetaData,
@@ -30,7 +31,7 @@ from homeassistant.helpers.device_registry import (
DeviceEntryType,
DeviceInfo,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -152,7 +153,7 @@ SENSOR_DESCRIPTIONS: tuple[IstaSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: IstaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ista EcoTrend sensors."""
@@ -270,7 +271,7 @@ class IstaSensor(CoordinatorEntity[IstaCoordinator], SensorEntity):
]
metadata: StatisticMetaData = {
- "has_mean": False,
+ "mean_type": StatisticMeanType.NONE,
"has_sum": True,
"name": f"{self.device_entry.name} {self.name}",
"source": DOMAIN,
diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py
index 738c7e2d5ad..1e227b08206 100644
--- a/homeassistant/components/isy994/__init__.py
+++ b/homeassistant/components/isy994/__init__.py
@@ -138,7 +138,7 @@ async def async_setup_entry(
for vtype, _, vid in isy.variables.children:
numbers.append(isy.variables[vtype][vid])
if (
- isy.conf[CONFIG_NETWORKING] or isy.conf[CONFIG_PORTAL]
+ isy.conf[CONFIG_NETWORKING] or isy.conf.get(CONFIG_PORTAL)
) and isy.networking.nobjs:
isy_data.devices[CONF_NETWORK] = _create_service_device_info(
isy, name=CONFIG_NETWORKING, unique_id=CONF_NETWORK
@@ -227,9 +227,9 @@ async def async_unload_entry(
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- isy_data = hass.data[DOMAIN][entry.entry_id]
+ isy_data: IsyData = hass.data[DOMAIN][entry.entry_id]
- isy: ISY = isy_data.root
+ isy = isy_data.root
_LOGGER.debug("ISY Stopping Event Stream and automatic updates")
isy.websocket.stop()
diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py
index 179944ad35f..8c9ce7dcc12 100644
--- a/homeassistant/components/isy994/binary_sensor.py
+++ b/homeassistant/components/isy994/binary_sensor.py
@@ -23,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.restore_state import RestoreEntity
@@ -54,7 +54,9 @@ DEVICE_PARENT_REQUIRED = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ISY binary sensor platform."""
entities: list[
diff --git a/homeassistant/components/isy994/button.py b/homeassistant/components/isy994/button.py
index b3b6aa40503..a895312c45a 100644
--- a/homeassistant/components/isy994/button.py
+++ b/homeassistant/components/isy994/button.py
@@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_NETWORK, DOMAIN
from .models import IsyData
@@ -28,7 +28,7 @@ from .models import IsyData
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ISY/IoX button from config entry."""
isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py
index d5deba56284..57c1b6aa79d 100644
--- a/homeassistant/components/isy994/climate.py
+++ b/homeassistant/components/isy994/climate.py
@@ -37,7 +37,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from .const import (
@@ -61,7 +61,9 @@ from .models import IsyData
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ISY thermostat platform."""
diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py
index b9d7ec44d27..6a660aaaf6f 100644
--- a/homeassistant/components/isy994/cover.py
+++ b/homeassistant/components/isy994/cover.py
@@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import _LOGGER, DOMAIN, UOM_8_BIT_RANGE
from .entity import ISYNodeEntity, ISYProgramEntity
@@ -23,7 +23,9 @@ from .models import IsyData
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ISY cover platform."""
isy_data: IsyData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/isy994/entity.py b/homeassistant/components/isy994/entity.py
index 893b33644fe..d170854396c 100644
--- a/homeassistant/components/isy994/entity.py
+++ b/homeassistant/components/isy994/entity.py
@@ -106,7 +106,7 @@ class ISYNodeEntity(ISYEntity):
return getattr(self._node, TAG_ENABLED, True)
@property
- def extra_state_attributes(self) -> dict:
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Get the state attributes for the device.
The 'aux_properties' in the pyisy Node class are combined with the
@@ -181,6 +181,7 @@ class ISYProgramEntity(ISYEntity):
_actions: Program
_status: Program
+ _node: Program
def __init__(self, name: str, status: Program, actions: Program = None) -> None:
"""Initialize the ISY program-based entity."""
@@ -189,7 +190,7 @@ class ISYProgramEntity(ISYEntity):
self._actions = actions
@property
- def extra_state_attributes(self) -> dict:
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Get the state attributes for the device."""
attr = {}
if self._actions:
diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py
index fc0406e2d5f..aa6059abf49 100644
--- a/homeassistant/components/isy994/fan.py
+++ b/homeassistant/components/isy994/fan.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -27,7 +27,9 @@ SPEED_RANGE = (1, 255) # off is not included
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ISY fan platform."""
isy_data: IsyData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py
index b9b269d9ca3..29df8398f97 100644
--- a/homeassistant/components/isy994/light.py
+++ b/homeassistant/components/isy994/light.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import _LOGGER, CONF_RESTORE_LIGHT_STATE, DOMAIN, UOM_PERCENTAGE
@@ -24,7 +24,9 @@ ATTR_LAST_BRIGHTNESS = "last_brightness"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ISY light platform."""
isy_data: IsyData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py
index dc2da2a6ee2..d6866a8e00c 100644
--- a/homeassistant/components/isy994/lock.py
+++ b/homeassistant/components/isy994/lock.py
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
@@ -48,7 +48,9 @@ def async_setup_lock_services(hass: HomeAssistant) -> None:
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ISY lock platform."""
isy_data: IsyData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json
index 3aa81027b4f..5cd3bb73a89 100644
--- a/homeassistant/components/isy994/manifest.json
+++ b/homeassistant/components/isy994/manifest.json
@@ -24,7 +24,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["pyisy"],
- "requirements": ["pyisy==3.1.14"],
+ "requirements": ["pyisy==3.4.0"],
"ssdp": [
{
"manufacturer": "Universal Devices Inc.",
diff --git a/homeassistant/components/isy994/number.py b/homeassistant/components/isy994/number.py
index c8feba1bf8d..fc30e6296d4 100644
--- a/homeassistant/components/isy994/number.py
+++ b/homeassistant/components/isy994/number.py
@@ -38,7 +38,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -80,7 +80,7 @@ BACKLIGHT_MEMORY_FILTER = {"memory": DEV_BL_ADDR, "cmd1": DEV_CMD_MEMORY_WRITE}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ISY/IoX number entities from config entry."""
isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/isy994/select.py b/homeassistant/components/isy994/select.py
index 8befcf024d1..868c96375bb 100644
--- a/homeassistant/components/isy994/select.py
+++ b/homeassistant/components/isy994/select.py
@@ -34,7 +34,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import _LOGGER, DOMAIN, UOM_INDEX
@@ -56,7 +56,7 @@ BACKLIGHT_MEMORY_FILTER = {"memory": DEV_BL_ADDR, "cmd1": DEV_CMD_MEMORY_WRITE}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ISY/IoX select entities from config entry."""
isy_data: IsyData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py
index 58ba3171bc8..2d27f4602c6 100644
--- a/homeassistant/components/isy994/sensor.py
+++ b/homeassistant/components/isy994/sensor.py
@@ -33,7 +33,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
_LOGGER,
@@ -97,9 +97,9 @@ ISY_CONTROL_TO_DEVICE_CLASS = {
"WEIGHT": SensorDeviceClass.WEIGHT,
"WINDCH": SensorDeviceClass.TEMPERATURE,
}
-ISY_CONTROL_TO_STATE_CLASS = {
- control: SensorStateClass.MEASUREMENT for control in ISY_CONTROL_TO_DEVICE_CLASS
-}
+ISY_CONTROL_TO_STATE_CLASS = dict.fromkeys(
+ ISY_CONTROL_TO_DEVICE_CLASS, SensorStateClass.MEASUREMENT
+)
ISY_CONTROL_TO_ENTITY_CATEGORY = {
PROP_RAMP_RATE: EntityCategory.DIAGNOSTIC,
PROP_ON_LEVEL: EntityCategory.DIAGNOSTIC,
@@ -108,7 +108,9 @@ ISY_CONTROL_TO_ENTITY_CATEGORY = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ISY sensor platform."""
isy_data: IsyData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py
index 6546aec6efa..24cfa9aefb1 100644
--- a/homeassistant/components/isy994/services.py
+++ b/homeassistant/components/isy994/services.py
@@ -21,6 +21,7 @@ from homeassistant.helpers.service import entity_service_call
from homeassistant.helpers.typing import VolDictType
from .const import _LOGGER, DOMAIN
+from .models import IsyData
# Common Services for All Platforms:
SERVICE_SEND_PROGRAM_COMMAND = "send_program_command"
@@ -149,7 +150,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
isy_name = service.data.get(CONF_ISY)
for config_entry_id in hass.data[DOMAIN]:
- isy_data = hass.data[DOMAIN][config_entry_id]
+ isy_data: IsyData = hass.data[DOMAIN][config_entry_id]
isy = isy_data.root
if isy_name and isy_name != isy.conf["name"]:
continue
diff --git a/homeassistant/components/isy994/strings.json b/homeassistant/components/isy994/strings.json
index 86a1f14ff91..6594c030f08 100644
--- a/homeassistant/components/isy994/strings.json
+++ b/homeassistant/components/isy994/strings.json
@@ -58,7 +58,7 @@
"services": {
"send_raw_node_command": {
"name": "Send raw node command",
- "description": "[%key:component::isy994::options::step::init::description%]",
+ "description": "Sends a “raw” (e.g., DON, DOF) ISY REST device command to a node using its Home Assistant entity ID. This is useful for devices that aren’t fully supported in Home Assistant yet, such as controls for many NodeServer nodes.",
"fields": {
"command": {
"name": "Command",
@@ -90,7 +90,7 @@
},
"get_zwave_parameter": {
"name": "Get Z-Wave Parameter",
- "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
+ "description": "Requests a Z-Wave device parameter via the ISY. The parameter value will be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
"fields": {
"parameter": {
"name": "Parameter",
@@ -100,7 +100,7 @@
},
"set_zwave_parameter": {
"name": "Set Z-Wave parameter",
- "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as a entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
+ "description": "Updates a Z-Wave device parameter via the ISY. The parameter value will also be returned as an entity extra state attribute with the name \"ZW_#\" where \"#\" is the parameter number.",
"fields": {
"parameter": {
"name": "[%key:component::isy994::services::get_zwave_parameter::fields::parameter::name%]",
diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py
index c05bd2ddbbb..d5c8a23cbea 100644
--- a/homeassistant/components/isy994/switch.py
+++ b/homeassistant/components/isy994/switch.py
@@ -25,7 +25,7 @@ from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import ISYAuxControlEntity, ISYNodeEntity, ISYProgramEntity
@@ -42,7 +42,9 @@ class ISYSwitchEntityDescription(SwitchEntityDescription):
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ISY switch platform."""
isy_data: IsyData = hass.data[DOMAIN][entry.entry_id]
@@ -155,7 +157,7 @@ class ISYEnableSwitchEntity(ISYAuxControlEntity, SwitchEntity):
device_info=device_info,
)
self._attr_name = description.name # Override super
- self._change_handler: EventListener = None
+ self._change_handler: EventListener | None = None
# pylint: disable-next=hass-missing-super-call
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/ituran/device_tracker.py b/homeassistant/components/ituran/device_tracker.py
index 37796570c61..5f816709864 100644
--- a/homeassistant/components/ituran/device_tracker.py
+++ b/homeassistant/components/ituran/device_tracker.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IturanConfigEntry
from .coordinator import IturanDataUpdateCoordinator
@@ -14,7 +14,7 @@ from .entity import IturanBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: IturanConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ituran tracker from config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/ituran/sensor.py b/homeassistant/components/ituran/sensor.py
index e962f5bd561..a115b2be89c 100644
--- a/homeassistant/components/ituran/sensor.py
+++ b/homeassistant/components/ituran/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfSpeed,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import IturanConfigEntry
@@ -87,7 +87,7 @@ SENSOR_TYPES: list[IturanSensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: IturanConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ituran sensors from config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py
index e61917c825b..80c3a0384c1 100644
--- a/homeassistant/components/izone/climate.py
+++ b/homeassistant/components/izone/climate.py
@@ -33,7 +33,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.temperature import display_temp as show_temp
from homeassistant.helpers.typing import ConfigType, VolDictType
@@ -73,7 +73,9 @@ IZONE_SERVICE_AIRFLOW_SCHEMA: VolDictType = {
async def async_setup_entry(
- hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize an IZone Controller."""
disco = hass.data[DATA_DISCOVERY_SERVICE]
diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py
index ab5d5e7d7f8..91fe0885e4c 100644
--- a/homeassistant/components/jellyfin/client_wrapper.py
+++ b/homeassistant/components/jellyfin/client_wrapper.py
@@ -97,16 +97,27 @@ def get_artwork_url(
client: JellyfinClient, item: dict[str, Any], max_width: int = 600
) -> str | None:
"""Find a suitable thumbnail for an item."""
- artwork_id: str = item["Id"]
- artwork_type = "Primary"
+ artwork_id: str | None = None
+ artwork_type: str | None = None
parent_backdrop_id: str | None = item.get("ParentBackdropItemId")
- if "Backdrop" in item[ITEM_KEY_IMAGE_TAGS]:
+ if "AlbumPrimaryImageTag" in item:
+ # jellyfin_apiclient_python doesn't support passing a specific tag to `.artwork`,
+ # so we don't use the actual value of AlbumPrimaryImageTag.
+ # However, its mere presence tells us that the album does have primary artwork,
+ # and the resulting URL will pull the primary album art even if the tag is not specified.
+ artwork_type = "Primary"
+ artwork_id = item["AlbumId"]
+ elif "Backdrop" in item[ITEM_KEY_IMAGE_TAGS]:
artwork_type = "Backdrop"
+ artwork_id = item["Id"]
elif parent_backdrop_id:
artwork_type = "Backdrop"
artwork_id = parent_backdrop_id
- elif "Primary" not in item[ITEM_KEY_IMAGE_TAGS]:
+ elif "Primary" in item[ITEM_KEY_IMAGE_TAGS]:
+ artwork_type = "Primary"
+ artwork_id = item["Id"]
+ else:
return None
return str(client.jellyfin.artwork(artwork_id, artwork_type, max_width))
diff --git a/homeassistant/components/jellyfin/media_player.py b/homeassistant/components/jellyfin/media_player.py
index bb0d914162d..e0fcc8a559b 100644
--- a/homeassistant/components/jellyfin/media_player.py
+++ b/homeassistant/components/jellyfin/media_player.py
@@ -12,12 +12,12 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import parse_datetime
from .browse_media import build_item_response, build_root_response
from .client_wrapper import get_artwork_url
-from .const import CONTENT_TYPE_MAP, LOGGER
+from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH
from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator
from .entity import JellyfinClientEntity
@@ -25,7 +25,7 @@ from .entity import JellyfinClientEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: JellyfinConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Jellyfin media_player from a config entry."""
coordinator = entry.runtime_data
@@ -169,7 +169,9 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
if self.now_playing is None:
return None
- return get_artwork_url(self.coordinator.api_client, self.now_playing, 150)
+ return get_artwork_url(
+ self.coordinator.api_client, self.now_playing, MAX_IMAGE_WIDTH
+ )
@property
def supported_features(self) -> MediaPlayerEntityFeature:
diff --git a/homeassistant/components/jellyfin/remote.py b/homeassistant/components/jellyfin/remote.py
index 7c543813a13..27a0b131ca0 100644
--- a/homeassistant/components/jellyfin/remote.py
+++ b/homeassistant/components/jellyfin/remote.py
@@ -14,7 +14,7 @@ from homeassistant.components.remote import (
RemoteEntity,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER
from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator
@@ -24,7 +24,7 @@ from .entity import JellyfinClientEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: JellyfinConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Jellyfin remote from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/jellyfin/sensor.py b/homeassistant/components/jellyfin/sensor.py
index 934f2eb4e32..e1100a9f43b 100644
--- a/homeassistant/components/jellyfin/sensor.py
+++ b/homeassistant/components/jellyfin/sensor.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator
@@ -43,7 +43,7 @@ SENSOR_TYPES: tuple[JellyfinSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: JellyfinConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Jellyfin sensor based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py
index 823e9bd59be..47d60d74938 100644
--- a/homeassistant/components/jewish_calendar/__init__.py
+++ b/homeassistant/components/jewish_calendar/__init__.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from functools import partial
+import logging
from hdate import Location
@@ -14,7 +15,9 @@ from homeassistant.const import (
CONF_TIME_ZONE,
Platform,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import config_validation as cv, entity_registry as er
+from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_CANDLE_LIGHT_MINUTES,
@@ -24,10 +27,21 @@ from .const import (
DEFAULT_DIASPORA,
DEFAULT_HAVDALAH_OFFSET_MINUTES,
DEFAULT_LANGUAGE,
+ DOMAIN,
)
from .entity import JewishCalendarConfigEntry, JewishCalendarData
+from .service import async_setup_services
+_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Jewish Calendar service."""
+ async_setup_services(hass)
+
+ return True
async def async_setup_entry(
@@ -80,3 +94,49 @@ async def async_unload_entry(
) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
+
+
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: JewishCalendarConfigEntry
+) -> bool:
+ """Migrate old entry."""
+
+ _LOGGER.debug("Migrating from version %s", config_entry.version)
+
+ @callback
+ def update_unique_id(
+ entity_entry: er.RegistryEntry,
+ ) -> dict[str, str] | None:
+ """Update unique ID of entity entry."""
+ key_translations = {
+ "first_light": "alot_hashachar",
+ "talit": "talit_and_tefillin",
+ "sunrise": "netz_hachama",
+ "gra_end_shma": "sof_zman_shema_gra",
+ "mga_end_shma": "sof_zman_shema_mga",
+ "gra_end_tfila": "sof_zman_tfilla_gra",
+ "mga_end_tfila": "sof_zman_tfilla_mga",
+ "midday": "chatzot_hayom",
+ "big_mincha": "mincha_gedola",
+ "small_mincha": "mincha_ketana",
+ "plag_mincha": "plag_hamincha",
+ "sunset": "shkia",
+ "first_stars": "tset_hakohavim_tsom",
+ "three_stars": "tset_hakohavim_shabbat",
+ }
+ old_keys = tuple(key_translations.keys())
+ if entity_entry.unique_id.endswith(old_keys):
+ old_key = entity_entry.unique_id.split("-")[1]
+ new_unique_id = f"{config_entry.entry_id}-{key_translations[old_key]}"
+ return {"new_unique_id": new_unique_id}
+ return None
+
+ if config_entry.version > 1:
+ # This means the user has downgraded from a future version
+ return False
+
+ if config_entry.version == 1:
+ await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id)
+ hass.config_entries.async_update_entry(config_entry, version=2)
+
+ return True
diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py
index 85519bf37b0..f33d79a01f5 100644
--- a/homeassistant/components/jewish_calendar/binary_sensor.py
+++ b/homeassistant/components/jewish_calendar/binary_sensor.py
@@ -5,9 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import datetime as dt
-from datetime import datetime
-import hdate
from hdate.zmanim import Zmanim
from homeassistant.components.binary_sensor import (
@@ -17,7 +15,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import event
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
@@ -27,7 +25,7 @@ from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription):
"""Binary Sensor description mixin class for Jewish Calendar."""
- is_on: Callable[[Zmanim], bool] = lambda _: False
+ is_on: Callable[[Zmanim, dt.datetime], bool] = lambda _, __: False
@dataclass(frozen=True)
@@ -42,18 +40,18 @@ BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = (
key="issur_melacha_in_effect",
name="Issur Melacha in Effect",
icon="mdi:power-plug-off",
- is_on=lambda state: bool(state.issur_melacha_in_effect),
+ is_on=lambda state, now: bool(state.issur_melacha_in_effect(now)),
),
JewishCalendarBinarySensorEntityDescription(
key="erev_shabbat_hag",
name="Erev Shabbat/Hag",
- is_on=lambda state: bool(state.erev_shabbat_chag),
+ is_on=lambda state, now: bool(state.erev_shabbat_chag(now)),
entity_registry_enabled_default=False,
),
JewishCalendarBinarySensorEntityDescription(
key="motzei_shabbat_hag",
name="Motzei Shabbat/Hag",
- is_on=lambda state: bool(state.motzei_shabbat_chag),
+ is_on=lambda state, now: bool(state.motzei_shabbat_chag(now)),
entity_registry_enabled_default=False,
),
)
@@ -62,7 +60,7 @@ BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: JewishCalendarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Jewish Calendar binary sensors."""
async_add_entities(
@@ -84,16 +82,16 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
def is_on(self) -> bool:
"""Return true if sensor is on."""
zmanim = self._get_zmanim()
- return self.entity_description.is_on(zmanim)
+ return self.entity_description.is_on(zmanim, dt_util.now())
def _get_zmanim(self) -> Zmanim:
"""Return the Zmanim object for now()."""
- return hdate.Zmanim(
- date=dt_util.now(),
+ return Zmanim(
+ date=dt.date.today(),
location=self._location,
candle_lighting_offset=self._candle_lighting_offset,
havdalah_offset=self._havdalah_offset,
- hebrew=self._hebrew,
+ language=self._language,
)
async def async_added_to_hass(self) -> None:
@@ -109,7 +107,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
return await super().async_will_remove_from_hass()
@callback
- def _update(self, now: datetime | None = None) -> None:
+ def _update(self, now: dt.datetime | None = None) -> None:
"""Update the state of the sensor."""
self._update_unsub = None
self._schedule_update()
@@ -119,7 +117,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
"""Schedule the next update of the sensor."""
now = dt_util.now()
zmanim = self._get_zmanim()
- update = zmanim.zmanim["sunrise"] + dt.timedelta(days=1)
+ update = zmanim.netz_hachama.local + dt.timedelta(days=1)
candle_lighting = zmanim.candle_lighting
if candle_lighting is not None and now < candle_lighting < update:
update = candle_lighting
diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py
index a2eadbf57bd..3cec9e9e24e 100644
--- a/homeassistant/components/jewish_calendar/config_flow.py
+++ b/homeassistant/components/jewish_calendar/config_flow.py
@@ -61,11 +61,14 @@ OPTIONS_SCHEMA = vol.Schema(
_LOGGER = logging.getLogger(__name__)
-def _get_data_schema(hass: HomeAssistant) -> vol.Schema:
+async def _get_data_schema(hass: HomeAssistant) -> vol.Schema:
default_location = {
CONF_LATITUDE: hass.config.latitude,
CONF_LONGITUDE: hass.config.longitude,
}
+ get_timezones: list[str] = list(
+ await hass.async_add_executor_job(zoneinfo.available_timezones)
+ )
return vol.Schema(
{
vol.Required(CONF_DIASPORA, default=DEFAULT_DIASPORA): BooleanSelector(),
@@ -75,9 +78,7 @@ def _get_data_schema(hass: HomeAssistant) -> vol.Schema:
vol.Optional(CONF_LOCATION, default=default_location): LocationSelector(),
vol.Optional(CONF_ELEVATION, default=hass.config.elevation): int,
vol.Optional(CONF_TIME_ZONE, default=hass.config.time_zone): SelectSelector(
- SelectSelectorConfig(
- options=sorted(zoneinfo.available_timezones()),
- )
+ SelectSelectorConfig(options=get_timezones, sort=True)
),
}
)
@@ -86,7 +87,7 @@ def _get_data_schema(hass: HomeAssistant) -> vol.Schema:
class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Jewish calendar."""
- VERSION = 1
+ VERSION = 2
@staticmethod
@callback
@@ -109,7 +110,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
- _get_data_schema(self.hass), user_input
+ await _get_data_schema(self.hass), user_input
),
)
@@ -121,7 +122,7 @@ class JewishCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
if not user_input:
return self.async_show_form(
data_schema=self.add_suggested_values_to_schema(
- _get_data_schema(self.hass),
+ await _get_data_schema(self.hass),
reconfigure_entry.data,
),
step_id="reconfigure",
diff --git a/homeassistant/components/jewish_calendar/const.py b/homeassistant/components/jewish_calendar/const.py
index 4af76a8927b..0d5455fcd86 100644
--- a/homeassistant/components/jewish_calendar/const.py
+++ b/homeassistant/components/jewish_calendar/const.py
@@ -2,6 +2,9 @@
DOMAIN = "jewish_calendar"
+ATTR_DATE = "date"
+ATTR_NUSACH = "nusach"
+
CONF_DIASPORA = "diaspora"
CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset"
CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset"
@@ -11,3 +14,5 @@ DEFAULT_CANDLE_LIGHT = 18
DEFAULT_DIASPORA = False
DEFAULT_HAVDALAH_OFFSET_MINUTES = 0
DEFAULT_LANGUAGE = "english"
+
+SERVICE_COUNT_OMER = "count_omer"
diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py
index 1d2a6e45c0a..2c031f0d160 100644
--- a/homeassistant/components/jewish_calendar/entity.py
+++ b/homeassistant/components/jewish_calendar/entity.py
@@ -3,6 +3,7 @@
from dataclasses import dataclass
from hdate import Location
+from hdate.translator import Language
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
@@ -17,7 +18,7 @@ type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData]
class JewishCalendarData:
"""Jewish Calendar runtime dataclass."""
- language: str
+ language: Language
diaspora: bool
location: Location
candle_lighting_offset: int
@@ -43,7 +44,6 @@ class JewishCalendarEntity(Entity):
)
data = config_entry.runtime_data
self._location = data.location
- self._hebrew = data.language == "hebrew"
self._language = data.language
self._candle_lighting_offset = data.candle_lighting_offset
self._havdalah_offset = data.havdalah_offset
diff --git a/homeassistant/components/jewish_calendar/icons.json b/homeassistant/components/jewish_calendar/icons.json
new file mode 100644
index 00000000000..24b922df7a2
--- /dev/null
+++ b/homeassistant/components/jewish_calendar/icons.json
@@ -0,0 +1,7 @@
+{
+ "services": {
+ "count_omer": {
+ "service": "mdi:counter"
+ }
+ }
+}
diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json
index aca45320002..877c4cf9a99 100644
--- a/homeassistant/components/jewish_calendar/manifest.json
+++ b/homeassistant/components/jewish_calendar/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/jewish_calendar",
"iot_class": "calculated",
"loggers": ["hdate"],
- "requirements": ["hdate==0.11.1"],
+ "requirements": ["hdate[astral]==1.0.3"],
"single_config_entry": true
}
diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py
index 5e02435ed06..78201d9e015 100644
--- a/homeassistant/components/jewish_calendar/sensor.py
+++ b/homeassistant/components/jewish_calendar/sensor.py
@@ -2,12 +2,13 @@
from __future__ import annotations
-from datetime import date as Date
+import datetime as dt
import logging
-from typing import Any, cast
+from typing import Any
-from hdate import HDate, HebrewDate, htables
-from hdate.zmanim import Zmanim
+from hdate import HDateInfo, Zmanim
+from hdate.holidays import HolidayDatabase
+from hdate.parasha import Parasha
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -16,7 +17,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import SUN_EVENT_SUNSET, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sun import get_astral_event_date
from homeassistant.util import dt as dt_util
@@ -59,83 +60,83 @@ INFO_SENSORS: tuple[SensorEntityDescription, ...] = (
TIME_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
- key="first_light",
+ key="alot_hashachar",
name="Alot Hashachar", # codespell:ignore alot
icon="mdi:weather-sunset-up",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
- key="talit",
+ key="talit_and_tefillin",
name="Talit and Tefillin",
icon="mdi:calendar-clock",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
- key="sunrise",
+ key="netz_hachama",
name="Hanetz Hachama",
icon="mdi:calendar-clock",
),
SensorEntityDescription(
- key="gra_end_shma",
+ key="sof_zman_shema_gra",
name='Latest time for Shma Gr"a',
icon="mdi:calendar-clock",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
- key="mga_end_shma",
+ key="sof_zman_shema_mga",
name='Latest time for Shma MG"A',
icon="mdi:calendar-clock",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
- key="gra_end_tfila",
+ key="sof_zman_tfilla_gra",
name='Latest time for Tefilla Gr"a',
icon="mdi:calendar-clock",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
- key="mga_end_tfila",
+ key="sof_zman_tfilla_mga",
name='Latest time for Tefilla MG"A',
icon="mdi:calendar-clock",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
- key="midday",
+ key="chatzot_hayom",
name="Chatzot Hayom",
icon="mdi:calendar-clock",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
- key="big_mincha",
+ key="mincha_gedola",
name="Mincha Gedola",
icon="mdi:calendar-clock",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
- key="small_mincha",
+ key="mincha_ketana",
name="Mincha Ketana",
icon="mdi:calendar-clock",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
- key="plag_mincha",
+ key="plag_hamincha",
name="Plag Hamincha",
icon="mdi:weather-sunset-down",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
- key="sunset",
+ key="shkia",
name="Shkia",
icon="mdi:weather-sunset",
),
SensorEntityDescription(
- key="first_stars",
+ key="tset_hakohavim_tsom",
name="T'set Hakochavim",
icon="mdi:weather-night",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
- key="three_stars",
+ key="tset_hakohavim_shabbat",
name="T'set Hakochavim, 3 stars",
icon="mdi:weather-night",
entity_registry_enabled_default=False,
@@ -168,7 +169,7 @@ TIME_SENSORS: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: JewishCalendarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Jewish calendar sensors ."""
sensors = [
@@ -196,6 +197,11 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity):
super().__init__(config_entry, description)
self._attrs: dict[str, str] = {}
+ async def async_added_to_hass(self) -> None:
+ """Call when entity is added to hass."""
+ await super().async_added_to_hass()
+ await self.async_update()
+
async def async_update(self) -> None:
"""Update the state of the sensor."""
now = dt_util.now()
@@ -212,7 +218,9 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity):
_LOGGER.debug("Now: %s Sunset: %s", now, sunset)
- daytime_date = HDate(today, diaspora=self._diaspora, hebrew=self._hebrew)
+ daytime_date = HDateInfo(
+ today, diaspora=self._diaspora, language=self._language
+ )
# The Jewish day starts after darkness (called "tzais") and finishes at
# sunset ("shkia"). The time in between is a gray area
@@ -238,14 +246,14 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity):
"New value for %s: %s", self.entity_description.key, self._attr_native_value
)
- def make_zmanim(self, date: Date) -> Zmanim:
+ def make_zmanim(self, date: dt.date) -> Zmanim:
"""Create a Zmanim object."""
return Zmanim(
date=date,
location=self._location,
candle_lighting_offset=self._candle_lighting_offset,
havdalah_offset=self._havdalah_offset,
- hebrew=self._hebrew,
+ language=self._language,
)
@property
@@ -254,43 +262,40 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity):
return self._attrs
def get_state(
- self, daytime_date: HDate, after_shkia_date: HDate, after_tzais_date: HDate
+ self,
+ daytime_date: HDateInfo,
+ after_shkia_date: HDateInfo,
+ after_tzais_date: HDateInfo,
) -> Any | None:
"""For a given type of sensor, return the state."""
# Terminology note: by convention in py-libhdate library, "upcoming"
# refers to "current" or "upcoming" dates.
if self.entity_description.key == "date":
- hdate = cast(HebrewDate, after_shkia_date.hdate)
- month = htables.MONTHS[hdate.month.value - 1]
+ hdate = after_shkia_date.hdate
+ hdate.month.set_language(self._language)
self._attrs = {
- "hebrew_year": hdate.year,
- "hebrew_month_name": month.hebrew if self._hebrew else month.english,
- "hebrew_day": hdate.day,
+ "hebrew_year": str(hdate.year),
+ "hebrew_month_name": str(hdate.month),
+ "hebrew_day": str(hdate.day),
}
- return after_shkia_date.hebrew_date
+ return after_shkia_date.hdate
if self.entity_description.key == "weekly_portion":
- self._attr_options = [
- (p.hebrew if self._hebrew else p.english) for p in htables.PARASHAOT
- ]
+ self._attr_options = list(Parasha)
# Compute the weekly portion based on the upcoming shabbat.
return after_tzais_date.upcoming_shabbat.parasha
if self.entity_description.key == "holiday":
- _id = _type = _type_id = ""
- _holiday_type = after_shkia_date.holiday_type
- if isinstance(_holiday_type, list):
- _id = ", ".join(after_shkia_date.holiday_name)
- _type = ", ".join([_htype.name for _htype in _holiday_type])
- _type_id = ", ".join([str(_htype.value) for _htype in _holiday_type])
- else:
- _id = after_shkia_date.holiday_name
- _type = _holiday_type.name
- _type_id = _holiday_type.value
- self._attrs = {"id": _id, "type": _type, "type_id": _type_id}
- self._attr_options = htables.get_all_holidays(self._language)
-
- return after_shkia_date.holiday_description
+ _holidays = after_shkia_date.holidays
+ _id = ", ".join(holiday.name for holiday in _holidays)
+ _type = ", ".join(
+ dict.fromkeys(_holiday.type.name for _holiday in _holidays)
+ )
+ self._attrs = {"id": _id, "type": _type}
+ self._attr_options = HolidayDatabase(self._diaspora).get_all_names(
+ self._language
+ )
+ return ", ".join(str(holiday) for holiday in _holidays) if _holidays else ""
if self.entity_description.key == "omer_count":
- return after_shkia_date.omer_day
+ return after_shkia_date.omer.total_days if after_shkia_date.omer else 0
if self.entity_description.key == "daf_yomi":
return daytime_date.daf_yomi
@@ -303,7 +308,10 @@ class JewishCalendarTimeSensor(JewishCalendarSensor):
_attr_device_class = SensorDeviceClass.TIMESTAMP
def get_state(
- self, daytime_date: HDate, after_shkia_date: HDate, after_tzais_date: HDate
+ self,
+ daytime_date: HDateInfo,
+ after_shkia_date: HDateInfo,
+ after_tzais_date: HDateInfo,
) -> Any | None:
"""For a given type of sensor, return the state."""
if self.entity_description.key == "upcoming_shabbat_candle_lighting":
@@ -325,5 +333,5 @@ class JewishCalendarTimeSensor(JewishCalendarSensor):
)
return times.havdalah
- times = self.make_zmanim(dt_util.now()).zmanim
- return times[self.entity_description.key]
+ times = self.make_zmanim(dt_util.now().date())
+ return times.zmanim[self.entity_description.key].local
diff --git a/homeassistant/components/jewish_calendar/service.py b/homeassistant/components/jewish_calendar/service.py
new file mode 100644
index 00000000000..7c3c7a21f1c
--- /dev/null
+++ b/homeassistant/components/jewish_calendar/service.py
@@ -0,0 +1,63 @@
+"""Services for Jewish Calendar."""
+
+import datetime
+from typing import cast
+
+from hdate import HebrewDate
+from hdate.omer import Nusach, Omer
+from hdate.translator import Language
+import voluptuous as vol
+
+from homeassistant.const import CONF_LANGUAGE
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.selector import LanguageSelector, LanguageSelectorConfig
+
+from .const import ATTR_DATE, ATTR_NUSACH, DOMAIN, SERVICE_COUNT_OMER
+
+SUPPORTED_LANGUAGES = {"en": "english", "fr": "french", "he": "hebrew"}
+OMER_SCHEMA = vol.Schema(
+ {
+ vol.Required(ATTR_DATE, default=datetime.date.today): cv.date,
+ vol.Required(ATTR_NUSACH, default="sfarad"): vol.In(
+ [nusach.name.lower() for nusach in Nusach]
+ ),
+ vol.Required(CONF_LANGUAGE, default="he"): LanguageSelector(
+ LanguageSelectorConfig(languages=list(SUPPORTED_LANGUAGES.keys()))
+ ),
+ }
+)
+
+
+def async_setup_services(hass: HomeAssistant) -> None:
+ """Set up the Jewish Calendar services."""
+
+ async def get_omer_count(call: ServiceCall) -> ServiceResponse:
+ """Return the Omer blessing for a given date."""
+ hebrew_date = HebrewDate.from_gdate(call.data["date"])
+ nusach = Nusach[call.data["nusach"].upper()]
+
+ # Currently Omer only supports Hebrew, English, and French and requires
+ # the full language name
+ language = cast(Language, SUPPORTED_LANGUAGES[call.data[CONF_LANGUAGE]])
+
+ omer = Omer(date=hebrew_date, nusach=nusach, language=language)
+ return {
+ "message": str(omer.count_str()),
+ "weeks": omer.week,
+ "days": omer.day,
+ "total_days": omer.total_days,
+ }
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_COUNT_OMER,
+ get_omer_count,
+ schema=OMER_SCHEMA,
+ supports_response=SupportsResponse.ONLY,
+ )
diff --git a/homeassistant/components/jewish_calendar/services.yaml b/homeassistant/components/jewish_calendar/services.yaml
new file mode 100644
index 00000000000..894fa30fee3
--- /dev/null
+++ b/homeassistant/components/jewish_calendar/services.yaml
@@ -0,0 +1,29 @@
+count_omer:
+ fields:
+ date:
+ required: true
+ example: "2025-04-14"
+ selector:
+ date:
+ nusach:
+ required: true
+ example: "sfarad"
+ default: "sfarad"
+ selector:
+ select:
+ translation_key: "nusach"
+ options:
+ - "sfarad"
+ - "ashkenaz"
+ - "adot_mizrah"
+ - "italian"
+ language:
+ required: true
+ default: "he"
+ example: "he"
+ selector:
+ language:
+ languages:
+ - "en"
+ - "he"
+ - "fr"
diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json
index 1b7b86c0056..933d77d2188 100644
--- a/homeassistant/components/jewish_calendar/strings.json
+++ b/homeassistant/components/jewish_calendar/strings.json
@@ -3,9 +3,9 @@
"sensor": {
"hebrew_date": {
"state_attributes": {
- "hebrew_year": { "name": "Hebrew Year" },
- "hebrew_month_name": { "name": "Hebrew Month Name" },
- "hebrew_day": { "name": "Hebrew Day" }
+ "hebrew_year": { "name": "Hebrew year" },
+ "hebrew_month_name": { "name": "Hebrew month name" },
+ "hebrew_day": { "name": "Hebrew day" }
}
}
}
@@ -16,10 +16,10 @@
"data": {
"name": "[%key:common::config_flow::data::name%]",
"diaspora": "Outside of Israel?",
- "language": "Language for Holidays and Dates",
+ "language": "Language for holidays and dates",
"location": "[%key:common::config_flow::data::location%]",
"elevation": "[%key:common::config_flow::data::elevation%]",
- "time_zone": "Time Zone"
+ "time_zone": "Time zone"
},
"data_description": {
"time_zone": "If you specify a location, make sure to specify the time zone for correct calendar times calculations"
@@ -36,7 +36,7 @@
"init": {
"title": "Configure options for Jewish Calendar",
"data": {
- "candle_lighting_minutes_before_sunset": "Minutes before sunset for candle lighthing",
+ "candle_lighting_minutes_before_sunset": "Minutes before sunset for candle lighting",
"havdalah_minutes_after_sunset": "Minutes after sunset for Havdalah"
},
"data_description": {
@@ -45,5 +45,35 @@
}
}
}
+ },
+ "selector": {
+ "nusach": {
+ "options": {
+ "sfarad": "Sfarad",
+ "ashkenaz": "Ashkenaz",
+ "adot_mizrah": "Adot Mizrah",
+ "italian": "Italian"
+ }
+ }
+ },
+ "services": {
+ "count_omer": {
+ "name": "Count the Omer",
+ "description": "Returns the phrase for counting the Omer on a given date.",
+ "fields": {
+ "date": {
+ "name": "Date",
+ "description": "Date to count the Omer for."
+ },
+ "nusach": {
+ "name": "Nusach",
+ "description": "Nusach to count the Omer in."
+ },
+ "language": {
+ "name": "[%key:common::config_flow::data::language%]",
+ "description": "Language to count the Omer in."
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py
index 383d0d590c4..69323884f61 100644
--- a/homeassistant/components/juicenet/number.py
+++ b/homeassistant/components/juicenet/number.py
@@ -13,7 +13,7 @@ from homeassistant.components.number import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
@@ -43,7 +43,7 @@ NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JuiceNet Numbers."""
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py
index 1f0b815cd97..7bf0639f5d0 100644
--- a/homeassistant/components/juicenet/sensor.py
+++ b/homeassistant/components/juicenet/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
from .entity import JuiceNetDevice
@@ -70,7 +70,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JuiceNet Sensors."""
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/juicenet/switch.py b/homeassistant/components/juicenet/switch.py
index d800ac58c2c..9f34b7afdb3 100644
--- a/homeassistant/components/juicenet/switch.py
+++ b/homeassistant/components/juicenet/switch.py
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, JUICENET_API, JUICENET_COORDINATOR
from .entity import JuiceNetDevice
@@ -14,7 +14,7 @@ from .entity import JuiceNetDevice
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JuiceNet switches."""
juicenet_data = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/justnimbus/sensor.py b/homeassistant/components/justnimbus/sensor.py
index c2c22307371..1e288e272cd 100644
--- a/homeassistant/components/justnimbus/sensor.py
+++ b/homeassistant/components/justnimbus/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import JustNimbusCoordinator
@@ -101,7 +101,9 @@ SENSOR_TYPES = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JustNimbus sensor."""
coordinator: JustNimbusCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/jvc_projector/binary_sensor.py b/homeassistant/components/jvc_projector/binary_sensor.py
index 0e1d8ce00a3..7ae76298839 100644
--- a/homeassistant/components/jvc_projector/binary_sensor.py
+++ b/homeassistant/components/jvc_projector/binary_sensor.py
@@ -6,7 +6,7 @@ from jvcprojector import const
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
from .entity import JvcProjectorEntity
@@ -15,7 +15,9 @@ ON_STATUS = (const.ON, const.WARMING)
async def async_setup_entry(
- hass: HomeAssistant, entry: JVCConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: JVCConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JVC Projector platform from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py
index bbee5ca11f6..e1aff2fbb4c 100644
--- a/homeassistant/components/jvc_projector/remote.py
+++ b/homeassistant/components/jvc_projector/remote.py
@@ -12,7 +12,7 @@ from jvcprojector import const
from homeassistant.components.remote import RemoteEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import JVCConfigEntry
from .entity import JvcProjectorEntity
@@ -54,7 +54,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: JVCConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: JVCConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JVC Projector platform from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/jvc_projector/select.py b/homeassistant/components/jvc_projector/select.py
index 4b2cea3c3a0..b83695609cb 100644
--- a/homeassistant/components/jvc_projector/select.py
+++ b/homeassistant/components/jvc_projector/select.py
@@ -10,7 +10,7 @@ from jvcprojector import JvcProjector, const
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
from .entity import JvcProjectorEntity
@@ -40,7 +40,7 @@ SELECTS: Final[list[JvcProjectorSelectDescription]] = [
async def async_setup_entry(
hass: HomeAssistant,
entry: JVCConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JVC Projector platform from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/jvc_projector/sensor.py b/homeassistant/components/jvc_projector/sensor.py
index 5854e60c97a..7a7799bc4ee 100644
--- a/homeassistant/components/jvc_projector/sensor.py
+++ b/homeassistant/components/jvc_projector/sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import JVCConfigEntry, JvcProjectorDataUpdateCoordinator
from .entity import JvcProjectorEntity
@@ -34,7 +34,9 @@ JVC_SENSORS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: JVCConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: JVCConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the JVC Projector platform from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/kaleidescape/media_player.py b/homeassistant/components/kaleidescape/media_player.py
index 33acb899728..88e2e16bef2 100644
--- a/homeassistant/components/kaleidescape/media_player.py
+++ b/homeassistant/components/kaleidescape/media_player.py
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
- from homeassistant.helpers.entity_platform import AddEntitiesCallback
+ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
KALEIDESCAPE_PLAYING_STATES = [
@@ -38,7 +38,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform from a config entry."""
entities = [KaleidescapeMediaPlayer(hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id])]
diff --git a/homeassistant/components/kaleidescape/remote.py b/homeassistant/components/kaleidescape/remote.py
index 2d35ad2787f..ddafd52f220 100644
--- a/homeassistant/components/kaleidescape/remote.py
+++ b/homeassistant/components/kaleidescape/remote.py
@@ -18,11 +18,13 @@ if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
- from homeassistant.helpers.entity_platform import AddEntitiesCallback
+ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform from a config entry."""
entities = [KaleidescapeRemote(hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id])]
diff --git a/homeassistant/components/kaleidescape/sensor.py b/homeassistant/components/kaleidescape/sensor.py
index 5520943e683..8bff5df2e70 100644
--- a/homeassistant/components/kaleidescape/sensor.py
+++ b/homeassistant/components/kaleidescape/sensor.py
@@ -18,7 +18,7 @@ if TYPE_CHECKING:
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
- from homeassistant.helpers.entity_platform import AddEntitiesCallback
+ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
@@ -131,7 +131,9 @@ SENSOR_TYPES: tuple[KaleidescapeSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform from a config entry."""
device: KaleidescapeDevice = hass.data[KALEIDESCAPE_DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/keenetic_ndms2/binary_sensor.py b/homeassistant/components/keenetic_ndms2/binary_sensor.py
index cb7d83b9238..4d1b5da3552 100644
--- a/homeassistant/components/keenetic_ndms2/binary_sensor.py
+++ b/homeassistant/components/keenetic_ndms2/binary_sensor.py
@@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import KeeneticRouter
from .const import DOMAIN, ROUTER
@@ -16,7 +16,7 @@ from .const import DOMAIN, ROUTER
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for Keenetic NDMS2 component."""
router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER]
diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py
index 0f5166e16dd..4143611d6af 100644
--- a/homeassistant/components/keenetic_ndms2/device_tracker.py
+++ b/homeassistant/components/keenetic_ndms2/device_tracker.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN, ROUTER
@@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for Keenetic NDMS2 component."""
router: KeeneticRouter = hass.data[DOMAIN][config_entry.entry_id][ROUTER]
diff --git a/homeassistant/components/kegtron/sensor.py b/homeassistant/components/kegtron/sensor.py
index e0638fccea0..602c61f96ff 100644
--- a/homeassistant/components/kegtron/sensor.py
+++ b/homeassistant/components/kegtron/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
@@ -110,7 +110,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Kegtron BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/keymitt_ble/strings.json b/homeassistant/components/keymitt_ble/strings.json
index 2a1f428603e..5e7e895d222 100644
--- a/homeassistant/components/keymitt_ble/strings.json
+++ b/homeassistant/components/keymitt_ble/strings.json
@@ -34,7 +34,7 @@
"services": {
"calibrate": {
"name": "Calibrate",
- "description": "Calibration - Set depth, press & hold duration, and operation mode. Warning - this will send a push command to the device.",
+ "description": "Sets the depth, press or release duration, and operation mode. Warning - this will send a push command to the device.",
"fields": {
"entity_id": {
"name": "Entity",
@@ -42,15 +42,15 @@
},
"depth": {
"name": "Depth",
- "description": "Depth in percent."
+ "description": "How far to extend the push arm."
},
"duration": {
"name": "Duration",
- "description": "Duration in seconds."
+ "description": "How long to press or release."
},
"mode": {
"name": "[%key:common::config_flow::data::mode%]",
- "description": "Normal | invert | toggle."
+ "description": "The operation mode of the arm."
}
}
}
diff --git a/homeassistant/components/keymitt_ble/switch.py b/homeassistant/components/keymitt_ble/switch.py
index ca458c5020f..57d3af98062 100644
--- a/homeassistant/components/keymitt_ble/switch.py
+++ b/homeassistant/components/keymitt_ble/switch.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.typing import VolDictType
@@ -29,7 +29,9 @@ CALIBRATE_SCHEMA: VolDictType = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MicroBot based on a config entry."""
coordinator: MicroBotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py
index 09a72fc529c..2f876ca855d 100644
--- a/homeassistant/components/kitchen_sink/__init__.py
+++ b/homeassistant/components/kitchen_sink/__init__.py
@@ -12,14 +12,24 @@ from random import random
import voluptuous as vol
from homeassistant.components.recorder import DOMAIN as RECORDER_DOMAIN, get_instance
-from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
+from homeassistant.components.recorder.models import (
+ StatisticData,
+ StatisticMeanType,
+ StatisticMetaData,
+)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
async_import_statistics,
get_last_statistics,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume
+from homeassistant.const import (
+ DEGREE,
+ Platform,
+ UnitOfEnergy,
+ UnitOfTemperature,
+ UnitOfVolume,
+)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
@@ -70,11 +80,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
-async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set the config entry up."""
+ if "recorder" in hass.config.components:
+ # Insert stats for mean_type_changed issue
+ await _insert_wrong_wind_direction_statistics(hass)
+
# Set up demo platforms with config entry
await hass.config_entries.async_forward_entry_setups(
- config_entry, COMPONENTS_WITH_DEMO_PLATFORM
+ entry, COMPONENTS_WITH_DEMO_PLATFORM
)
# Create issues
@@ -85,7 +99,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
await _insert_statistics(hass)
# Start a reauth flow
- config_entry.async_start_reauth(hass)
+ entry.async_start_reauth(hass)
+
+ entry.async_on_unload(entry.add_update_listener(_async_update_listener))
# Notify backup listeners
hass.async_create_task(_notify_backup_listeners(hass), eager_start=False)
@@ -93,6 +109,11 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
return True
+async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Handle update."""
+ await hass.config_entries.async_reload(entry.entry_id)
+
+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload config entry."""
# Notify backup listeners
@@ -226,7 +247,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": "Outdoor temperature",
"statistic_id": f"{DOMAIN}:temperature_outdoor",
"unit_of_measurement": UnitOfTemperature.CELSIUS,
- "has_mean": True,
+ "mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
}
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
@@ -239,7 +260,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": "Energy consumption 1",
"statistic_id": f"{DOMAIN}:energy_consumption_kwh",
"unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR,
- "has_mean": False,
+ "mean_type": StatisticMeanType.NONE,
"has_sum": True,
}
await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 1)
@@ -251,7 +272,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": "Energy consumption 2",
"statistic_id": f"{DOMAIN}:energy_consumption_mwh",
"unit_of_measurement": UnitOfEnergy.MEGA_WATT_HOUR,
- "has_mean": False,
+ "mean_type": StatisticMeanType.NONE,
"has_sum": True,
}
await _insert_sum_statistics(
@@ -265,7 +286,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": "Gas consumption 1",
"statistic_id": f"{DOMAIN}:gas_consumption_m3",
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
- "has_mean": False,
+ "mean_type": StatisticMeanType.NONE,
"has_sum": True,
}
await _insert_sum_statistics(
@@ -279,7 +300,7 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
"name": "Gas consumption 2",
"statistic_id": f"{DOMAIN}:gas_consumption_ft3",
"unit_of_measurement": UnitOfVolume.CUBIC_FEET,
- "has_mean": False,
+ "mean_type": StatisticMeanType.NONE,
"has_sum": True,
}
await _insert_sum_statistics(hass, metadata, yesterday_midnight, today_midnight, 15)
@@ -289,9 +310,9 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
metadata = {
"source": RECORDER_DOMAIN,
"name": None,
- "statistic_id": "sensor.statistics_issue_1",
+ "statistic_id": "sensor.statistics_issues_issue_1",
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
- "has_mean": True,
+ "mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
}
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
@@ -301,9 +322,9 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
metadata = {
"source": RECORDER_DOMAIN,
"name": None,
- "statistic_id": "sensor.statistics_issue_2",
+ "statistic_id": "sensor.statistics_issues_issue_2",
"unit_of_measurement": "cats",
- "has_mean": True,
+ "mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
}
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
@@ -313,9 +334,9 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
metadata = {
"source": RECORDER_DOMAIN,
"name": None,
- "statistic_id": "sensor.statistics_issue_3",
+ "statistic_id": "sensor.statistics_issues_issue_3",
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
- "has_mean": True,
+ "mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
}
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
@@ -325,10 +346,30 @@ async def _insert_statistics(hass: HomeAssistant) -> None:
metadata = {
"source": RECORDER_DOMAIN,
"name": None,
- "statistic_id": "sensor.statistics_issue_4",
+ "statistic_id": "sensor.statistics_issues_issue_4",
"unit_of_measurement": UnitOfVolume.CUBIC_METERS,
- "has_mean": True,
+ "mean_type": StatisticMeanType.ARITHMETIC,
"has_sum": False,
}
statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 15, 1)
async_import_statistics(hass, metadata, statistics)
+
+
+async def _insert_wrong_wind_direction_statistics(hass: HomeAssistant) -> None:
+ """Insert some fake wind direction statistics."""
+ now = dt_util.now()
+ yesterday = now - datetime.timedelta(days=1)
+ yesterday_midnight = yesterday.replace(hour=0, minute=0, second=0, microsecond=0)
+ today_midnight = yesterday_midnight + datetime.timedelta(days=1)
+
+ # Add some statistics required to raise the mean_type_changed issue later
+ metadata: StatisticMetaData = {
+ "source": RECORDER_DOMAIN,
+ "name": None,
+ "statistic_id": "sensor.statistics_issues_issue_5",
+ "unit_of_measurement": DEGREE,
+ "mean_type": StatisticMeanType.ARITHMETIC,
+ "has_sum": False,
+ }
+ statistics = _generate_mean_statistics(yesterday_midnight, today_midnight, 0, 360)
+ async_import_statistics(hass, metadata, statistics)
diff --git a/homeassistant/components/kitchen_sink/backup.py b/homeassistant/components/kitchen_sink/backup.py
index 44ac0456105..46b204845ad 100644
--- a/homeassistant/components/kitchen_sink/backup.py
+++ b/homeassistant/components/kitchen_sink/backup.py
@@ -7,7 +7,13 @@ from collections.abc import AsyncIterator, Callable, Coroutine
import logging
from typing import Any
-from homeassistant.components.backup import AddonInfo, AgentBackup, BackupAgent, Folder
+from homeassistant.components.backup import (
+ AddonInfo,
+ AgentBackup,
+ BackupAgent,
+ BackupNotFound,
+ Folder,
+)
from homeassistant.core import HomeAssistant, callback
from . import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
@@ -110,9 +116,9 @@ class KitchenSinkBackupAgent(BackupAgent):
self,
backup_id: str,
**kwargs: Any,
- ) -> AgentBackup | None:
+ ) -> AgentBackup:
"""Return a backup."""
for backup in self._uploads:
if backup.backup_id == backup_id:
return backup
- return None
+ raise BackupNotFound(f"Backup {backup_id} not found")
diff --git a/homeassistant/components/kitchen_sink/button.py b/homeassistant/components/kitchen_sink/button.py
index 5c62c4b32d1..1ee9bd78095 100644
--- a/homeassistant/components/kitchen_sink/button.py
+++ b/homeassistant/components/kitchen_sink/button.py
@@ -7,7 +7,7 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -15,7 +15,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo button platform."""
async_add_entities(
diff --git a/homeassistant/components/kitchen_sink/config_flow.py b/homeassistant/components/kitchen_sink/config_flow.py
index 019d1dddcad..aa722d27944 100644
--- a/homeassistant/components/kitchen_sink/config_flow.py
+++ b/homeassistant/components/kitchen_sink/config_flow.py
@@ -12,9 +12,12 @@ from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
+ ConfigSubentryFlow,
OptionsFlow,
+ SubentryFlowResult,
)
from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
from . import DOMAIN
@@ -35,6 +38,14 @@ class KitchenSinkConfigFlow(ConfigFlow, domain=DOMAIN):
"""Get the options flow for this handler."""
return OptionsFlowHandler()
+ @classmethod
+ @callback
+ def async_get_supported_subentry_types(
+ cls, config_entry: ConfigEntry
+ ) -> dict[str, type[ConfigSubentryFlow]]:
+ """Return subentries supported by this handler."""
+ return {"entity": SubentryFlowHandler}
+
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Set the config entry up from yaml."""
return self.async_create_entry(title="Kitchen Sink", data=import_data)
@@ -70,27 +81,84 @@ class OptionsFlowHandler(OptionsFlow):
if user_input is not None:
return self.async_create_entry(data=self.config_entry.options | user_input)
+ data_schema = vol.Schema(
+ {
+ vol.Required("section_1"): data_entry_flow.section(
+ vol.Schema(
+ {
+ vol.Optional(
+ CONF_BOOLEAN,
+ default=self.config_entry.options.get(
+ CONF_BOOLEAN, False
+ ),
+ ): bool,
+ vol.Optional(CONF_INT): cv.positive_int,
+ }
+ ),
+ {"collapsed": False},
+ ),
+ }
+ )
+ self.add_suggested_values_to_schema(
+ data_schema,
+ {"section_1": {"int": self.config_entry.options.get(CONF_INT, 10)}},
+ )
+
+ return self.async_show_form(step_id="options_1", data_schema=data_schema)
+
+
+class SubentryFlowHandler(ConfigSubentryFlow):
+ """Handle subentry flow."""
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """User flow to create a sensor subentry."""
+ return await self.async_step_add_sensor()
+
+ async def async_step_add_sensor(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Add a new sensor."""
+ if user_input is not None:
+ title = user_input.pop("name")
+ return self.async_create_entry(data=user_input, title=title)
+
return self.async_show_form(
- step_id="options_1",
+ step_id="add_sensor",
data_schema=vol.Schema(
{
- vol.Required("section_1"): data_entry_flow.section(
- vol.Schema(
- {
- vol.Optional(
- CONF_BOOLEAN,
- default=self.config_entry.options.get(
- CONF_BOOLEAN, False
- ),
- ): bool,
- vol.Optional(
- CONF_INT,
- default=self.config_entry.options.get(CONF_INT, 10),
- ): int,
- }
- ),
- {"collapsed": False},
- ),
+ vol.Required("name"): str,
+ vol.Required("state"): int,
+ }
+ ),
+ )
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Reconfigure a sensor subentry."""
+ return await self.async_step_reconfigure_sensor()
+
+ async def async_step_reconfigure_sensor(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Reconfigure a sensor."""
+ if user_input is not None:
+ title = user_input.pop("name")
+ return self.async_update_and_abort(
+ self._get_entry(),
+ self._get_reconfigure_subentry(),
+ data=user_input,
+ title=title,
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure_sensor",
+ data_schema=vol.Schema(
+ {
+ vol.Required("name"): str,
+ vol.Required("state"): int,
}
),
)
diff --git a/homeassistant/components/kitchen_sink/image.py b/homeassistant/components/kitchen_sink/image.py
index 504b36464f5..130317f4bc5 100644
--- a/homeassistant/components/kitchen_sink/image.py
+++ b/homeassistant/components/kitchen_sink/image.py
@@ -7,7 +7,10 @@ from pathlib import Path
from homeassistant.components.image import ImageEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -35,7 +38,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Everything but the Kitchen Sink config entry."""
await async_setup_platform(hass, {}, async_add_entities)
diff --git a/homeassistant/components/kitchen_sink/lawn_mower.py b/homeassistant/components/kitchen_sink/lawn_mower.py
index 51814fb262d..18a3f3dee77 100644
--- a/homeassistant/components/kitchen_sink/lawn_mower.py
+++ b/homeassistant/components/kitchen_sink/lawn_mower.py
@@ -9,7 +9,10 @@ from homeassistant.components.lawn_mower import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -71,7 +74,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Everything but the Kitchen Sink config entry."""
await async_setup_platform(hass, {}, async_add_entities)
diff --git a/homeassistant/components/kitchen_sink/lock.py b/homeassistant/components/kitchen_sink/lock.py
index 80ecc57d0d9..63566482cdf 100644
--- a/homeassistant/components/kitchen_sink/lock.py
+++ b/homeassistant/components/kitchen_sink/lock.py
@@ -7,7 +7,10 @@ from typing import Any
from homeassistant.components.lock import LockEntity, LockEntityFeature, LockState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -49,7 +52,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Everything but the Kitchen Sink config entry."""
await async_setup_platform(hass, {}, async_add_entities)
diff --git a/homeassistant/components/kitchen_sink/notify.py b/homeassistant/components/kitchen_sink/notify.py
index fb34a36f0b7..be5bad58109 100644
--- a/homeassistant/components/kitchen_sink/notify.py
+++ b/homeassistant/components/kitchen_sink/notify.py
@@ -7,7 +7,7 @@ from homeassistant.components.notify import NotifyEntity, NotifyEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -15,7 +15,7 @@ from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo notify entity platform."""
async_add_entities(
diff --git a/homeassistant/components/kitchen_sink/sensor.py b/homeassistant/components/kitchen_sink/sensor.py
index 95e56c276e4..04cb833f0df 100644
--- a/homeassistant/components/kitchen_sink/sensor.py
+++ b/homeassistant/components/kitchen_sink/sensor.py
@@ -8,10 +8,10 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import UnitOfPower
+from homeassistant.const import DEGREE, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED, StateType, UndefinedType
from . import DOMAIN
@@ -21,7 +21,7 @@ from .device import async_create_device
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Everything but the Kitchen Sink config entry."""
async_create_device(
@@ -87,9 +87,36 @@ async def async_setup_entry(
state_class=None,
unit_of_measurement=UnitOfPower.WATT,
),
+ DemoSensor(
+ device_unique_id="statistics_issues",
+ unique_id="statistics_issue_5",
+ device_name="Statistics issues",
+ entity_name="Issue 5",
+ state=100,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
+ unit_of_measurement=DEGREE,
+ ),
]
)
+ for subentry_id, subentry in config_entry.subentries.items():
+ async_add_entities(
+ [
+ DemoSensor(
+ device_unique_id=subentry_id,
+ unique_id=subentry_id,
+ device_name=subentry.title,
+ entity_name=None,
+ state=subentry.data["state"],
+ device_class=None,
+ state_class=None,
+ unit_of_measurement=None,
+ )
+ ],
+ config_subentry_id=subentry_id,
+ )
+
class DemoSensor(SensorEntity):
"""Representation of a Demo sensor."""
diff --git a/homeassistant/components/kitchen_sink/strings.json b/homeassistant/components/kitchen_sink/strings.json
index c03f909e617..e0cdf75b707 100644
--- a/homeassistant/components/kitchen_sink/strings.json
+++ b/homeassistant/components/kitchen_sink/strings.json
@@ -9,6 +9,31 @@
}
}
},
+ "config_subentries": {
+ "entity": {
+ "step": {
+ "add_sensor": {
+ "description": "Configure the new sensor",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "state": "Initial state"
+ }
+ },
+ "reconfigure_sensor": {
+ "description": "Reconfigure the sensor",
+ "data": {
+ "name": "[%key:component::kitchen_sink::config_subentries::entity::step::reconfigure_sensor::data::state%]",
+ "state": "Initial state"
+ }
+ }
+ },
+ "initiate_flow": {
+ "user": "Add sensor",
+ "reconfigure": "Reconfigure sensor"
+ },
+ "entry_type": "Sensor"
+ }
+ },
"options": {
"step": {
"init": {
diff --git a/homeassistant/components/kitchen_sink/switch.py b/homeassistant/components/kitchen_sink/switch.py
index 68a8312b496..45d3cb14eca 100644
--- a/homeassistant/components/kitchen_sink/switch.py
+++ b/homeassistant/components/kitchen_sink/switch.py
@@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .device import async_create_device
@@ -17,7 +17,7 @@ from .device import async_create_device
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the demo switch platform."""
async_create_device(
diff --git a/homeassistant/components/kitchen_sink/weather.py b/homeassistant/components/kitchen_sink/weather.py
index e94e823c692..a6b7cc69d05 100644
--- a/homeassistant/components/kitchen_sink/weather.py
+++ b/homeassistant/components/kitchen_sink/weather.py
@@ -26,7 +26,7 @@ from homeassistant.components.weather import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util import dt as dt_util
@@ -56,7 +56,7 @@ CONDITION_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Demo config entry."""
async_add_entities(
diff --git a/homeassistant/components/kmtronic/switch.py b/homeassistant/components/kmtronic/switch.py
index f00ecf8623c..b32f78b0e98 100644
--- a/homeassistant/components/kmtronic/switch.py
+++ b/homeassistant/components/kmtronic/switch.py
@@ -7,14 +7,16 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_REVERSE, DATA_COORDINATOR, DATA_HUB, DOMAIN, MANUFACTURER
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Config entry example."""
coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR]
diff --git a/homeassistant/components/knocki/event.py b/homeassistant/components/knocki/event.py
index 74dc5a0f64c..1b148f03f1c 100644
--- a/homeassistant/components/knocki/event.py
+++ b/homeassistant/components/knocki/event.py
@@ -5,7 +5,7 @@ from knocki import Event, EventType, KnockiClient, Trigger
from homeassistant.components.event import EventEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import KnockiConfigEntry
from .const import DOMAIN
@@ -14,7 +14,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: KnockiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Knocki from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
index fa3439b02f4..8ad16642e45 100644
--- a/homeassistant/components/knx/__init__.py
+++ b/homeassistant/components/knx/__init__.py
@@ -486,7 +486,7 @@ class KNXModule:
transcoder := DPTBase.parse_transcoder(dpt)
):
self._address_filter_transcoder.update(
- {_filter: transcoder for _filter in _filters}
+ dict.fromkeys(_filters, transcoder)
)
return self.xknx.telegram_queue.register_telegram_received_cb(
diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py
index c629860351c..c11612f79bf 100644
--- a/homeassistant/components/knx/binary_sensor.py
+++ b/homeassistant/components/knx/binary_sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.restore_state import RestoreEntity
@@ -45,7 +45,7 @@ from .storage.const import CONF_ENTITY, CONF_GA_PASSIVE, CONF_GA_SENSOR, CONF_GA
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the KNX binary sensor platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/button.py b/homeassistant/components/knx/button.py
index 5a2add5dcd7..538299a0556 100644
--- a/homeassistant/components/knx/button.py
+++ b/homeassistant/components/knx/button.py
@@ -8,7 +8,7 @@ from homeassistant import config_entries
from homeassistant.components.button import ButtonEntity
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_PAYLOAD, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNXModule
@@ -19,7 +19,7 @@ from .entity import KnxYamlEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the KNX binary sensor platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py
index e3bb63581e7..fdce5e0c470 100644
--- a/homeassistant/components/knx/climate.py
+++ b/homeassistant/components/knx/climate.py
@@ -34,7 +34,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNXModule
@@ -49,7 +49,7 @@ CONTROLLER_MODES_INV = {value: key for key, value in CONTROLLER_MODES.items()}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up climate(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py
index 2d38426a687..3c5752b990c 100644
--- a/homeassistant/components/knx/cover.py
+++ b/homeassistant/components/knx/cover.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNXModule
@@ -34,7 +34,7 @@ from .schema import CoverSchema
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up cover(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py
index 8f65ac8a952..7980e6a2bc3 100644
--- a/homeassistant/components/knx/date.py
+++ b/homeassistant/components/knx/date.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
@@ -36,7 +36,7 @@ from .entity import KnxYamlEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/datetime.py b/homeassistant/components/knx/datetime.py
index b75e1a14f67..7701597a8ef 100644
--- a/homeassistant/components/knx/datetime.py
+++ b/homeassistant/components/knx/datetime.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
@@ -37,7 +37,7 @@ from .entity import KnxYamlEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py
index 6585b848d8a..461e6f25879 100644
--- a/homeassistant/components/knx/expose.py
+++ b/homeassistant/components/knx/expose.py
@@ -30,6 +30,7 @@ from homeassistant.exceptions import TemplateError
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, StateType
+from homeassistant.util import dt as dt_util
from .const import CONF_RESPOND_TO_READ, KNX_ADDRESS
from .schema import ExposeSchema
@@ -217,7 +218,7 @@ class KNXExposeTime:
self.device = xknx_device_cls(
self.xknx,
name=expose_type.capitalize(),
- localtime=True,
+ localtime=dt_util.get_default_time_zone(),
group_address=config[KNX_ADDRESS],
)
diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py
index 75d91e48048..926b6458706 100644
--- a/homeassistant/components/knx/fan.py
+++ b/homeassistant/components/knx/fan.py
@@ -11,7 +11,7 @@ from homeassistant import config_entries
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.percentage import (
percentage_to_ranged_value,
@@ -30,7 +30,7 @@ DEFAULT_PERCENTAGE: Final = 50
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up fan(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py
index 33edc19fb1c..865cfdc6e25 100644
--- a/homeassistant/components/knx/light.py
+++ b/homeassistant/components/knx/light.py
@@ -22,7 +22,7 @@ from homeassistant.components.light import (
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.typing import ConfigType
@@ -61,7 +61,7 @@ from .storage.entity_store_schema import LightColorMode
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up light(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
index 86c050443e3..bde6dfa226f 100644
--- a/homeassistant/components/knx/manifest.json
+++ b/homeassistant/components/knx/manifest.json
@@ -10,9 +10,9 @@
"iot_class": "local_push",
"loggers": ["xknx", "xknxproject"],
"requirements": [
- "xknx==3.5.0",
- "xknxproject==3.8.1",
- "knx-frontend==2025.1.30.194235"
+ "xknx==3.6.0",
+ "xknxproject==3.8.2",
+ "knx-frontend==2025.3.8.214559"
],
"single_config_entry": true
}
diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py
index 245de2e937e..97980ab3d36 100644
--- a/homeassistant/components/knx/notify.py
+++ b/homeassistant/components/knx/notify.py
@@ -9,7 +9,7 @@ from homeassistant import config_entries
from homeassistant.components.notify import NotifyEntity
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNXModule
@@ -20,7 +20,7 @@ from .entity import KnxYamlEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up notify(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py
index 27e4ff743ab..67e8778accc 100644
--- a/homeassistant/components/knx/number.py
+++ b/homeassistant/components/knx/number.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNXModule
@@ -31,7 +31,7 @@ from .schema import NumberSchema
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py
index dfd226d72b1..f5361a6e7da 100644
--- a/homeassistant/components/knx/scene.py
+++ b/homeassistant/components/knx/scene.py
@@ -10,7 +10,7 @@ from homeassistant import config_entries
from homeassistant.components.scene import Scene
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNXModule
@@ -22,7 +22,7 @@ from .schema import SceneSchema
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up scene(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/select.py b/homeassistant/components/knx/select.py
index b499e3c601d..e80fa66f9d4 100644
--- a/homeassistant/components/knx/select.py
+++ b/homeassistant/components/knx/select.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
@@ -36,7 +36,7 @@ from .schema import SelectSchema
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up select(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py
index fa4911aa4b7..8e537ea234e 100644
--- a/homeassistant/components/knx/sensor.py
+++ b/homeassistant/components/knx/sensor.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, StateType
from homeassistant.util.enum import try_parse_enum
@@ -112,7 +112,7 @@ SYSTEM_ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py
index f0f760180f4..fc28e0850ed 100644
--- a/homeassistant/components/knx/services.py
+++ b/homeassistant/components/knx/services.py
@@ -126,7 +126,7 @@ async def service_event_register_modify(call: ServiceCall) -> None:
transcoder := DPTBase.parse_transcoder(dpt)
):
knx_module.group_address_transcoder.update(
- {_address: transcoder for _address in group_addresses}
+ dict.fromkeys(group_addresses, transcoder)
)
for group_address in group_addresses:
if group_address in knx_module.knx_event_callback.group_addresses:
diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py
index d99ffa86f52..cde18a181ec 100644
--- a/homeassistant/components/knx/storage/entity_store_schema.py
+++ b/homeassistant/components/knx/storage/entity_store_schema.py
@@ -114,7 +114,7 @@ BINARY_SENSOR_SCHEMA = vol.Schema(
),
vol.Optional(CONF_RESET_AFTER): selector.NumberSelector(
selector.NumberSelectorConfig(
- min=0, max=10, step=0.1, unit_of_measurement="s"
+ min=0, max=600, step=0.1, unit_of_measurement="s"
)
),
},
diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json
index dadc8e84796..737cc2d8b2d 100644
--- a/homeassistant/components/knx/strings.json
+++ b/homeassistant/components/knx/strings.json
@@ -315,11 +315,11 @@
"preset_mode": {
"name": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::name%]",
"state": {
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
+ "building_protection": "Building protection",
"comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
- "standby": "Standby",
"economy": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
- "building_protection": "Building protection"
+ "standby": "[%key:common::state::standby%]"
}
}
}
@@ -397,7 +397,7 @@
},
"response": {
"name": "Send as Response",
- "description": "If set to `True`, the telegram will be sent as a `GroupValueResponse` instead of a `GroupValueWrite`."
+ "description": "Whether the telegram should be sent as a `GroupValueResponse` instead of a `GroupValueWrite`."
}
}
},
@@ -425,7 +425,7 @@
},
"remove": {
"name": "Remove event registration",
- "description": "If `True` the group address(es) will be removed."
+ "description": "Whether the group address(es) will be removed."
}
}
},
@@ -455,7 +455,7 @@
},
"remove": {
"name": "Remove exposure",
- "description": "If `True` the exposure will be removed. Only `address` is required for removal."
+ "description": "Whether the exposure should be removed. Only the 'Address' field is required for removal."
}
}
},
diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py
index 725468cd6a9..730c5b788ff 100644
--- a/homeassistant/components/knx/switch.py
+++ b/homeassistant/components/knx/switch.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.restore_state import RestoreEntity
@@ -48,7 +48,7 @@ from .storage.const import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch(es) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/text.py b/homeassistant/components/knx/text.py
index 2256afadbd9..9c2bb88f92b 100644
--- a/homeassistant/components/knx/text.py
+++ b/homeassistant/components/knx/text.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
@@ -30,7 +30,7 @@ from .entity import KnxYamlEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor(s) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/time.py b/homeassistant/components/knx/time.py
index 1e82c324502..2c74ab18af3 100644
--- a/homeassistant/components/knx/time.py
+++ b/homeassistant/components/knx/time.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
@@ -36,7 +36,7 @@ from .entity import KnxYamlEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py
index a1e5c0efe48..342ab445611 100644
--- a/homeassistant/components/knx/weather.py
+++ b/homeassistant/components/knx/weather.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import KNXModule
@@ -28,7 +28,7 @@ from .schema import WeatherSchema
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch(es) for KNX platform."""
knx_module = hass.data[KNX_MODULE_KEY]
diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py
index bbddbd9f348..c4a2436548a 100644
--- a/homeassistant/components/kodi/media_player.py
+++ b/homeassistant/components/kodi/media_player.py
@@ -46,7 +46,10 @@ from homeassistant.helpers import (
entity_platform,
)
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.network import is_internal_request
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
@@ -206,7 +209,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Kodi media player platform."""
platform = entity_platform.async_get_current_platform()
diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py
index 75c381c53f2..3f1a27302d8 100644
--- a/homeassistant/components/konnected/binary_sensor.py
+++ b/homeassistant/components/konnected/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN as KONNECTED_DOMAIN
@@ -21,7 +21,7 @@ from .const import DOMAIN as KONNECTED_DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors attached to a Konnected device from a config entry."""
data = hass.data[KONNECTED_DOMAIN]
diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py
index 6191f98f179..cd36c217627 100644
--- a/homeassistant/components/konnected/sensor.py
+++ b/homeassistant/components/konnected/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW
@@ -43,7 +43,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors attached to a Konnected device from a config entry."""
data = hass.data[KONNECTED_DOMAIN]
diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json
index e1a6863a199..df92e014f12 100644
--- a/homeassistant/components/konnected/strings.json
+++ b/homeassistant/components/konnected/strings.json
@@ -2,19 +2,19 @@
"config": {
"step": {
"import_confirm": {
- "title": "Import Konnected Device",
- "description": "A Konnected Alarm Panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry."
+ "title": "Import Konnected device",
+ "description": "A Konnected alarm panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry."
},
"user": {
- "description": "Please enter the host information for your Konnected Panel.",
+ "description": "Please enter the host information for your Konnected panel.",
"data": {
"host": "[%key:common::config_flow::data::ip%]",
"port": "[%key:common::config_flow::data::port%]"
}
},
"confirm": {
- "title": "Konnected Device Ready",
- "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings."
+ "title": "Konnected device ready",
+ "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected alarm panel settings."
}
},
"error": {
@@ -45,8 +45,8 @@
}
},
"options_io_ext": {
- "title": "Configure Extended I/O",
- "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.",
+ "title": "Configure extended I/O",
+ "description": "Select the configuration of the remaining I/O below. You'll be able to configure detailed options in the next steps.",
"data": {
"8": "Zone 8",
"9": "Zone 9",
@@ -59,25 +59,25 @@
}
},
"options_binary": {
- "title": "Configure Binary Sensor",
+ "title": "Configure binary sensor",
"description": "{zone} options",
"data": {
- "type": "Binary Sensor Type",
+ "type": "Binary sensor type",
"name": "[%key:common::config_flow::data::name%]",
"inverse": "Invert the open/close state"
}
},
"options_digital": {
- "title": "Configure Digital Sensor",
+ "title": "Configure digital sensor",
"description": "[%key:component::konnected::options::step::options_binary::description%]",
"data": {
- "type": "Sensor Type",
+ "type": "Sensor type",
"name": "[%key:common::config_flow::data::name%]",
- "poll_interval": "Poll Interval (minutes)"
+ "poll_interval": "Poll interval (minutes)"
}
},
"options_switch": {
- "title": "Configure Switchable Output",
+ "title": "Configure switchable output",
"description": "{zone} options: state {state}",
"data": {
"name": "[%key:common::config_flow::data::name%]",
@@ -89,18 +89,18 @@
}
},
"options_misc": {
- "title": "Configure Misc",
+ "title": "Configure misc",
"description": "Please select the desired behavior for your panel",
"data": {
"discovery": "Respond to discovery requests on your network",
"blink": "Blink panel LED on when sending state change",
- "override_api_host": "Override default Home Assistant API host panel URL",
- "api_host": "Override API host URL"
+ "override_api_host": "Override default Home Assistant API host URL",
+ "api_host": "Custom API host URL"
}
}
},
"error": {
- "bad_host": "Invalid Override API host URL"
+ "bad_host": "Invalid custom API host URL"
},
"abort": {
"not_konn_panel": "[%key:component::konnected::config::abort::not_konn_panel%]"
diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py
index 65b99d623f1..58311502cbe 100644
--- a/homeassistant/components/konnected/switch.py
+++ b/homeassistant/components/konnected/switch.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_ACTIVATION,
@@ -33,7 +33,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches attached to a Konnected device from a config entry."""
data = hass.data[KONNECTED_DOMAIN]
diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py
index 059a09aadf2..7efb00cf8f4 100644
--- a/homeassistant/components/kostal_plenticore/number.py
+++ b/homeassistant/components/kostal_plenticore/number.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -73,7 +73,9 @@ NUMBER_SETTINGS_DATA = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Kostal Plenticore Number entities."""
plenticore = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py
index 941b1566609..61929b9fadc 100644
--- a/homeassistant/components/kostal_plenticore/select.py
+++ b/homeassistant/components/kostal_plenticore/select.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -42,7 +42,9 @@ SELECT_SETTINGS_DATA = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add kostal plenticore Select widget."""
plenticore: Plenticore = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py
index 567ade278c3..1be7fb06e7b 100644
--- a/homeassistant/components/kostal_plenticore/sensor.py
+++ b/homeassistant/components/kostal_plenticore/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -807,7 +807,9 @@ SENSOR_PROCESS_DATA = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add kostal plenticore Sensors."""
plenticore = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py
index 86d1fe2b9be..e3d5f830c78 100644
--- a/homeassistant/components/kostal_plenticore/switch.py
+++ b/homeassistant/components/kostal_plenticore/switch.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -48,7 +48,9 @@ SWITCH_SETTINGS_DATA = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add kostal plenticore Switch."""
plenticore = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/kraken/__init__.py b/homeassistant/components/kraken/__init__.py
index 9a90e77f2b6..c981f3fd438 100644
--- a/homeassistant/components/kraken/__init__.py
+++ b/homeassistant/components/kraken/__init__.py
@@ -145,7 +145,10 @@ class KrakenData:
await asyncio.sleep(CALL_RATE_LIMIT_SLEEP)
def _get_websocket_name_asset_pairs(self) -> str:
- return ",".join(wsname for wsname in self.tradable_asset_pairs.values())
+ return ",".join(
+ self.tradable_asset_pairs[tracked_pair]
+ for tracked_pair in self._config_entry.options[CONF_TRACKED_ASSET_PAIRS]
+ )
def set_update_interval(self, update_interval: int) -> None:
"""Set the coordinator update_interval to the supplied update_interval."""
diff --git a/homeassistant/components/kraken/sensor.py b/homeassistant/components/kraken/sensor.py
index 37fee795783..1d3f36d29e4 100644
--- a/homeassistant/components/kraken/sensor.py
+++ b/homeassistant/components/kraken/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -139,7 +139,7 @@ SENSOR_TYPES: tuple[KrakenSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add kraken entities from a config_entry."""
diff --git a/homeassistant/components/kulersky/__init__.py b/homeassistant/components/kulersky/__init__.py
index 6c8037bdafc..b123a4cc035 100644
--- a/homeassistant/components/kulersky/__init__.py
+++ b/homeassistant/components/kulersky/__init__.py
@@ -1,21 +1,31 @@
"""Kuler Sky lights integration."""
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
-from homeassistant.core import HomeAssistant
+import logging
-from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
+from homeassistant.components.bluetooth import async_ble_device_from_address
+from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry
+from homeassistant.const import CONF_ADDRESS, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
+
+from .const import DOMAIN
PLATFORMS = [Platform.LIGHT]
+_LOGGER = logging.getLogger(__name__)
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Kuler Sky from a config entry."""
- if DOMAIN not in hass.data:
- hass.data[DOMAIN] = {}
- if DATA_ADDRESSES not in hass.data[DOMAIN]:
- hass.data[DOMAIN][DATA_ADDRESSES] = set()
-
+ ble_device = async_ble_device_from_address(
+ hass, entry.data[CONF_ADDRESS], connectable=True
+ )
+ if not ble_device:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -23,11 +33,48 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- # Stop discovery
- unregister_discovery = hass.data[DOMAIN].pop(DATA_DISCOVERY_SUBSCRIPTION, None)
- if unregister_discovery:
- unregister_discovery()
-
- hass.data.pop(DOMAIN, None)
-
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+ """Migrate old entry."""
+ _LOGGER.debug("Migrating from version %s", config_entry.version)
+
+ # Version 1 was a single entry instance that started a bluetooth discovery
+ # thread to add devices. Version 2 has one config entry per device, and
+ # supports core bluetooth discovery
+ if config_entry.version == 1:
+ dev_reg = dr.async_get(hass)
+ devices = dev_reg.devices.get_devices_for_config_entry_id(config_entry.entry_id)
+
+ if len(devices) == 0:
+ _LOGGER.error("Unable to migrate; No devices registered")
+ return False
+
+ first_device = devices[0]
+ domain_identifiers = [i for i in first_device.identifiers if i[0] == DOMAIN]
+ address = next(iter(domain_identifiers))[1]
+ hass.config_entries.async_update_entry(
+ config_entry,
+ title=first_device.name or address,
+ data={CONF_ADDRESS: address},
+ unique_id=address,
+ version=2,
+ )
+
+ # Create new config flows for the remaining devices
+ for device in devices[1:]:
+ domain_identifiers = [i for i in device.identifiers if i[0] == DOMAIN]
+ address = next(iter(domain_identifiers))[1]
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_INTEGRATION_DISCOVERY},
+ data={CONF_ADDRESS: address},
+ )
+ )
+
+ _LOGGER.debug("Migration to version %s successful", config_entry.version)
+
+ return True
diff --git a/homeassistant/components/kulersky/config_flow.py b/homeassistant/components/kulersky/config_flow.py
index fca214dd9a3..f27d2ef0ea0 100644
--- a/homeassistant/components/kulersky/config_flow.py
+++ b/homeassistant/components/kulersky/config_flow.py
@@ -1,26 +1,143 @@
"""Config flow for Kuler Sky."""
import logging
+from typing import Any
+from bluetooth_data_tools import human_readable_name
import pykulersky
+import voluptuous as vol
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_flow
+from homeassistant.components.bluetooth import (
+ BluetoothServiceInfoBleak,
+ async_discovered_service_info,
+ async_last_service_info,
+)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_ADDRESS
-from .const import DOMAIN
+from .const import DOMAIN, EXPECTED_SERVICE_UUID
_LOGGER = logging.getLogger(__name__)
-async def _async_has_devices(hass: HomeAssistant) -> bool:
- """Return if there are devices that can be discovered."""
- # Check if there are any devices that can be discovered in the network.
- try:
- devices = await pykulersky.discover()
- except pykulersky.PykulerskyException as exc:
- _LOGGER.error("Unable to discover nearby Kuler Sky devices: %s", exc)
- return False
- return len(devices) > 0
+class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Kulersky."""
+ VERSION = 2
-config_entry_flow.register_discovery_flow(DOMAIN, "Kuler Sky", _async_has_devices)
+ def __init__(self) -> None:
+ """Initialize the config flow."""
+ self._discovery_info: BluetoothServiceInfoBleak | None = None
+ self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
+
+ async def async_step_integration_discovery(
+ self, discovery_info: dict[str, str]
+ ) -> ConfigFlowResult:
+ """Handle the integration discovery step.
+
+ The old version of the integration used to have multiple
+ device in a single config entry. This is now deprecated.
+ The integration discovery step is used to create config
+ entries for each device beyond the first one.
+ """
+ address: str = discovery_info[CONF_ADDRESS]
+ if service_info := async_last_service_info(self.hass, address):
+ title = human_readable_name(None, service_info.name, service_info.address)
+ else:
+ title = address
+ await self.async_set_unique_id(address)
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(
+ title=title,
+ data={CONF_ADDRESS: address},
+ )
+
+ async def async_step_bluetooth(
+ self, discovery_info: BluetoothServiceInfoBleak
+ ) -> ConfigFlowResult:
+ """Handle the bluetooth discovery step."""
+ await self.async_set_unique_id(discovery_info.address)
+ self._abort_if_unique_id_configured()
+ self._discovery_info = discovery_info
+ self.context["title_placeholders"] = {
+ "name": human_readable_name(
+ None, discovery_info.name, discovery_info.address
+ )
+ }
+ return await self.async_step_user()
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the user step to pick discovered device."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ address = user_input[CONF_ADDRESS]
+ discovery_info = self._discovered_devices[address]
+ local_name = human_readable_name(
+ None, discovery_info.name, discovery_info.address
+ )
+ await self.async_set_unique_id(
+ discovery_info.address, raise_on_progress=False
+ )
+ self._abort_if_unique_id_configured()
+
+ kulersky_light = None
+ try:
+ kulersky_light = pykulersky.Light(discovery_info.address)
+ await kulersky_light.connect()
+ except pykulersky.PykulerskyException:
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected error")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title=local_name,
+ data={
+ CONF_ADDRESS: discovery_info.address,
+ },
+ )
+ finally:
+ if kulersky_light:
+ await kulersky_light.disconnect()
+
+ if discovery := self._discovery_info:
+ self._discovered_devices[discovery.address] = discovery
+ else:
+ current_addresses = self._async_current_ids()
+ for discovery in async_discovered_service_info(self.hass):
+ if (
+ discovery.address in current_addresses
+ or discovery.address in self._discovered_devices
+ or EXPECTED_SERVICE_UUID not in discovery.service_uuids
+ ):
+ continue
+ self._discovered_devices[discovery.address] = discovery
+
+ if not self._discovered_devices:
+ return self.async_abort(reason="no_devices_found")
+
+ if self._discovery_info:
+ data_schema = vol.Schema(
+ {vol.Required(CONF_ADDRESS): self._discovery_info.address}
+ )
+ else:
+ data_schema = vol.Schema(
+ {
+ vol.Required(CONF_ADDRESS): vol.In(
+ {
+ service_info.address: (
+ f"{service_info.name} ({service_info.address})"
+ )
+ for service_info in self._discovered_devices.values()
+ }
+ ),
+ }
+ )
+ return self.async_show_form(
+ step_id="user",
+ data_schema=data_schema,
+ errors=errors,
+ )
diff --git a/homeassistant/components/kulersky/const.py b/homeassistant/components/kulersky/const.py
index 8d0b4380bb3..c735b4774f9 100644
--- a/homeassistant/components/kulersky/const.py
+++ b/homeassistant/components/kulersky/const.py
@@ -4,3 +4,5 @@ DOMAIN = "kulersky"
DATA_ADDRESSES = "addresses"
DATA_DISCOVERY_SUBSCRIPTION = "discovery_subscription"
+
+EXPECTED_SERVICE_UUID = "8d96a001-0002-64c2-0001-9acc4838521c"
diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py
index 552507ef50b..d6a45ed1ebe 100644
--- a/homeassistant/components/kulersky/light.py
+++ b/homeassistant/components/kulersky/light.py
@@ -2,12 +2,12 @@
from __future__ import annotations
-from datetime import timedelta
import logging
from typing import Any
import pykulersky
+from homeassistant.components import bluetooth
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_RGBW_COLOR,
@@ -15,51 +15,31 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DATA_ADDRESSES, DATA_DISCOVERY_SUBSCRIPTION, DOMAIN
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
-DISCOVERY_INTERVAL = timedelta(seconds=60)
-
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Kuler sky light devices."""
-
- async def discover(*args):
- """Attempt to discover new lights."""
- lights = await pykulersky.discover()
-
- # Filter out already discovered lights
- new_lights = [
- light
- for light in lights
- if light.address not in hass.data[DOMAIN][DATA_ADDRESSES]
- ]
-
- new_entities = []
- for light in new_lights:
- hass.data[DOMAIN][DATA_ADDRESSES].add(light.address)
- new_entities.append(KulerskyLight(light))
-
- async_add_entities(new_entities, update_before_add=True)
-
- # Start initial discovery
- hass.async_create_task(discover())
-
- # Perform recurring discovery of new devices
- hass.data[DOMAIN][DATA_DISCOVERY_SUBSCRIPTION] = async_track_time_interval(
- hass, discover, DISCOVERY_INTERVAL
+ ble_device = bluetooth.async_ble_device_from_address(
+ hass, config_entry.data[CONF_ADDRESS], connectable=True
)
+ entity = KulerskyLight(
+ config_entry.title,
+ config_entry.data[CONF_ADDRESS],
+ pykulersky.Light(ble_device),
+ )
+ async_add_entities([entity], update_before_add=True)
class KulerskyLight(LightEntity):
@@ -71,37 +51,30 @@ class KulerskyLight(LightEntity):
_attr_supported_color_modes = {ColorMode.RGBW}
_attr_color_mode = ColorMode.RGBW
- def __init__(self, light: pykulersky.Light) -> None:
+ def __init__(self, name: str, address: str, light: pykulersky.Light) -> None:
"""Initialize a Kuler Sky light."""
self._light = light
- self._attr_unique_id = light.address
+ self._attr_unique_id = address
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, light.address)},
+ identifiers={(DOMAIN, address)},
+ connections={(CONNECTION_BLUETOOTH, address)},
manufacturer="Brightech",
- name=light.name,
+ name=name,
)
- async def async_added_to_hass(self) -> None:
- """Run when entity about to be added to hass."""
- self.async_on_remove(
- self.hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_STOP, self.async_will_remove_from_hass
- )
- )
-
- async def async_will_remove_from_hass(self, *args) -> None:
+ async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
try:
await self._light.disconnect()
except pykulersky.PykulerskyException:
_LOGGER.debug(
- "Exception disconnected from %s", self._light.address, exc_info=True
+ "Exception disconnected from %s", self._attr_unique_id, exc_info=True
)
@property
- def is_on(self):
+ def is_on(self) -> bool | None:
"""Return true if light is on."""
- return self.brightness > 0
+ return self.brightness is not None and self.brightness > 0
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
@@ -133,11 +106,13 @@ class KulerskyLight(LightEntity):
rgbw = await self._light.get_color()
except pykulersky.PykulerskyException as exc:
if self._attr_available:
- _LOGGER.warning("Unable to connect to %s: %s", self._light.address, exc)
+ _LOGGER.warning(
+ "Unable to connect to %s: %s", self._attr_unique_id, exc
+ )
self._attr_available = False
return
if self._attr_available is False:
- _LOGGER.warning("Reconnected to %s", self._light.address)
+ _LOGGER.info("Reconnected to %s", self._attr_unique_id)
self._attr_available = True
brightness = max(rgbw)
diff --git a/homeassistant/components/kulersky/manifest.json b/homeassistant/components/kulersky/manifest.json
index e0d9ec4fe36..a838c47c698 100644
--- a/homeassistant/components/kulersky/manifest.json
+++ b/homeassistant/components/kulersky/manifest.json
@@ -1,10 +1,16 @@
{
"domain": "kulersky",
"name": "Kuler Sky",
+ "bluetooth": [
+ {
+ "service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c"
+ }
+ ],
"codeowners": ["@emlove"],
"config_flow": true,
+ "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/kulersky",
"iot_class": "local_polling",
"loggers": ["bleak", "pykulersky"],
- "requirements": ["pykulersky==0.5.2"]
+ "requirements": ["pykulersky==0.5.8"]
}
diff --git a/homeassistant/components/kulersky/strings.json b/homeassistant/components/kulersky/strings.json
index ad8f0f41ae7..959d7d0690a 100644
--- a/homeassistant/components/kulersky/strings.json
+++ b/homeassistant/components/kulersky/strings.json
@@ -1,13 +1,23 @@
{
"config": {
"step": {
- "confirm": {
- "description": "[%key:common::config_flow::description::confirm_setup%]"
+ "user": {
+ "data": {
+ "address": "[%key:common::config_flow::data::device%]"
+ }
}
},
"abort": {
- "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ }
+ },
+ "exceptions": {
+ "cannot_connect": {
+ "message": "[%key:common::config_flow::error::cannot_connect%]"
}
}
}
diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py
index 3d741e8f1a8..16d7e8b2bb8 100644
--- a/homeassistant/components/lacrosse_view/coordinator.py
+++ b/homeassistant/components/lacrosse_view/coordinator.py
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import SCAN_INTERVAL
+from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
@@ -75,16 +75,28 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]):
try:
# Fetch last hour of data
for sensor in self.devices:
- sensor.data = (
- await self.api.get_sensor_status(
- sensor=sensor,
- tz=self.hass.config.time_zone,
+ data = await self.api.get_sensor_status(
+ sensor=sensor,
+ tz=self.hass.config.time_zone,
+ )
+ _LOGGER.debug("Got data: %s", data)
+
+ if data_error := data.get("error"):
+ if data_error == "no_readings":
+ sensor.data = None
+ _LOGGER.debug("No readings for %s", sensor.name)
+ continue
+ _LOGGER.debug("Error: %s", data_error)
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="update_error"
)
- )["data"]["current"]
- _LOGGER.debug("Got data: %s", sensor.data)
+
+ sensor.data = data["data"]["current"]
except HTTPError as error:
- raise UpdateFailed from error
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="update_error"
+ ) from error
# Verify that we have permission to read the sensors
for sensor in self.devices:
diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py
index 624d97d482a..dde8dfd54a2 100644
--- a/homeassistant/components/lacrosse_view/sensor.py
+++ b/homeassistant/components/lacrosse_view/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -64,6 +64,7 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
value_fn=get_value,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ suggested_display_precision=2,
),
"Humidity": LaCrosseSensorEntityDescription(
key="Humidity",
@@ -71,6 +72,7 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
value_fn=get_value,
native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=2,
),
"HeatIndex": LaCrosseSensorEntityDescription(
key="HeatIndex",
@@ -79,6 +81,7 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
value_fn=get_value,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
+ suggested_display_precision=2,
),
"WindSpeed": LaCrosseSensorEntityDescription(
key="WindSpeed",
@@ -86,6 +89,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR,
device_class=SensorDeviceClass.WIND_SPEED,
+ suggested_display_precision=2,
),
"Rain": LaCrosseSensorEntityDescription(
key="Rain",
@@ -93,12 +97,16 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS,
device_class=SensorDeviceClass.PRECIPITATION,
+ suggested_display_precision=2,
),
"WindHeading": LaCrosseSensorEntityDescription(
key="WindHeading",
translation_key="wind_heading",
value_fn=get_value,
native_unit_of_measurement=DEGREE,
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
"WetDry": LaCrosseSensorEntityDescription(
key="WetDry",
@@ -117,6 +125,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
native_unit_of_measurement=UnitOfPressure.HPA,
+ suggested_display_precision=2,
),
"FeelsLike": LaCrosseSensorEntityDescription(
key="FeelsLike",
@@ -125,6 +134,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
+ suggested_display_precision=2,
),
"WindChill": LaCrosseSensorEntityDescription(
key="WindChill",
@@ -133,6 +143,7 @@ SENSOR_DESCRIPTIONS = {
value_fn=get_value,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
+ suggested_display_precision=2,
),
}
# map of API returned unit of measurement strings to their corresponding unit of measurement
@@ -149,7 +160,7 @@ UNIT_OF_MEASUREMENT_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LaCrosse View from a config entry."""
coordinator: DataUpdateCoordinator[list[Sensor]] = hass.data[DOMAIN][
diff --git a/homeassistant/components/lacrosse_view/strings.json b/homeassistant/components/lacrosse_view/strings.json
index 8dc27ba259e..c5d9a11e49a 100644
--- a/homeassistant/components/lacrosse_view/strings.json
+++ b/homeassistant/components/lacrosse_view/strings.json
@@ -42,5 +42,10 @@
"name": "Wind chill"
}
}
+ },
+ "exceptions": {
+ "update_error": {
+ "message": "Error updating data"
+ }
}
}
diff --git a/homeassistant/components/lamarzocco/__init__.py b/homeassistant/components/lamarzocco/__init__.py
index d20616e1940..51a939391a8 100644
--- a/homeassistant/components/lamarzocco/__init__.py
+++ b/homeassistant/components/lamarzocco/__init__.py
@@ -1,27 +1,27 @@
"""The La Marzocco integration."""
+import asyncio
import logging
from packaging import version
-from pylamarzocco.clients.bluetooth import LaMarzoccoBluetoothClient
-from pylamarzocco.clients.cloud import LaMarzoccoCloudClient
-from pylamarzocco.clients.local import LaMarzoccoLocalClient
-from pylamarzocco.const import BT_MODEL_PREFIXES, FirmwareType
-from pylamarzocco.devices.machine import LaMarzoccoMachine
+from pylamarzocco import (
+ LaMarzoccoBluetoothClient,
+ LaMarzoccoCloudClient,
+ LaMarzoccoMachine,
+)
+from pylamarzocco.const import FirmwareType
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.components.bluetooth import async_discovered_service_info
from homeassistant.const import (
- CONF_HOST,
CONF_MAC,
- CONF_MODEL,
- CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
CONF_USERNAME,
Platform,
)
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_create_clientsession
@@ -29,9 +29,9 @@ from .const import CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import (
LaMarzoccoConfigEntry,
LaMarzoccoConfigUpdateCoordinator,
- LaMarzoccoFirmwareUpdateCoordinator,
LaMarzoccoRuntimeData,
- LaMarzoccoStatisticsUpdateCoordinator,
+ LaMarzoccoScheduleUpdateCoordinator,
+ LaMarzoccoSettingsUpdateCoordinator,
)
PLATFORMS = [
@@ -45,6 +45,8 @@ PLATFORMS = [
Platform.UPDATE,
]
+BT_MODEL_PREFIXES = ("MICRA", "MINI", "GS3")
+
_LOGGER = logging.getLogger(__name__)
@@ -61,24 +63,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
client=client,
)
- # initialize local API
- local_client: LaMarzoccoLocalClient | None = None
- if (host := entry.data.get(CONF_HOST)) is not None:
- _LOGGER.debug("Initializing local API")
- local_client = LaMarzoccoLocalClient(
- host=host,
- local_bearer=entry.data[CONF_TOKEN],
- client=client,
+ try:
+ settings = await cloud_client.get_thing_settings(serial)
+ except AuthFail as ex:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN, translation_key="authentication_failed"
+ ) from ex
+ except RequestNotSuccessful as ex:
+ _LOGGER.debug(ex, exc_info=True)
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN, translation_key="api_error"
+ ) from ex
+
+ gateway_version = version.parse(
+ settings.firmwares[FirmwareType.GATEWAY].build_version
+ )
+
+ if gateway_version < version.parse("v5.0.9"):
+ # incompatible gateway firmware, create an issue
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "unsupported_gateway_firmware",
+ is_fixable=False,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="unsupported_gateway_firmware",
+ translation_placeholders={"gateway_version": str(gateway_version)},
)
# initialize Bluetooth
bluetooth_client: LaMarzoccoBluetoothClient | None = None
- if entry.options.get(CONF_USE_BLUETOOTH, True):
-
- def bluetooth_configured() -> bool:
- return entry.data.get(CONF_MAC, "") and entry.data.get(CONF_NAME, "")
-
- if not bluetooth_configured():
+ if entry.options.get(CONF_USE_BLUETOOTH, True) and (
+ token := settings.ble_auth_token
+ ):
+ if CONF_MAC not in entry.data:
for discovery_info in async_discovered_service_info(hass):
if (
(name := discovery_info.name)
@@ -92,55 +110,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
data={
**entry.data,
CONF_MAC: discovery_info.address,
- CONF_NAME: discovery_info.name,
},
)
- break
- if bluetooth_configured():
+ if not entry.data[CONF_TOKEN]:
+ # update the token in the config entry
+ hass.config_entries.async_update_entry(
+ entry,
+ data={
+ **entry.data,
+ CONF_TOKEN: token,
+ },
+ )
+
+ if CONF_MAC in entry.data:
_LOGGER.debug("Initializing Bluetooth device")
bluetooth_client = LaMarzoccoBluetoothClient(
- username=entry.data[CONF_USERNAME],
- serial_number=serial,
- token=entry.data[CONF_TOKEN],
address_or_ble_device=entry.data[CONF_MAC],
+ ble_token=token,
)
device = LaMarzoccoMachine(
- model=entry.data[CONF_MODEL],
serial_number=entry.unique_id,
- name=entry.data[CONF_NAME],
cloud_client=cloud_client,
- local_client=local_client,
bluetooth_client=bluetooth_client,
)
coordinators = LaMarzoccoRuntimeData(
- LaMarzoccoConfigUpdateCoordinator(hass, entry, device, local_client),
- LaMarzoccoFirmwareUpdateCoordinator(hass, entry, device),
- LaMarzoccoStatisticsUpdateCoordinator(hass, entry, device),
+ LaMarzoccoConfigUpdateCoordinator(hass, entry, device),
+ LaMarzoccoSettingsUpdateCoordinator(hass, entry, device),
+ LaMarzoccoScheduleUpdateCoordinator(hass, entry, device),
)
- # API does not like concurrent requests, so no asyncio.gather here
- await coordinators.config_coordinator.async_config_entry_first_refresh()
- await coordinators.firmware_coordinator.async_config_entry_first_refresh()
- await coordinators.statistics_coordinator.async_config_entry_first_refresh()
+ await asyncio.gather(
+ coordinators.config_coordinator.async_config_entry_first_refresh(),
+ coordinators.settings_coordinator.async_config_entry_first_refresh(),
+ coordinators.schedule_coordinator.async_config_entry_first_refresh(),
+ )
entry.runtime_data = coordinators
- gateway_version = device.firmware[FirmwareType.GATEWAY].current_version
- if version.parse(gateway_version) < version.parse("v3.4-rc5"):
- # incompatible gateway firmware, create an issue
- ir.async_create_issue(
- hass,
- DOMAIN,
- "unsupported_gateway_firmware",
- is_fixable=False,
- severity=ir.IssueSeverity.ERROR,
- translation_key="unsupported_gateway_firmware",
- translation_placeholders={"gateway_version": gateway_version},
- )
-
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def update_listener(
@@ -162,41 +171,45 @@ async def async_migrate_entry(
hass: HomeAssistant, entry: LaMarzoccoConfigEntry
) -> bool:
"""Migrate config entry."""
- if entry.version > 2:
+ if entry.version > 3:
# guard against downgrade from a future version
return False
if entry.version == 1:
+ _LOGGER.error(
+ "Migration from version 1 is no longer supported, please remove and re-add the integration"
+ )
+ return False
+
+ if entry.version == 2:
cloud_client = LaMarzoccoCloudClient(
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
try:
- fleet = await cloud_client.get_customer_fleet()
+ things = await cloud_client.list_things()
except (AuthFail, RequestNotSuccessful) as exc:
_LOGGER.error("Migration failed with error %s", exc)
return False
-
- assert entry.unique_id is not None
- device = fleet[entry.unique_id]
- v2_data = {
+ v3_data = {
CONF_USERNAME: entry.data[CONF_USERNAME],
CONF_PASSWORD: entry.data[CONF_PASSWORD],
- CONF_MODEL: device.model,
- CONF_NAME: device.name,
- CONF_TOKEN: device.communication_key,
+ CONF_TOKEN: next(
+ (
+ thing.ble_auth_token
+ for thing in things
+ if thing.serial_number == entry.unique_id
+ ),
+ None,
+ ),
}
-
- if CONF_HOST in entry.data:
- v2_data[CONF_HOST] = entry.data[CONF_HOST]
-
if CONF_MAC in entry.data:
- v2_data[CONF_MAC] = entry.data[CONF_MAC]
-
+ v3_data[CONF_MAC] = entry.data[CONF_MAC]
hass.config_entries.async_update_entry(
entry,
- data=v2_data,
- version=2,
+ data=v3_data,
+ version=3,
)
_LOGGER.debug("Migrated La Marzocco config entry to version 2")
+
return True
diff --git a/homeassistant/components/lamarzocco/binary_sensor.py b/homeassistant/components/lamarzocco/binary_sensor.py
index e36b53bc993..98cf7cf222e 100644
--- a/homeassistant/components/lamarzocco/binary_sensor.py
+++ b/homeassistant/components/lamarzocco/binary_sensor.py
@@ -2,9 +2,11 @@
from collections.abc import Callable
from dataclasses import dataclass
+from typing import cast
-from pylamarzocco.const import MachineModel
-from pylamarzocco.models import LaMarzoccoMachineConfig
+from pylamarzocco import LaMarzoccoMachine
+from pylamarzocco.const import BackFlushStatus, MachineState, WidgetType
+from pylamarzocco.models import BackFlush, MachineStatus
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -13,10 +15,10 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LaMarzoccoConfigEntry
-from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
+from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -29,7 +31,7 @@ class LaMarzoccoBinarySensorEntityDescription(
):
"""Description of a La Marzocco binary sensor."""
- is_on_fn: Callable[[LaMarzoccoMachineConfig], bool | None]
+ is_on_fn: Callable[[LaMarzoccoMachine], bool | None]
ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
@@ -37,33 +39,41 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
key="water_tank",
translation_key="water_tank",
device_class=BinarySensorDeviceClass.PROBLEM,
- is_on_fn=lambda config: not config.water_contact,
+ is_on_fn=lambda machine: WidgetType.CM_NO_WATER in machine.dashboard.config,
entity_category=EntityCategory.DIAGNOSTIC,
- supported_fn=lambda coordinator: coordinator.local_connection_configured,
),
LaMarzoccoBinarySensorEntityDescription(
key="brew_active",
translation_key="brew_active",
device_class=BinarySensorDeviceClass.RUNNING,
- is_on_fn=lambda config: config.brew_active,
- available_fn=lambda device: device.websocket_connected,
+ is_on_fn=(
+ lambda machine: cast(
+ MachineStatus, machine.dashboard.config[WidgetType.CM_MACHINE_STATUS]
+ ).status
+ is MachineState.BREWING
+ ),
+ available_fn=lambda device: device.websocket.connected,
entity_category=EntityCategory.DIAGNOSTIC,
),
LaMarzoccoBinarySensorEntityDescription(
key="backflush_enabled",
translation_key="backflush_enabled",
device_class=BinarySensorDeviceClass.RUNNING,
- is_on_fn=lambda config: config.backflush_enabled,
+ is_on_fn=(
+ lambda machine: cast(
+ BackFlush, machine.dashboard.config[WidgetType.CM_BACK_FLUSH]
+ ).status
+ is BackFlushStatus.REQUESTED
+ ),
entity_category=EntityCategory.DIAGNOSTIC,
),
-)
-
-SCALE_ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
LaMarzoccoBinarySensorEntityDescription(
- key="connected",
+ key="websocket_connected",
+ translation_key="websocket_connected",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
- is_on_fn=lambda config: config.scale.connected if config.scale else None,
+ is_on_fn=(lambda machine: machine.websocket.connected),
entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
),
)
@@ -71,35 +81,16 @@ SCALE_ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensor entities."""
coordinator = entry.runtime_data.config_coordinator
- entities = [
+ async_add_entities(
LaMarzoccoBinarySensorEntity(coordinator, description)
for description in ENTITIES
if description.supported_fn(coordinator)
- ]
-
- if (
- coordinator.device.model == MachineModel.LINEA_MINI
- and coordinator.device.config.scale
- ):
- entities.extend(
- LaMarzoccoScaleBinarySensorEntity(coordinator, description)
- for description in SCALE_ENTITIES
- )
-
- def _async_add_new_scale() -> None:
- async_add_entities(
- LaMarzoccoScaleBinarySensorEntity(coordinator, description)
- for description in SCALE_ENTITIES
- )
-
- coordinator.new_device_callback.append(_async_add_new_scale)
-
- async_add_entities(entities)
+ )
class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity):
@@ -110,12 +101,4 @@ class LaMarzoccoBinarySensorEntity(LaMarzoccoEntity, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
- return self.entity_description.is_on_fn(self.coordinator.device.config)
-
-
-class LaMarzoccoScaleBinarySensorEntity(
- LaMarzoccoBinarySensorEntity, LaMarzoccScaleEntity
-):
- """Binary sensor for La Marzocco scales."""
-
- entity_description: LaMarzoccoBinarySensorEntityDescription
+ return self.entity_description.is_on_fn(self.coordinator.device)
diff --git a/homeassistant/components/lamarzocco/button.py b/homeassistant/components/lamarzocco/button.py
index 22e92f656ff..db51d610949 100644
--- a/homeassistant/components/lamarzocco/button.py
+++ b/homeassistant/components/lamarzocco/button.py
@@ -10,7 +10,7 @@ from pylamarzocco.exceptions import RequestNotSuccessful
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
@@ -53,7 +53,7 @@ ENTITIES: tuple[LaMarzoccoButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button entities."""
diff --git a/homeassistant/components/lamarzocco/calendar.py b/homeassistant/components/lamarzocco/calendar.py
index 1dcc7c324ac..e4673372d0a 100644
--- a/homeassistant/components/lamarzocco/calendar.py
+++ b/homeassistant/components/lamarzocco/calendar.py
@@ -3,11 +3,11 @@
from collections.abc import Iterator
from datetime import datetime, timedelta
-from pylamarzocco.models import LaMarzoccoWakeUpSleepEntry
+from pylamarzocco.const import WeekDay
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
@@ -18,28 +18,30 @@ PARALLEL_UPDATES = 0
CALENDAR_KEY = "auto_on_off_schedule"
-DAY_OF_WEEK = [
- "monday",
- "tuesday",
- "wednesday",
- "thursday",
- "friday",
- "saturday",
- "sunday",
-]
+WEEKDAY_TO_ENUM = {
+ 0: WeekDay.MONDAY,
+ 1: WeekDay.TUESDAY,
+ 2: WeekDay.WEDNESDAY,
+ 3: WeekDay.THURSDAY,
+ 4: WeekDay.FRIDAY,
+ 5: WeekDay.SATURDAY,
+ 6: WeekDay.SUNDAY,
+}
async def async_setup_entry(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch entities and services."""
- coordinator = entry.runtime_data.config_coordinator
+ coordinator = entry.runtime_data.schedule_coordinator
+
async_add_entities(
- LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, wake_up_sleep_entry)
- for wake_up_sleep_entry in coordinator.device.config.wake_up_sleep_entries.values()
+ LaMarzoccoCalendarEntity(coordinator, CALENDAR_KEY, schedule.identifier)
+ for schedule in coordinator.device.schedule.smart_wake_up_sleep.schedules
+ if schedule.identifier
)
@@ -52,12 +54,12 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity):
self,
coordinator: LaMarzoccoUpdateCoordinator,
key: str,
- wake_up_sleep_entry: LaMarzoccoWakeUpSleepEntry,
+ identifier: str,
) -> None:
"""Set up calendar."""
- super().__init__(coordinator, f"{key}_{wake_up_sleep_entry.entry_id}")
- self.wake_up_sleep_entry = wake_up_sleep_entry
- self._attr_translation_placeholders = {"id": wake_up_sleep_entry.entry_id}
+ super().__init__(coordinator, f"{key}_{identifier}")
+ self._identifier = identifier
+ self._attr_translation_placeholders = {"id": identifier}
@property
def event(self) -> CalendarEvent | None:
@@ -112,24 +114,31 @@ class LaMarzoccoCalendarEntity(LaMarzoccoBaseEntity, CalendarEntity):
def _async_get_calendar_event(self, date: datetime) -> CalendarEvent | None:
"""Return calendar event for a given weekday."""
+ schedule_entry = (
+ self.coordinator.device.schedule.smart_wake_up_sleep.schedules_dict[
+ self._identifier
+ ]
+ )
# check first if auto/on off is turned on in general
- if not self.wake_up_sleep_entry.enabled:
+ if not schedule_entry.enabled:
return None
# parse the schedule for the day
- if DAY_OF_WEEK[date.weekday()] not in self.wake_up_sleep_entry.days:
+ if WEEKDAY_TO_ENUM[date.weekday()] not in schedule_entry.days:
return None
- hour_on, minute_on = self.wake_up_sleep_entry.time_on.split(":")
- hour_off, minute_off = self.wake_up_sleep_entry.time_off.split(":")
+ hour_on = schedule_entry.on_time_minutes // 60
+ minute_on = schedule_entry.on_time_minutes % 60
+ hour_off = schedule_entry.off_time_minutes // 60
+ minute_off = schedule_entry.off_time_minutes % 60
- # if off time is 24:00, then it means the off time is the next day
- # only for legacy schedules
day_offset = 0
- if hour_off == "24":
+ if hour_off == 24:
+ # if the machine is scheduled to turn off at midnight, we need to
+ # set the end date to the next day
day_offset = 1
- hour_off = "0"
+ hour_off = 0
end_date = date.replace(
hour=int(hour_off),
diff --git a/homeassistant/components/lamarzocco/config_flow.py b/homeassistant/components/lamarzocco/config_flow.py
index 87a9824423a..e352e337d0b 100644
--- a/homeassistant/components/lamarzocco/config_flow.py
+++ b/homeassistant/components/lamarzocco/config_flow.py
@@ -7,10 +7,9 @@ import logging
from typing import Any
from aiohttp import ClientSession
-from pylamarzocco.clients.cloud import LaMarzoccoCloudClient
-from pylamarzocco.clients.local import LaMarzoccoLocalClient
+from pylamarzocco import LaMarzoccoCloudClient
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
-from pylamarzocco.models import LaMarzoccoDeviceInfo
+from pylamarzocco.models import Thing
import voluptuous as vol
from homeassistant.components.bluetooth import (
@@ -26,9 +25,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import (
CONF_ADDRESS,
- CONF_HOST,
CONF_MAC,
- CONF_MODEL,
CONF_NAME,
CONF_PASSWORD,
CONF_TOKEN,
@@ -52,6 +49,7 @@ from .const import CONF_USE_BLUETOOTH, DOMAIN
from .coordinator import LaMarzoccoConfigEntry
CONF_MACHINE = "machine"
+BT_MODEL_PREFIXES = ("MICRA", "MINI", "GS3")
_LOGGER = logging.getLogger(__name__)
@@ -59,14 +57,14 @@ _LOGGER = logging.getLogger(__name__)
class LmConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for La Marzocco."""
- VERSION = 2
+ VERSION = 3
_client: ClientSession
def __init__(self) -> None:
"""Initialize the config flow."""
self._config: dict[str, Any] = {}
- self._fleet: dict[str, LaMarzoccoDeviceInfo] = {}
+ self._things: dict[str, Thing] = {}
self._discovered: dict[str, str] = {}
async def async_step_user(
@@ -83,7 +81,6 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
data = {
**data,
**user_input,
- **self._discovered,
}
self._client = async_create_clientsession(self.hass)
@@ -93,7 +90,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
client=self._client,
)
try:
- self._fleet = await cloud_client.get_customer_fleet()
+ things = await cloud_client.list_things()
except AuthFail:
_LOGGER.debug("Server rejected login credentials")
errors["base"] = "invalid_auth"
@@ -101,37 +98,30 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error("Error connecting to server: %s", exc)
errors["base"] = "cannot_connect"
else:
- if not self._fleet:
+ self._things = {thing.serial_number: thing for thing in things}
+ if not self._things:
errors["base"] = "no_machines"
if not errors:
+ self._config = data
if self.source == SOURCE_REAUTH:
return self.async_update_reload_and_abort(
- self._get_reauth_entry(), data=data
+ self._get_reauth_entry(), data_updates=data
)
if self._discovered:
- if self._discovered[CONF_MACHINE] not in self._fleet:
+ if self._discovered[CONF_MACHINE] not in self._things:
errors["base"] = "machine_not_found"
else:
- self._config = data
- # if DHCP discovery was used, auto fill machine selection
- if CONF_HOST in self._discovered:
- return await self.async_step_machine_selection(
- user_input={
- CONF_HOST: self._discovered[CONF_HOST],
- CONF_MACHINE: self._discovered[CONF_MACHINE],
- }
- )
- # if Bluetooth discovery was used, only select host
- return self.async_show_form(
- step_id="machine_selection",
- data_schema=vol.Schema(
- {vol.Optional(CONF_HOST): cv.string}
- ),
- )
+ # store discovered connection address
+ if CONF_MAC in self._discovered:
+ self._config[CONF_MAC] = self._discovered[CONF_MAC]
+ if CONF_ADDRESS in self._discovered:
+ self._config[CONF_ADDRESS] = self._discovered[CONF_ADDRESS]
+ return await self.async_step_machine_selection(
+ user_input={CONF_MACHINE: self._discovered[CONF_MACHINE]}
+ )
if not errors:
- self._config = data
return await self.async_step_machine_selection()
placeholders: dict[str, str] | None = None
@@ -175,43 +165,35 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
else:
serial_number = self._discovered[CONF_MACHINE]
- selected_device = self._fleet[serial_number]
-
- # validate local connection if host is provided
- if user_input.get(CONF_HOST):
- if not await LaMarzoccoLocalClient.validate_connection(
- client=self._client,
- host=user_input[CONF_HOST],
- token=selected_device.communication_key,
- ):
- errors[CONF_HOST] = "cannot_connect"
- else:
- self._config[CONF_HOST] = user_input[CONF_HOST]
+ selected_device = self._things[serial_number]
if not errors:
if self.source == SOURCE_RECONFIGURE:
for service_info in async_discovered_service_info(self.hass):
- self._discovered[service_info.name] = service_info.address
+ if service_info.name.startswith(BT_MODEL_PREFIXES):
+ self._discovered[service_info.name] = service_info.address
if self._discovered:
return await self.async_step_bluetooth_selection()
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(),
+ data_updates=self._config,
+ )
return self.async_create_entry(
title=selected_device.name,
data={
**self._config,
- CONF_NAME: selected_device.name,
- CONF_MODEL: selected_device.model,
- CONF_TOKEN: selected_device.communication_key,
+ CONF_TOKEN: self._things[serial_number].ble_auth_token,
},
)
machine_options = [
SelectOptionDict(
- value=device.serial_number,
- label=f"{device.model} ({device.serial_number})",
+ value=thing.serial_number,
+ label=f"{thing.name} ({thing.serial_number})",
)
- for device in self._fleet.values()
+ for thing in self._things.values()
]
machine_selection_schema = vol.Schema(
@@ -224,7 +206,6 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
mode=SelectSelectorMode.DROPDOWN,
)
),
- vol.Optional(CONF_HOST): cv.string,
}
)
@@ -242,8 +223,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
- data={
- **self._config,
+ data_updates={
CONF_MAC: user_input[CONF_MAC],
},
)
@@ -304,7 +284,6 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured(
updates={
- CONF_HOST: discovery_info.ip,
CONF_ADDRESS: discovery_info.macaddress,
}
)
@@ -316,8 +295,8 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
discovery_info.ip,
)
+ self._discovered[CONF_NAME] = discovery_info.hostname
self._discovered[CONF_MACHINE] = serial
- self._discovered[CONF_HOST] = discovery_info.ip
self._discovered[CONF_ADDRESS] = discovery_info.macaddress
return await self.async_step_user()
diff --git a/homeassistant/components/lamarzocco/coordinator.py b/homeassistant/components/lamarzocco/coordinator.py
index dddca6565e4..a8b3d9d0ee7 100644
--- a/homeassistant/components/lamarzocco/coordinator.py
+++ b/homeassistant/components/lamarzocco/coordinator.py
@@ -3,28 +3,25 @@
from __future__ import annotations
from abc import abstractmethod
-from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any
-from pylamarzocco.clients.local import LaMarzoccoLocalClient
-from pylamarzocco.devices.machine import LaMarzoccoMachine
+from pylamarzocco import LaMarzoccoMachine
from pylamarzocco.exceptions import AuthFail, RequestNotSuccessful
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
SCAN_INTERVAL = timedelta(seconds=30)
-FIRMWARE_UPDATE_INTERVAL = timedelta(hours=1)
-STATISTICS_UPDATE_INTERVAL = timedelta(minutes=5)
+SETTINGS_UPDATE_INTERVAL = timedelta(hours=1)
+SCHEDULE_UPDATE_INTERVAL = timedelta(minutes=5)
_LOGGER = logging.getLogger(__name__)
@@ -33,8 +30,8 @@ class LaMarzoccoRuntimeData:
"""Runtime data for La Marzocco."""
config_coordinator: LaMarzoccoConfigUpdateCoordinator
- firmware_coordinator: LaMarzoccoFirmwareUpdateCoordinator
- statistics_coordinator: LaMarzoccoStatisticsUpdateCoordinator
+ settings_coordinator: LaMarzoccoSettingsUpdateCoordinator
+ schedule_coordinator: LaMarzoccoScheduleUpdateCoordinator
type LaMarzoccoConfigEntry = ConfigEntry[LaMarzoccoRuntimeData]
@@ -51,7 +48,6 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
device: LaMarzoccoMachine,
- local_client: LaMarzoccoLocalClient | None = None,
) -> None:
"""Initialize coordinator."""
super().__init__(
@@ -62,9 +58,6 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
update_interval=self._default_update_interval,
)
self.device = device
- self.local_connection_configured = local_client is not None
- self._local_client = local_client
- self.new_device_callback: list[Callable] = []
async def _async_update_data(self) -> None:
"""Do the data update."""
@@ -89,30 +82,22 @@ class LaMarzoccoUpdateCoordinator(DataUpdateCoordinator[None]):
class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
"""Class to handle fetching data from the La Marzocco API centrally."""
- _scale_address: str | None = None
-
async def _async_connect_websocket(self) -> None:
"""Set up the coordinator."""
- if self._local_client is not None and (
- self._local_client.websocket is None or self._local_client.websocket.closed
- ):
+ if not self.device.websocket.connected:
_LOGGER.debug("Init WebSocket in background task")
self.config_entry.async_create_background_task(
hass=self.hass,
- target=self.device.websocket_connect(
- notify_callback=lambda: self.async_set_updated_data(None)
+ target=self.device.connect_dashboard_websocket(
+ update_callback=lambda _: self.async_set_updated_data(None)
),
name="lm_websocket_task",
)
async def websocket_close(_: Any | None = None) -> None:
- if (
- self._local_client is not None
- and self._local_client.websocket is not None
- and not self._local_client.websocket.closed
- ):
- await self._local_client.websocket.close()
+ if self.device.websocket.connected:
+ await self.device.websocket.disconnect()
self.config_entry.async_on_unload(
self.hass.bus.async_listen_once(
@@ -123,47 +108,28 @@ class LaMarzoccoConfigUpdateCoordinator(LaMarzoccoUpdateCoordinator):
async def _internal_async_update_data(self) -> None:
"""Fetch data from API endpoint."""
- await self.device.get_config()
- _LOGGER.debug("Current status: %s", str(self.device.config))
+ await self.device.get_dashboard()
+ _LOGGER.debug("Current status: %s", self.device.dashboard.to_dict())
await self._async_connect_websocket()
- self._async_add_remove_scale()
-
- @callback
- def _async_add_remove_scale(self) -> None:
- """Add or remove a scale when added or removed."""
- if self.device.config.scale and not self._scale_address:
- self._scale_address = self.device.config.scale.address
- for scale_callback in self.new_device_callback:
- scale_callback()
- elif not self.device.config.scale and self._scale_address:
- device_registry = dr.async_get(self.hass)
- if device := device_registry.async_get_device(
- identifiers={(DOMAIN, self._scale_address)}
- ):
- device_registry.async_update_device(
- device_id=device.id,
- remove_config_entry_id=self.config_entry.entry_id,
- )
- self._scale_address = None
-class LaMarzoccoFirmwareUpdateCoordinator(LaMarzoccoUpdateCoordinator):
- """Coordinator for La Marzocco firmware."""
+class LaMarzoccoSettingsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
+ """Coordinator for La Marzocco settings."""
- _default_update_interval = FIRMWARE_UPDATE_INTERVAL
+ _default_update_interval = SETTINGS_UPDATE_INTERVAL
async def _internal_async_update_data(self) -> None:
"""Fetch data from API endpoint."""
- await self.device.get_firmware()
- _LOGGER.debug("Current firmware: %s", str(self.device.firmware))
+ await self.device.get_settings()
+ _LOGGER.debug("Current settings: %s", self.device.settings.to_dict())
-class LaMarzoccoStatisticsUpdateCoordinator(LaMarzoccoUpdateCoordinator):
- """Coordinator for La Marzocco statistics."""
+class LaMarzoccoScheduleUpdateCoordinator(LaMarzoccoUpdateCoordinator):
+ """Coordinator for La Marzocco schedule."""
- _default_update_interval = STATISTICS_UPDATE_INTERVAL
+ _default_update_interval = SCHEDULE_UPDATE_INTERVAL
async def _internal_async_update_data(self) -> None:
"""Fetch data from API endpoint."""
- await self.device.get_statistics()
- _LOGGER.debug("Current statistics: %s", str(self.device.statistics))
+ await self.device.get_schedule()
+ _LOGGER.debug("Current schedule: %s", self.device.schedule.to_dict())
diff --git a/homeassistant/components/lamarzocco/diagnostics.py b/homeassistant/components/lamarzocco/diagnostics.py
index 204a8b7142a..6837dd6a9ee 100644
--- a/homeassistant/components/lamarzocco/diagnostics.py
+++ b/homeassistant/components/lamarzocco/diagnostics.py
@@ -2,10 +2,7 @@
from __future__ import annotations
-from dataclasses import asdict
-from typing import Any, TypedDict
-
-from pylamarzocco.const import FirmwareType
+from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
@@ -17,15 +14,6 @@ TO_REDACT = {
}
-class DiagnosticsData(TypedDict):
- """Diagnostic data for La Marzocco."""
-
- model: str
- config: dict[str, Any]
- firmware: list[dict[FirmwareType, dict[str, Any]]]
- statistics: dict[str, Any]
-
-
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
@@ -33,12 +21,4 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data.config_coordinator
device = coordinator.device
- # collect all data sources
- diagnostics_data = DiagnosticsData(
- model=device.model,
- config=asdict(device.config),
- firmware=[{key: asdict(firmware)} for key, firmware in device.firmware.items()],
- statistics=asdict(device.statistics),
- )
-
- return async_redact_data(diagnostics_data, TO_REDACT)
+ return async_redact_data(device.to_dict(), TO_REDACT)
diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py
index 3e70ff1acdf..2e3a7f2ce83 100644
--- a/homeassistant/components/lamarzocco/entity.py
+++ b/homeassistant/components/lamarzocco/entity.py
@@ -2,10 +2,9 @@
from collections.abc import Callable
from dataclasses import dataclass
-from typing import TYPE_CHECKING
+from pylamarzocco import LaMarzoccoMachine
from pylamarzocco.const import FirmwareType
-from pylamarzocco.devices.machine import LaMarzoccoMachine
from homeassistant.const import CONF_ADDRESS, CONF_MAC
from homeassistant.helpers.device_registry import (
@@ -46,12 +45,12 @@ class LaMarzoccoBaseEntity(
self._attr_unique_id = f"{device.serial_number}_{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.serial_number)},
- name=device.name,
+ name=device.dashboard.name,
manufacturer="La Marzocco",
- model=device.full_model_name,
- model_id=device.model,
+ model=device.dashboard.model_name.value,
+ model_id=device.dashboard.model_code.value,
serial_number=device.serial_number,
- sw_version=device.firmware[FirmwareType.MACHINE].current_version,
+ sw_version=device.settings.firmwares[FirmwareType.MACHINE].build_version,
)
connections: set[tuple[str, str]] = set()
if coordinator.config_entry.data.get(CONF_ADDRESS):
@@ -86,26 +85,3 @@ class LaMarzoccoEntity(LaMarzoccoBaseEntity):
"""Initialize the entity."""
super().__init__(coordinator, entity_description.key)
self.entity_description = entity_description
-
-
-class LaMarzoccScaleEntity(LaMarzoccoEntity):
- """Common class for scale."""
-
- def __init__(
- self,
- coordinator: LaMarzoccoUpdateCoordinator,
- entity_description: LaMarzoccoEntityDescription,
- ) -> None:
- """Initialize the entity."""
- super().__init__(coordinator, entity_description)
- scale = coordinator.device.config.scale
- if TYPE_CHECKING:
- assert scale
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, scale.address)},
- name=scale.name,
- manufacturer="Acaia",
- model="Lunar",
- model_id="Y.301",
- via_device=(DOMAIN, coordinator.device.serial_number),
- )
diff --git a/homeassistant/components/lamarzocco/icons.json b/homeassistant/components/lamarzocco/icons.json
index 2be882fafea..2964f48ecbd 100644
--- a/homeassistant/components/lamarzocco/icons.json
+++ b/homeassistant/components/lamarzocco/icons.json
@@ -34,36 +34,20 @@
"dose": {
"default": "mdi:cup-water"
},
- "prebrew_off": {
- "default": "mdi:water-off"
- },
- "prebrew_on": {
- "default": "mdi:water"
- },
- "preinfusion_off": {
- "default": "mdi:water"
- },
- "scale_target": {
- "default": "mdi:scale-balance"
- },
"smart_standby_time": {
"default": "mdi:timer"
},
- "steam_temp": {
- "default": "mdi:thermometer-water"
+ "preinfusion_time": {
+ "default": "mdi:water"
},
- "tea_water_duration": {
- "default": "mdi:timer-sand"
+ "prebrew_time_on": {
+ "default": "mdi:water"
+ },
+ "prebrew_time_off": {
+ "default": "mdi:water-off"
}
},
"select": {
- "active_bbw": {
- "default": "mdi:alpha-u",
- "state": {
- "a": "mdi:alpha-a",
- "b": "mdi:alpha-b"
- }
- },
"smart_standby_mode": {
"default": "mdi:power",
"state": {
@@ -89,23 +73,11 @@
}
},
"sensor": {
- "drink_stats_coffee": {
- "default": "mdi:chart-line"
+ "coffee_boiler_ready_time": {
+ "default": "mdi:av-timer"
},
- "drink_stats_flushing": {
- "default": "mdi:chart-line"
- },
- "drink_stats_coffee_key": {
- "default": "mdi:chart-scatter-plot"
- },
- "shot_timer": {
- "default": "mdi:timer"
- },
- "current_temp_coffee": {
- "default": "mdi:thermometer"
- },
- "current_temp_steam": {
- "default": "mdi:thermometer"
+ "steam_boiler_ready_time": {
+ "default": "mdi:av-timer"
}
},
"switch": {
diff --git a/homeassistant/components/lamarzocco/manifest.json b/homeassistant/components/lamarzocco/manifest.json
index afd367b0f6e..3053056a2d0 100644
--- a/homeassistant/components/lamarzocco/manifest.json
+++ b/homeassistant/components/lamarzocco/manifest.json
@@ -34,8 +34,8 @@
],
"documentation": "https://www.home-assistant.io/integrations/lamarzocco",
"integration_type": "device",
- "iot_class": "cloud_polling",
+ "iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
- "requirements": ["pylamarzocco==1.4.6"]
+ "requirements": ["pylamarzocco==2.0.0b1"]
}
diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py
index 44b582fbf1a..81a03b4d6ee 100644
--- a/homeassistant/components/lamarzocco/number.py
+++ b/homeassistant/components/lamarzocco/number.py
@@ -2,18 +2,12 @@
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
-from typing import Any
+from typing import Any, cast
-from pylamarzocco.const import (
- KEYS_PER_MODEL,
- BoilerType,
- MachineModel,
- PhysicalKey,
- PrebrewMode,
-)
-from pylamarzocco.devices.machine import LaMarzoccoMachine
+from pylamarzocco import LaMarzoccoMachine
+from pylamarzocco.const import ModelName, PreExtractionMode, WidgetType
from pylamarzocco.exceptions import RequestNotSuccessful
-from pylamarzocco.models import LaMarzoccoMachineConfig
+from pylamarzocco.models import CoffeeBoiler, PreBrewing
from homeassistant.components.number import (
NumberDeviceClass,
@@ -29,11 +23,11 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
-from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
-from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
+from .coordinator import LaMarzoccoConfigEntry
+from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
PARALLEL_UPDATES = 1
@@ -45,25 +39,10 @@ class LaMarzoccoNumberEntityDescription(
):
"""Description of a La Marzocco number entity."""
- native_value_fn: Callable[[LaMarzoccoMachineConfig], float | int]
+ native_value_fn: Callable[[LaMarzoccoMachine], float | int]
set_value_fn: Callable[[LaMarzoccoMachine, float | int], Coroutine[Any, Any, bool]]
-@dataclass(frozen=True, kw_only=True)
-class LaMarzoccoKeyNumberEntityDescription(
- LaMarzoccoEntityDescription,
- NumberEntityDescription,
-):
- """Description of an La Marzocco number entity with keys."""
-
- native_value_fn: Callable[
- [LaMarzoccoMachineConfig, PhysicalKey], float | int | None
- ]
- set_value_fn: Callable[
- [LaMarzoccoMachine, float | int, PhysicalKey], Coroutine[Any, Any, bool]
- ]
-
-
ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
LaMarzoccoNumberEntityDescription(
key="coffee_temp",
@@ -73,43 +52,11 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
native_step=PRECISION_TENTHS,
native_min_value=85,
native_max_value=104,
- set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.COFFEE, temp),
- native_value_fn=lambda config: config.boilers[
- BoilerType.COFFEE
- ].target_temperature,
- ),
- LaMarzoccoNumberEntityDescription(
- key="steam_temp",
- translation_key="steam_temp",
- device_class=NumberDeviceClass.TEMPERATURE,
- native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- native_step=PRECISION_WHOLE,
- native_min_value=126,
- native_max_value=131,
- set_value_fn=lambda machine, temp: machine.set_temp(BoilerType.STEAM, temp),
- native_value_fn=lambda config: config.boilers[
- BoilerType.STEAM
- ].target_temperature,
- supported_fn=lambda coordinator: coordinator.device.model
- in (
- MachineModel.GS3_AV,
- MachineModel.GS3_MP,
- ),
- ),
- LaMarzoccoNumberEntityDescription(
- key="tea_water_duration",
- translation_key="tea_water_duration",
- device_class=NumberDeviceClass.DURATION,
- native_unit_of_measurement=UnitOfTime.SECONDS,
- native_step=PRECISION_WHOLE,
- native_min_value=0,
- native_max_value=30,
- set_value_fn=lambda machine, value: machine.set_dose_tea_water(int(value)),
- native_value_fn=lambda config: config.dose_hot_water,
- supported_fn=lambda coordinator: coordinator.device.model
- in (
- MachineModel.GS3_AV,
- MachineModel.GS3_MP,
+ set_value_fn=lambda machine, temp: machine.set_coffee_target_temperature(temp),
+ native_value_fn=(
+ lambda machine: cast(
+ CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER]
+ ).target_temperature
),
),
LaMarzoccoNumberEntityDescription(
@@ -117,111 +64,134 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
translation_key="smart_standby_time",
device_class=NumberDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
- native_step=10,
- native_min_value=10,
- native_max_value=240,
- entity_category=EntityCategory.CONFIG,
- set_value_fn=lambda machine, value: machine.set_smart_standby(
- enabled=machine.config.smart_standby.enabled,
- mode=machine.config.smart_standby.mode,
- minutes=int(value),
- ),
- native_value_fn=lambda config: config.smart_standby.minutes,
- ),
-)
-
-
-KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
- LaMarzoccoKeyNumberEntityDescription(
- key="prebrew_off",
- translation_key="prebrew_off",
- device_class=NumberDeviceClass.DURATION,
- native_unit_of_measurement=UnitOfTime.SECONDS,
- native_step=PRECISION_TENTHS,
- native_min_value=1,
- native_max_value=10,
- entity_category=EntityCategory.CONFIG,
- set_value_fn=lambda machine, value, key: machine.set_prebrew_time(
- prebrew_off_time=value, key=key
- ),
- native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time,
- available_fn=lambda device: len(device.config.prebrew_configuration) > 0
- and device.config.prebrew_mode == PrebrewMode.PREBREW,
- supported_fn=lambda coordinator: coordinator.device.model
- != MachineModel.GS3_MP,
- ),
- LaMarzoccoKeyNumberEntityDescription(
- key="prebrew_on",
- translation_key="prebrew_on",
- device_class=NumberDeviceClass.DURATION,
- native_unit_of_measurement=UnitOfTime.SECONDS,
- native_step=PRECISION_TENTHS,
- native_min_value=2,
- native_max_value=10,
- entity_category=EntityCategory.CONFIG,
- set_value_fn=lambda machine, value, key: machine.set_prebrew_time(
- prebrew_on_time=value, key=key
- ),
- native_value_fn=lambda config, key: config.prebrew_configuration[key].off_time,
- available_fn=lambda device: len(device.config.prebrew_configuration) > 0
- and device.config.prebrew_mode == PrebrewMode.PREBREW,
- supported_fn=lambda coordinator: coordinator.device.model
- != MachineModel.GS3_MP,
- ),
- LaMarzoccoKeyNumberEntityDescription(
- key="preinfusion_off",
- translation_key="preinfusion_off",
- device_class=NumberDeviceClass.DURATION,
- native_unit_of_measurement=UnitOfTime.SECONDS,
- native_step=PRECISION_TENTHS,
- native_min_value=2,
- native_max_value=29,
- entity_category=EntityCategory.CONFIG,
- set_value_fn=lambda machine, value, key: machine.set_preinfusion_time(
- preinfusion_time=value, key=key
- ),
- native_value_fn=lambda config, key: config.prebrew_configuration[
- key
- ].preinfusion_time,
- available_fn=lambda device: len(device.config.prebrew_configuration) > 0
- and device.config.prebrew_mode == PrebrewMode.PREINFUSION,
- supported_fn=lambda coordinator: coordinator.device.model
- != MachineModel.GS3_MP,
- ),
- LaMarzoccoKeyNumberEntityDescription(
- key="dose",
- translation_key="dose",
- native_unit_of_measurement="ticks",
native_step=PRECISION_WHOLE,
native_min_value=0,
- native_max_value=999,
+ native_max_value=240,
entity_category=EntityCategory.CONFIG,
- set_value_fn=lambda machine, ticks, key: machine.set_dose(
- dose=int(ticks), key=key
+ set_value_fn=(
+ lambda machine, value: machine.set_smart_standby(
+ enabled=machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
+ mode=machine.schedule.smart_wake_up_sleep.smart_stand_by_after,
+ minutes=int(value),
+ )
),
- native_value_fn=lambda config, key: config.doses[key],
- supported_fn=lambda coordinator: coordinator.device.model
- == MachineModel.GS3_AV,
+ native_value_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
),
-)
-
-SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
- LaMarzoccoKeyNumberEntityDescription(
- key="scale_target",
- translation_key="scale_target",
- native_step=PRECISION_WHOLE,
- native_min_value=1,
- native_max_value=100,
+ LaMarzoccoNumberEntityDescription(
+ key="preinfusion_off",
+ translation_key="preinfusion_time",
+ device_class=NumberDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ native_step=PRECISION_TENTHS,
+ native_min_value=0,
+ native_max_value=10,
entity_category=EntityCategory.CONFIG,
- set_value_fn=lambda machine, weight, key: machine.set_bbw_recipe_target(
- key, int(weight)
+ set_value_fn=(
+ lambda machine, value: machine.set_pre_extraction_times(
+ seconds_on=0,
+ seconds_off=float(value),
+ )
),
- native_value_fn=lambda config, key: (
- config.bbw_settings.doses[key] if config.bbw_settings else None
+ native_value_fn=(
+ lambda machine: cast(
+ PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
+ )
+ .times.pre_infusion[0]
+ .seconds.seconds_out
+ ),
+ available_fn=(
+ lambda machine: cast(
+ PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
+ ).mode
+ is PreExtractionMode.PREINFUSION
),
supported_fn=(
- lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI
- and coordinator.device.config.scale is not None
+ lambda coordinator: coordinator.device.dashboard.model_name
+ in (
+ ModelName.LINEA_MICRA,
+ ModelName.LINEA_MINI,
+ ModelName.LINEA_MINI_R,
+ )
+ ),
+ ),
+ LaMarzoccoNumberEntityDescription(
+ key="prebrew_on",
+ translation_key="prebrew_time_on",
+ device_class=NumberDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ native_step=PRECISION_TENTHS,
+ native_min_value=0,
+ native_max_value=10,
+ entity_category=EntityCategory.CONFIG,
+ set_value_fn=(
+ lambda machine, value: machine.set_pre_extraction_times(
+ seconds_on=float(value),
+ seconds_off=cast(
+ PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
+ )
+ .times.pre_brewing[0]
+ .seconds.seconds_out,
+ )
+ ),
+ native_value_fn=(
+ lambda machine: cast(
+ PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
+ )
+ .times.pre_brewing[0]
+ .seconds.seconds_in
+ ),
+ available_fn=lambda machine: cast(
+ PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
+ ).mode
+ is PreExtractionMode.PREBREWING,
+ supported_fn=(
+ lambda coordinator: coordinator.device.dashboard.model_name
+ in (
+ ModelName.LINEA_MICRA,
+ ModelName.LINEA_MINI,
+ ModelName.LINEA_MINI_R,
+ )
+ ),
+ ),
+ LaMarzoccoNumberEntityDescription(
+ key="prebrew_off",
+ translation_key="prebrew_time_off",
+ device_class=NumberDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ native_step=PRECISION_TENTHS,
+ native_min_value=0,
+ native_max_value=10,
+ entity_category=EntityCategory.CONFIG,
+ set_value_fn=(
+ lambda machine, value: machine.set_pre_extraction_times(
+ seconds_on=cast(
+ PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
+ )
+ .times.pre_brewing[0]
+ .seconds.seconds_in,
+ seconds_off=float(value),
+ )
+ ),
+ native_value_fn=(
+ lambda machine: cast(
+ PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
+ )
+ .times.pre_brewing[0]
+ .seconds.seconds_out
+ ),
+ available_fn=(
+ lambda machine: cast(
+ PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]
+ ).mode
+ is PreExtractionMode.PREBREWING
+ ),
+ supported_fn=(
+ lambda coordinator: coordinator.device.dashboard.model_name
+ in (
+ ModelName.LINEA_MICRA,
+ ModelName.LINEA_MINI,
+ ModelName.LINEA_MINI_R,
+ )
),
),
)
@@ -230,7 +200,7 @@ SCALE_KEY_ENTITIES: tuple[LaMarzoccoKeyNumberEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number entities."""
coordinator = entry.runtime_data.config_coordinator
@@ -240,34 +210,6 @@ async def async_setup_entry(
if description.supported_fn(coordinator)
]
- for description in KEY_ENTITIES:
- if description.supported_fn(coordinator):
- num_keys = KEYS_PER_MODEL[MachineModel(coordinator.device.model)]
- entities.extend(
- LaMarzoccoKeyNumberEntity(coordinator, description, key)
- for key in range(min(num_keys, 1), num_keys + 1)
- )
-
- for description in SCALE_KEY_ENTITIES:
- if description.supported_fn(coordinator):
- if bbw_settings := coordinator.device.config.bbw_settings:
- entities.extend(
- LaMarzoccoScaleTargetNumberEntity(
- coordinator, description, int(key)
- )
- for key in bbw_settings.doses
- )
-
- def _async_add_new_scale() -> None:
- if bbw_settings := coordinator.device.config.bbw_settings:
- async_add_entities(
- LaMarzoccoScaleTargetNumberEntity(coordinator, description, int(key))
- for description in SCALE_KEY_ENTITIES
- for key in bbw_settings.doses
- )
-
- coordinator.new_device_callback.append(_async_add_new_scale)
-
async_add_entities(entities)
@@ -279,7 +221,7 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity):
@property
def native_value(self) -> float:
"""Return the current value."""
- return self.entity_description.native_value_fn(self.coordinator.device.config)
+ return self.entity_description.native_value_fn(self.coordinator.device)
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
@@ -298,62 +240,3 @@ class LaMarzoccoNumberEntity(LaMarzoccoEntity, NumberEntity):
},
) from exc
self.async_write_ha_state()
-
-
-class LaMarzoccoKeyNumberEntity(LaMarzoccoEntity, NumberEntity):
- """Number representing espresso machine with key support."""
-
- entity_description: LaMarzoccoKeyNumberEntityDescription
-
- def __init__(
- self,
- coordinator: LaMarzoccoUpdateCoordinator,
- description: LaMarzoccoKeyNumberEntityDescription,
- pyhsical_key: int,
- ) -> None:
- """Initialize the entity."""
- super().__init__(coordinator, description)
-
- # Physical Key on the machine the entity represents.
- if pyhsical_key == 0:
- pyhsical_key = 1
- else:
- self._attr_translation_key = f"{description.translation_key}_key"
- self._attr_translation_placeholders = {"key": str(pyhsical_key)}
- self._attr_unique_id = f"{super()._attr_unique_id}_key{pyhsical_key}"
- self._attr_entity_registry_enabled_default = False
- self.pyhsical_key = pyhsical_key
-
- @property
- def native_value(self) -> float | None:
- """Return the current value."""
- return self.entity_description.native_value_fn(
- self.coordinator.device.config, PhysicalKey(self.pyhsical_key)
- )
-
- async def async_set_native_value(self, value: float) -> None:
- """Set the value."""
- if value != self.native_value:
- try:
- await self.entity_description.set_value_fn(
- self.coordinator.device, value, PhysicalKey(self.pyhsical_key)
- )
- except RequestNotSuccessful as exc:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="number_exception_key",
- translation_placeholders={
- "key": self.entity_description.key,
- "value": str(value),
- "physical_key": str(self.pyhsical_key),
- },
- ) from exc
- self.async_write_ha_state()
-
-
-class LaMarzoccoScaleTargetNumberEntity(
- LaMarzoccoKeyNumberEntity, LaMarzoccScaleEntity
-):
- """Entity representing a key number on the scale."""
-
- entity_description: LaMarzoccoKeyNumberEntityDescription
diff --git a/homeassistant/components/lamarzocco/select.py b/homeassistant/components/lamarzocco/select.py
index 7acb654f0d2..44dad6bfb2a 100644
--- a/homeassistant/components/lamarzocco/select.py
+++ b/homeassistant/components/lamarzocco/select.py
@@ -2,50 +2,50 @@
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
-from typing import Any
+from typing import Any, cast
from pylamarzocco.const import (
- MachineModel,
- PhysicalKey,
- PrebrewMode,
- SmartStandbyMode,
- SteamLevel,
+ ModelName,
+ PreExtractionMode,
+ SmartStandByType,
+ SteamTargetLevel,
+ WidgetType,
)
-from pylamarzocco.devices.machine import LaMarzoccoMachine
+from pylamarzocco.devices import LaMarzoccoMachine
from pylamarzocco.exceptions import RequestNotSuccessful
-from pylamarzocco.models import LaMarzoccoMachineConfig
+from pylamarzocco.models import PreBrewing, SteamBoilerLevel
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry
-from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
+from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
PARALLEL_UPDATES = 1
STEAM_LEVEL_HA_TO_LM = {
- "1": SteamLevel.LEVEL_1,
- "2": SteamLevel.LEVEL_2,
- "3": SteamLevel.LEVEL_3,
+ "1": SteamTargetLevel.LEVEL_1,
+ "2": SteamTargetLevel.LEVEL_2,
+ "3": SteamTargetLevel.LEVEL_3,
}
STEAM_LEVEL_LM_TO_HA = {value: key for key, value in STEAM_LEVEL_HA_TO_LM.items()}
PREBREW_MODE_HA_TO_LM = {
- "disabled": PrebrewMode.DISABLED,
- "prebrew": PrebrewMode.PREBREW,
- "preinfusion": PrebrewMode.PREINFUSION,
+ "disabled": PreExtractionMode.DISABLED,
+ "prebrew": PreExtractionMode.PREBREWING,
+ "preinfusion": PreExtractionMode.PREINFUSION,
}
PREBREW_MODE_LM_TO_HA = {value: key for key, value in PREBREW_MODE_HA_TO_LM.items()}
STANDBY_MODE_HA_TO_LM = {
- "power_on": SmartStandbyMode.POWER_ON,
- "last_brewing": SmartStandbyMode.LAST_BREWING,
+ "power_on": SmartStandByType.POWER_ON,
+ "last_brewing": SmartStandByType.LAST_BREW,
}
STANDBY_MODE_LM_TO_HA = {value: key for key, value in STANDBY_MODE_HA_TO_LM.items()}
@@ -58,7 +58,7 @@ class LaMarzoccoSelectEntityDescription(
):
"""Description of a La Marzocco select entity."""
- current_option_fn: Callable[[LaMarzoccoMachineConfig], str | None]
+ current_option_fn: Callable[[LaMarzoccoMachine], str | None]
select_option_fn: Callable[[LaMarzoccoMachine, str], Coroutine[Any, Any, bool]]
@@ -70,24 +70,36 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
select_option_fn=lambda machine, option: machine.set_steam_level(
STEAM_LEVEL_HA_TO_LM[option]
),
- current_option_fn=lambda config: STEAM_LEVEL_LM_TO_HA[config.steam_level],
- supported_fn=lambda coordinator: coordinator.device.model
- == MachineModel.LINEA_MICRA,
+ current_option_fn=lambda machine: STEAM_LEVEL_LM_TO_HA[
+ cast(
+ SteamBoilerLevel,
+ machine.dashboard.config[WidgetType.CM_STEAM_BOILER_LEVEL],
+ ).target_level
+ ],
+ supported_fn=(
+ lambda coordinator: coordinator.device.dashboard.model_name
+ in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
+ ),
),
LaMarzoccoSelectEntityDescription(
key="prebrew_infusion_select",
translation_key="prebrew_infusion_select",
entity_category=EntityCategory.CONFIG,
options=["disabled", "prebrew", "preinfusion"],
- select_option_fn=lambda machine, option: machine.set_prebrew_mode(
+ select_option_fn=lambda machine, option: machine.set_pre_extraction_mode(
PREBREW_MODE_HA_TO_LM[option]
),
- current_option_fn=lambda config: PREBREW_MODE_LM_TO_HA[config.prebrew_mode],
- supported_fn=lambda coordinator: coordinator.device.model
- in (
- MachineModel.GS3_AV,
- MachineModel.LINEA_MICRA,
- MachineModel.LINEA_MINI,
+ current_option_fn=lambda machine: PREBREW_MODE_LM_TO_HA[
+ cast(PreBrewing, machine.dashboard.config[WidgetType.CM_PRE_BREWING]).mode
+ ],
+ supported_fn=(
+ lambda coordinator: coordinator.device.dashboard.model_name
+ in (
+ ModelName.LINEA_MICRA,
+ ModelName.LINEA_MINI,
+ ModelName.LINEA_MINI_R,
+ ModelName.GS3_AV,
+ )
),
),
LaMarzoccoSelectEntityDescription(
@@ -96,65 +108,30 @@ ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
options=["power_on", "last_brewing"],
select_option_fn=lambda machine, option: machine.set_smart_standby(
- enabled=machine.config.smart_standby.enabled,
+ enabled=machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
mode=STANDBY_MODE_HA_TO_LM[option],
- minutes=machine.config.smart_standby.minutes,
+ minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
),
- current_option_fn=lambda config: STANDBY_MODE_LM_TO_HA[
- config.smart_standby.mode
+ current_option_fn=lambda machine: STANDBY_MODE_LM_TO_HA[
+ machine.schedule.smart_wake_up_sleep.smart_stand_by_after
],
),
)
-SCALE_ENTITIES: tuple[LaMarzoccoSelectEntityDescription, ...] = (
- LaMarzoccoSelectEntityDescription(
- key="active_bbw",
- translation_key="active_bbw",
- options=["a", "b"],
- select_option_fn=lambda machine, option: machine.set_active_bbw_recipe(
- PhysicalKey[option.upper()]
- ),
- current_option_fn=lambda config: (
- config.bbw_settings.active_dose.name.lower()
- if config.bbw_settings
- else None
- ),
- ),
-)
-
async def async_setup_entry(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up select entities."""
coordinator = entry.runtime_data.config_coordinator
- entities = [
+ async_add_entities(
LaMarzoccoSelectEntity(coordinator, description)
for description in ENTITIES
if description.supported_fn(coordinator)
- ]
-
- if (
- coordinator.device.model == MachineModel.LINEA_MINI
- and coordinator.device.config.scale
- ):
- entities.extend(
- LaMarzoccoScaleSelectEntity(coordinator, description)
- for description in SCALE_ENTITIES
- )
-
- def _async_add_new_scale() -> None:
- async_add_entities(
- LaMarzoccoScaleSelectEntity(coordinator, description)
- for description in SCALE_ENTITIES
- )
-
- coordinator.new_device_callback.append(_async_add_new_scale)
-
- async_add_entities(entities)
+ )
class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
@@ -165,9 +142,7 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
@property
def current_option(self) -> str | None:
"""Return the current selected option."""
- return str(
- self.entity_description.current_option_fn(self.coordinator.device.config)
- )
+ return self.entity_description.current_option_fn(self.coordinator.device)
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
@@ -186,9 +161,3 @@ class LaMarzoccoSelectEntity(LaMarzoccoEntity, SelectEntity):
},
) from exc
self.async_write_ha_state()
-
-
-class LaMarzoccoScaleSelectEntity(LaMarzoccoSelectEntity, LaMarzoccScaleEntity):
- """Select entity for La Marzocco scales."""
-
- entity_description: LaMarzoccoSelectEntityDescription
diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py
index a2d6143daa5..17f11534483 100644
--- a/homeassistant/components/lamarzocco/sensor.py
+++ b/homeassistant/components/lamarzocco/sensor.py
@@ -2,27 +2,29 @@
from collections.abc import Callable
from dataclasses import dataclass
+from datetime import datetime
+from typing import cast
-from pylamarzocco.const import KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey
-from pylamarzocco.devices.machine import LaMarzoccoMachine
+from pylamarzocco.const import ModelName, WidgetType
+from pylamarzocco.models import (
+ BaseWidgetOutput,
+ CoffeeBoiler,
+ SteamBoilerLevel,
+ SteamBoilerTemperature,
+)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
- SensorStateClass,
-)
-from homeassistant.const import (
- PERCENTAGE,
- EntityCategory,
- UnitOfTemperature,
- UnitOfTime,
)
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
-from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
-from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
+from .coordinator import LaMarzoccoConfigEntry
+from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@@ -30,102 +32,56 @@ PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoSensorEntityDescription(
- LaMarzoccoEntityDescription, SensorEntityDescription
+ LaMarzoccoEntityDescription,
+ SensorEntityDescription,
):
"""Description of a La Marzocco sensor."""
- value_fn: Callable[[LaMarzoccoMachine], float | int]
-
-
-@dataclass(frozen=True, kw_only=True)
-class LaMarzoccoKeySensorEntityDescription(
- LaMarzoccoEntityDescription, SensorEntityDescription
-):
- """Description of a keyed La Marzocco sensor."""
-
- value_fn: Callable[[LaMarzoccoMachine, PhysicalKey], int | None]
+ value_fn: Callable[
+ [dict[WidgetType, BaseWidgetOutput]], StateType | datetime | None
+ ]
ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription(
- key="shot_timer",
- translation_key="shot_timer",
- native_unit_of_measurement=UnitOfTime.SECONDS,
- state_class=SensorStateClass.MEASUREMENT,
- device_class=SensorDeviceClass.DURATION,
- value_fn=lambda device: device.config.brew_active_duration,
- available_fn=lambda device: device.websocket_connected,
- entity_category=EntityCategory.DIAGNOSTIC,
- supported_fn=lambda coordinator: coordinator.local_connection_configured,
- ),
- LaMarzoccoSensorEntityDescription(
- key="current_temp_coffee",
- translation_key="current_temp_coffee",
- native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- suggested_display_precision=1,
- state_class=SensorStateClass.MEASUREMENT,
- device_class=SensorDeviceClass.TEMPERATURE,
- value_fn=lambda device: device.config.boilers[
- BoilerType.COFFEE
- ].current_temperature,
- ),
- LaMarzoccoSensorEntityDescription(
- key="current_temp_steam",
- translation_key="current_temp_steam",
- native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- suggested_display_precision=1,
- state_class=SensorStateClass.MEASUREMENT,
- device_class=SensorDeviceClass.TEMPERATURE,
- value_fn=lambda device: device.config.boilers[
- BoilerType.STEAM
- ].current_temperature,
- supported_fn=lambda coordinator: coordinator.device.model
- != MachineModel.LINEA_MINI,
- ),
-)
-
-STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
- LaMarzoccoSensorEntityDescription(
- key="drink_stats_coffee",
- translation_key="drink_stats_coffee",
- state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda device: device.statistics.total_coffee,
- available_fn=lambda device: len(device.statistics.drink_stats) > 0,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
- LaMarzoccoSensorEntityDescription(
- key="drink_stats_flushing",
- translation_key="drink_stats_flushing",
- state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda device: device.statistics.total_flushes,
- available_fn=lambda device: len(device.statistics.drink_stats) > 0,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
-)
-
-KEY_STATISTIC_ENTITIES: tuple[LaMarzoccoKeySensorEntityDescription, ...] = (
- LaMarzoccoKeySensorEntityDescription(
- key="drink_stats_coffee_key",
- translation_key="drink_stats_coffee_key",
- state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda device, key: device.statistics.drink_stats.get(key),
- available_fn=lambda device: len(device.statistics.drink_stats) > 0,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
-)
-
-SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
- LaMarzoccoSensorEntityDescription(
- key="scale_battery",
- native_unit_of_measurement=PERCENTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- device_class=SensorDeviceClass.BATTERY,
- value_fn=lambda device: (
- device.config.scale.battery if device.config.scale else 0
+ key="coffee_boiler_ready_time",
+ translation_key="coffee_boiler_ready_time",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=(
+ lambda config: cast(
+ CoffeeBoiler, config[WidgetType.CM_COFFEE_BOILER]
+ ).ready_start_time
),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ LaMarzoccoSensorEntityDescription(
+ key="steam_boiler_ready_time",
+ translation_key="steam_boiler_ready_time",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=(
+ lambda config: cast(
+ SteamBoilerLevel, config[WidgetType.CM_STEAM_BOILER_LEVEL]
+ ).ready_start_time
+ ),
+ entity_category=EntityCategory.DIAGNOSTIC,
supported_fn=(
- lambda coordinator: coordinator.device.model == MachineModel.LINEA_MINI
+ lambda coordinator: coordinator.device.dashboard.model_name
+ in (ModelName.LINEA_MICRA, ModelName.LINEA_MINI_R)
+ ),
+ ),
+ LaMarzoccoSensorEntityDescription(
+ key="steam_boiler_ready_time",
+ translation_key="steam_boiler_ready_time",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=(
+ lambda config: cast(
+ SteamBoilerTemperature, config[WidgetType.CM_STEAM_BOILER_TEMPERATURE]
+ ).ready_start_time
+ ),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ supported_fn=(
+ lambda coordinator: coordinator.device.dashboard.model_name
+ in (ModelName.GS3_AV, ModelName.GS3_MP, ModelName.LINEA_MINI)
),
),
)
@@ -134,91 +90,26 @@ SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor entities."""
- config_coordinator = entry.runtime_data.config_coordinator
+ coordinator = entry.runtime_data.config_coordinator
- entities: list[LaMarzoccoSensorEntity | LaMarzoccoKeySensorEntity] = []
-
- entities = [
- LaMarzoccoSensorEntity(config_coordinator, description)
+ async_add_entities(
+ LaMarzoccoSensorEntity(coordinator, description)
for description in ENTITIES
- if description.supported_fn(config_coordinator)
- ]
-
- if (
- config_coordinator.device.model == MachineModel.LINEA_MINI
- and config_coordinator.device.config.scale
- ):
- entities.extend(
- LaMarzoccoScaleSensorEntity(config_coordinator, description)
- for description in SCALE_ENTITIES
- )
-
- statistics_coordinator = entry.runtime_data.statistics_coordinator
- entities.extend(
- LaMarzoccoSensorEntity(statistics_coordinator, description)
- for description in STATISTIC_ENTITIES
- if description.supported_fn(statistics_coordinator)
+ if description.supported_fn(coordinator)
)
- num_keys = KEYS_PER_MODEL[MachineModel(config_coordinator.device.model)]
- if num_keys > 0:
- entities.extend(
- LaMarzoccoKeySensorEntity(statistics_coordinator, description, key)
- for description in KEY_STATISTIC_ENTITIES
- for key in range(1, num_keys + 1)
- )
-
- def _async_add_new_scale() -> None:
- async_add_entities(
- LaMarzoccoScaleSensorEntity(config_coordinator, description)
- for description in SCALE_ENTITIES
- )
-
- config_coordinator.new_device_callback.append(_async_add_new_scale)
-
- async_add_entities(entities)
-
class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
- """Sensor representing espresso machine temperature data."""
+ """Sensor representing espresso machine water reservoir status."""
entity_description: LaMarzoccoSensorEntityDescription
@property
- def native_value(self) -> int | float | None:
- """State of the sensor."""
- return self.entity_description.value_fn(self.coordinator.device)
-
-
-class LaMarzoccoKeySensorEntity(LaMarzoccoEntity, SensorEntity):
- """Sensor for a La Marzocco key."""
-
- entity_description: LaMarzoccoKeySensorEntityDescription
-
- def __init__(
- self,
- coordinator: LaMarzoccoUpdateCoordinator,
- description: LaMarzoccoKeySensorEntityDescription,
- key: int,
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator, description)
- self.key = key
- self._attr_translation_placeholders = {"key": str(key)}
- self._attr_unique_id = f"{super()._attr_unique_id}_key{key}"
-
- @property
- def native_value(self) -> int | None:
- """State of the sensor."""
+ def native_value(self) -> StateType | datetime | None:
+ """Return value of the sensor."""
return self.entity_description.value_fn(
- self.coordinator.device, PhysicalKey(self.key)
+ self.coordinator.device.dashboard.config
)
-
-
-class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity):
- """Sensor for a La Marzocco scale."""
-
- entity_description: LaMarzoccoSensorEntityDescription
diff --git a/homeassistant/components/lamarzocco/strings.json b/homeassistant/components/lamarzocco/strings.json
index 62050685c27..7a77b8ad72c 100644
--- a/homeassistant/components/lamarzocco/strings.json
+++ b/homeassistant/components/lamarzocco/strings.json
@@ -32,13 +32,11 @@
}
},
"machine_selection": {
- "description": "Select the machine you want to integrate. Set the \"IP\" to get access to shot time related sensors.",
+ "description": "Select the machine you want to integrate.",
"data": {
- "host": "[%key:common::config_flow::data::ip%]",
"machine": "Machine"
},
"data_description": {
- "host": "Local IP address of the machine",
"machine": "Select the machine you want to integrate"
}
},
@@ -85,6 +83,9 @@
},
"water_tank": {
"name": "Water tank empty"
+ },
+ "websocket_connected": {
+ "name": "WebSocket connected"
}
},
"button": {
@@ -101,52 +102,24 @@
"coffee_temp": {
"name": "Coffee target temperature"
},
- "dose_key": {
- "name": "Dose Key {key}"
- },
- "prebrew_on": {
- "name": "Prebrew on time"
- },
- "prebrew_on_key": {
- "name": "Prebrew on time Key {key}"
- },
- "prebrew_off": {
- "name": "Prebrew off time"
- },
- "prebrew_off_key": {
- "name": "Prebrew off time Key {key}"
- },
- "preinfusion_off": {
- "name": "Preinfusion time"
- },
- "preinfusion_off_key": {
- "name": "Preinfusion time Key {key}"
- },
- "scale_target_key": {
- "name": "Brew by weight target {key}"
- },
"smart_standby_time": {
"name": "Smart standby time"
},
- "steam_temp": {
- "name": "Steam target temperature"
+ "preinfusion_time": {
+ "name": "Preinfusion time"
},
- "tea_water_duration": {
- "name": "Tea water duration"
+ "prebrew_time_on": {
+ "name": "Prebrew on time"
+ },
+ "prebrew_time_off": {
+ "name": "Prebrew off time"
}
},
"select": {
- "active_bbw": {
- "name": "Active brew by weight recipe",
- "state": {
- "a": "Recipe A",
- "b": "Recipe B"
- }
- },
"prebrew_infusion_select": {
"name": "Prebrew/-infusion mode",
"state": {
- "disabled": "Disabled",
+ "disabled": "[%key:common::state::disabled%]",
"prebrew": "Prebrew",
"preinfusion": "Preinfusion"
}
@@ -168,26 +141,11 @@
}
},
"sensor": {
- "current_temp_coffee": {
- "name": "Current coffee temperature"
+ "coffee_boiler_ready_time": {
+ "name": "Coffee boiler ready time"
},
- "current_temp_steam": {
- "name": "Current steam temperature"
- },
- "drink_stats_coffee": {
- "name": "Total coffees made",
- "unit_of_measurement": "coffees"
- },
- "drink_stats_coffee_key": {
- "name": "Coffees made Key {key}",
- "unit_of_measurement": "coffees"
- },
- "drink_stats_flushing": {
- "name": "Total flushes made",
- "unit_of_measurement": "flushes"
- },
- "shot_timer": {
- "name": "Shot timer"
+ "steam_boiler_ready_time": {
+ "name": "Steam boiler ready time"
}
},
"switch": {
@@ -232,9 +190,6 @@
"number_exception": {
"message": "Error while setting value {value} for number {key}"
},
- "number_exception_key": {
- "message": "Error while setting value {value} for number {key}, key {physical_key}"
- },
"select_option_error": {
"message": "Error while setting select option {option} for {key}"
},
diff --git a/homeassistant/components/lamarzocco/switch.py b/homeassistant/components/lamarzocco/switch.py
index 54bd1ac2aed..ca5fb820150 100644
--- a/homeassistant/components/lamarzocco/switch.py
+++ b/homeassistant/components/lamarzocco/switch.py
@@ -2,18 +2,23 @@
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
-from typing import Any
+from typing import Any, cast
-from pylamarzocco.const import BoilerType
-from pylamarzocco.devices.machine import LaMarzoccoMachine
+from pylamarzocco import LaMarzoccoMachine
+from pylamarzocco.const import MachineMode, ModelName, WidgetType
from pylamarzocco.exceptions import RequestNotSuccessful
-from pylamarzocco.models import LaMarzoccoMachineConfig
+from pylamarzocco.models import (
+ MachineStatus,
+ SteamBoilerLevel,
+ SteamBoilerTemperature,
+ WakeUpScheduleSettings,
+)
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
@@ -30,7 +35,7 @@ class LaMarzoccoSwitchEntityDescription(
"""Description of a La Marzocco Switch."""
control_fn: Callable[[LaMarzoccoMachine, bool], Coroutine[Any, Any, bool]]
- is_on_fn: Callable[[LaMarzoccoMachineConfig], bool]
+ is_on_fn: Callable[[LaMarzoccoMachine], bool]
ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
@@ -39,13 +44,42 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
translation_key="main",
name=None,
control_fn=lambda machine, state: machine.set_power(state),
- is_on_fn=lambda config: config.turned_on,
+ is_on_fn=(
+ lambda machine: cast(
+ MachineStatus, machine.dashboard.config[WidgetType.CM_MACHINE_STATUS]
+ ).mode
+ is MachineMode.BREWING_MODE
+ ),
),
LaMarzoccoSwitchEntityDescription(
key="steam_boiler_enable",
translation_key="steam_boiler",
control_fn=lambda machine, state: machine.set_steam(state),
- is_on_fn=lambda config: config.boilers[BoilerType.STEAM].enabled,
+ is_on_fn=(
+ lambda machine: cast(
+ SteamBoilerLevel,
+ machine.dashboard.config[WidgetType.CM_STEAM_BOILER_LEVEL],
+ ).enabled
+ ),
+ supported_fn=(
+ lambda coordinator: coordinator.device.dashboard.model_name
+ in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
+ ),
+ ),
+ LaMarzoccoSwitchEntityDescription(
+ key="steam_boiler_enable",
+ translation_key="steam_boiler",
+ control_fn=lambda machine, state: machine.set_steam(state),
+ is_on_fn=(
+ lambda machine: cast(
+ SteamBoilerTemperature,
+ machine.dashboard.config[WidgetType.CM_STEAM_BOILER_TEMPERATURE],
+ ).enabled
+ ),
+ supported_fn=(
+ lambda coordinator: coordinator.device.dashboard.model_name
+ not in (ModelName.LINEA_MINI_R, ModelName.LINEA_MICRA)
+ ),
),
LaMarzoccoSwitchEntityDescription(
key="smart_standby_enabled",
@@ -53,10 +87,10 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
control_fn=lambda machine, state: machine.set_smart_standby(
enabled=state,
- mode=machine.config.smart_standby.mode,
- minutes=machine.config.smart_standby.minutes,
+ mode=machine.schedule.smart_wake_up_sleep.smart_stand_by_after,
+ minutes=machine.schedule.smart_wake_up_sleep.smart_stand_by_minutes,
),
- is_on_fn=lambda config: config.smart_standby.enabled,
+ is_on_fn=lambda machine: machine.schedule.smart_wake_up_sleep.smart_stand_by_enabled,
),
)
@@ -64,7 +98,7 @@ ENTITIES: tuple[LaMarzoccoSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch entities and services."""
@@ -78,8 +112,8 @@ async def async_setup_entry(
)
entities.extend(
- LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry_id)
- for wake_up_sleep_entry_id in coordinator.device.config.wake_up_sleep_entries
+ LaMarzoccoAutoOnOffSwitchEntity(coordinator, wake_up_sleep_entry)
+ for wake_up_sleep_entry in coordinator.device.schedule.smart_wake_up_sleep.schedules
)
async_add_entities(entities)
@@ -117,7 +151,7 @@ class LaMarzoccoSwitchEntity(LaMarzoccoEntity, SwitchEntity):
@property
def is_on(self) -> bool:
"""Return true if device is on."""
- return self.entity_description.is_on_fn(self.coordinator.device.config)
+ return self.entity_description.is_on_fn(self.coordinator.device)
class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity):
@@ -129,22 +163,21 @@ class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity):
def __init__(
self,
coordinator: LaMarzoccoUpdateCoordinator,
- identifier: str,
+ schedule_entry: WakeUpScheduleSettings,
) -> None:
"""Initialize the switch."""
- super().__init__(coordinator, f"auto_on_off_{identifier}")
- self._identifier = identifier
- self._attr_translation_placeholders = {"id": identifier}
- self.entity_category = EntityCategory.CONFIG
+ super().__init__(coordinator, f"auto_on_off_{schedule_entry.identifier}")
+ assert schedule_entry.identifier
+ self._schedule_entry = schedule_entry
+ self._identifier = schedule_entry.identifier
+ self._attr_translation_placeholders = {"id": schedule_entry.identifier}
+ self._attr_entity_category = EntityCategory.CONFIG
async def _async_enable(self, state: bool) -> None:
"""Enable or disable the auto on/off schedule."""
- wake_up_sleep_entry = self.coordinator.device.config.wake_up_sleep_entries[
- self._identifier
- ]
- wake_up_sleep_entry.enabled = state
+ self._schedule_entry.enabled = state
try:
- await self.coordinator.device.set_wake_up_sleep(wake_up_sleep_entry)
+ await self.coordinator.device.set_wakeup_schedule(self._schedule_entry)
except RequestNotSuccessful as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
@@ -164,6 +197,4 @@ class LaMarzoccoAutoOnOffSwitchEntity(LaMarzoccoBaseEntity, SwitchEntity):
@property
def is_on(self) -> bool:
"""Return true if switch is on."""
- return self.coordinator.device.config.wake_up_sleep_entries[
- self._identifier
- ].enabled
+ return self._schedule_entry.enabled
diff --git a/homeassistant/components/lamarzocco/update.py b/homeassistant/components/lamarzocco/update.py
index 0833ee6e249..632c66a8b66 100644
--- a/homeassistant/components/lamarzocco/update.py
+++ b/homeassistant/components/lamarzocco/update.py
@@ -1,9 +1,10 @@
"""Support for La Marzocco update entities."""
+import asyncio
from dataclasses import dataclass
from typing import Any
-from pylamarzocco.const import FirmwareType
+from pylamarzocco.const import FirmwareType, UpdateCommandStatus
from pylamarzocco.exceptions import RequestNotSuccessful
from homeassistant.components.update import (
@@ -15,13 +16,14 @@ from homeassistant.components.update import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMarzoccoConfigEntry
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription
PARALLEL_UPDATES = 1
+MAX_UPDATE_WAIT = 150
@dataclass(frozen=True, kw_only=True)
@@ -55,11 +57,11 @@ ENTITIES: tuple[LaMarzoccoUpdateEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LaMarzoccoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create update entities."""
- coordinator = entry.runtime_data.firmware_coordinator
+ coordinator = entry.runtime_data.settings_coordinator
async_add_entities(
LaMarzoccoUpdateEntity(coordinator, description)
for description in ENTITIES
@@ -71,38 +73,67 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
"""Entity representing the update state."""
entity_description: LaMarzoccoUpdateEntityDescription
- _attr_supported_features = UpdateEntityFeature.INSTALL
+ _attr_supported_features = (
+ UpdateEntityFeature.INSTALL
+ | UpdateEntityFeature.PROGRESS
+ | UpdateEntityFeature.RELEASE_NOTES
+ )
@property
- def installed_version(self) -> str | None:
+ def installed_version(self) -> str:
"""Return the current firmware version."""
- return self.coordinator.device.firmware[
+ return self.coordinator.device.settings.firmwares[
self.entity_description.component
- ].current_version
+ ].build_version
@property
def latest_version(self) -> str:
"""Return the latest firmware version."""
- return self.coordinator.device.firmware[
+ if available_update := self.coordinator.device.settings.firmwares[
self.entity_description.component
- ].latest_version
+ ].available_update:
+ return available_update.build_version
+ return self.installed_version
@property
def release_url(self) -> str | None:
"""Return the release notes URL."""
return "https://support-iot.lamarzocco.com/firmware-updates/"
+ def release_notes(self) -> str | None:
+ """Return the release notes for the latest firmware version."""
+ if available_update := self.coordinator.device.settings.firmwares[
+ self.entity_description.component
+ ].available_update:
+ return available_update.change_log
+ return None
+
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
+
self._attr_in_progress = True
self.async_write_ha_state()
+
+ counter = 0
+
+ def _raise_timeout_error() -> None: # to avoid TRY301
+ raise TimeoutError("Update timed out")
+
try:
- success = await self.coordinator.device.update_firmware(
- self.entity_description.component
- )
- except RequestNotSuccessful as exc:
+ await self.coordinator.device.update_firmware()
+ while (
+ update_progress := await self.coordinator.device.get_firmware()
+ ).command_status is UpdateCommandStatus.IN_PROGRESS:
+ if counter >= MAX_UPDATE_WAIT:
+ _raise_timeout_error()
+ self._attr_update_percentage = update_progress.progress_percentage
+ self.async_write_ha_state()
+ await asyncio.sleep(3)
+ counter += 1
+
+ except (TimeoutError, RequestNotSuccessful) as exc:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="update_failed",
@@ -110,13 +141,6 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
"key": self.entity_description.key,
},
) from exc
- if not success:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="update_failed",
- translation_placeholders={
- "key": self.entity_description.key,
- },
- )
- self._attr_in_progress = False
- await self.coordinator.async_request_refresh()
+ finally:
+ self._attr_in_progress = False
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/lametric/button.py b/homeassistant/components/lametric/button.py
index f0a452f2d02..3c7d754fa0b 100644
--- a/homeassistant/components/lametric/button.py
+++ b/homeassistant/components/lametric/button.py
@@ -12,7 +12,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMetricDataUpdateCoordinator
@@ -58,7 +58,7 @@ BUTTONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LaMetric button based on a config entry."""
coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/lametric/number.py b/homeassistant/components/lametric/number.py
index ccfd48a3abf..7f356741d76 100644
--- a/homeassistant/components/lametric/number.py
+++ b/homeassistant/components/lametric/number.py
@@ -12,7 +12,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMetricDataUpdateCoordinator
@@ -58,7 +58,7 @@ NUMBERS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LaMetric number based on a config entry."""
coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/lametric/select.py b/homeassistant/components/lametric/select.py
index bf9872f2791..eab7cd5997c 100644
--- a/homeassistant/components/lametric/select.py
+++ b/homeassistant/components/lametric/select.py
@@ -12,7 +12,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMetricDataUpdateCoordinator
@@ -43,7 +43,7 @@ SELECTS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LaMetric select based on a config entry."""
coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/lametric/sensor.py b/homeassistant/components/lametric/sensor.py
index f202a77b530..a5d5da3c046 100644
--- a/homeassistant/components/lametric/sensor.py
+++ b/homeassistant/components/lametric/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMetricDataUpdateCoordinator
@@ -45,7 +45,7 @@ SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LaMetric sensor based on a config entry."""
coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json
index 3c2f05fa535..0656454bb01 100644
--- a/homeassistant/components/lametric/strings.json
+++ b/homeassistant/components/lametric/strings.json
@@ -18,7 +18,7 @@
},
"data_description": {
"host": "The IP address or hostname of your LaMetric TIME on your network.",
- "api_key": "You can find this API key in [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)."
+ "api_key": "You can find this API key in the [devices page in your LaMetric developer account](https://developer.lametric.com/user/devices)."
}
},
"cloud_select_device": {
@@ -83,8 +83,8 @@
"brightness_mode": {
"name": "Brightness mode",
"state": {
- "auto": "Automatic",
- "manual": "Manual"
+ "auto": "[%key:common::state::auto%]",
+ "manual": "[%key:common::state::manual%]"
}
}
},
diff --git a/homeassistant/components/lametric/switch.py b/homeassistant/components/lametric/switch.py
index 3aabfaf17e1..85e61164639 100644
--- a/homeassistant/components/lametric/switch.py
+++ b/homeassistant/components/lametric/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LaMetricDataUpdateCoordinator
@@ -48,7 +48,7 @@ SWITCHES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LaMetric switch based on a config entry."""
coordinator: LaMetricDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/landisgyr_heat_meter/sensor.py b/homeassistant/components/landisgyr_heat_meter/sensor.py
index dd76d3e53cc..9bb4af572fd 100644
--- a/homeassistant/components/landisgyr_heat_meter/sensor.py
+++ b/homeassistant/components/landisgyr_heat_meter/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
@@ -269,7 +269,9 @@ HEAT_METER_SENSOR_TYPES = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
unique_id = entry.entry_id
diff --git a/homeassistant/components/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py
index 0e1f680dd63..ca40aebd0d4 100644
--- a/homeassistant/components/lastfm/config_flow.py
+++ b/homeassistant/components/lastfm/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import logging
from typing import Any
from pylast import LastFMNetwork, PyLastError, User, WSError
@@ -32,6 +33,8 @@ CONFIG_SCHEMA: vol.Schema = vol.Schema(
}
)
+_LOGGER = logging.getLogger(__name__)
+
def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]:
"""Get and validate lastFM User."""
@@ -49,7 +52,8 @@ def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]:
errors["base"] = "invalid_auth"
else:
errors["base"] = "unknown"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
return user, errors
diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py
index 48770113a80..89025583e92 100644
--- a/homeassistant/components/lastfm/sensor.py
+++ b/homeassistant/components/lastfm/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -27,7 +27,7 @@ from .coordinator import LastFMDataUpdateCoordinator, LastFMUserData
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize the entries."""
diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py
index 7d3b2bd97b6..201b4c8f037 100644
--- a/homeassistant/components/launch_library/sensor.py
+++ b/homeassistant/components/launch_library/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, PERCENTAGE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -122,7 +122,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
name = entry.data.get(CONF_NAME, DEFAULT_NEXT_LAUNCH_NAME)
diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py
index cee6aa6c754..82f4f7609dc 100644
--- a/homeassistant/components/laundrify/binary_sensor.py
+++ b/homeassistant/components/laundrify/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, MODELS
@@ -23,7 +23,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors from a config entry created in the integrations UI."""
diff --git a/homeassistant/components/laundrify/sensor.py b/homeassistant/components/laundrify/sensor.py
index 98169f95fce..3c343861b0a 100644
--- a/homeassistant/components/laundrify/sensor.py
+++ b/homeassistant/components/laundrify/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -24,7 +24,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add power sensor for passed config_entry in HA."""
diff --git a/homeassistant/components/lawn_mower/__init__.py b/homeassistant/components/lawn_mower/__init__.py
index 0680bfc9d71..f8c3e0cd67d 100644
--- a/homeassistant/components/lawn_mower/__init__.py
+++ b/homeassistant/components/lawn_mower/__init__.py
@@ -28,6 +28,7 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
DATA_COMPONENT: HassKey[EntityComponent[LawnMowerEntity]] = HassKey(DOMAIN)
+ENTITY_ID_FORMAT = DOMAIN + ".{}"
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
SCAN_INTERVAL = timedelta(seconds=60)
diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json
index ebaea4ffd6a..9cc56b8a11e 100644
--- a/homeassistant/components/lawn_mower/strings.json
+++ b/homeassistant/components/lawn_mower/strings.json
@@ -4,7 +4,7 @@
"_": {
"name": "[%key:component::lawn_mower::title%]",
"state": {
- "error": "Error",
+ "error": "[%key:common::state::error%]",
"paused": "[%key:common::state::paused%]",
"mowing": "Mowing",
"docked": "Docked",
diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py
index 58924413c56..256e132b30d 100644
--- a/homeassistant/components/lcn/__init__.py
+++ b/homeassistant/components/lcn/__init__.py
@@ -49,6 +49,7 @@ from .helpers import (
InputType,
async_update_config_entry,
generate_unique_id,
+ purge_device_registry,
register_lcn_address_devices,
register_lcn_host_device,
)
@@ -120,6 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
register_lcn_host_device(hass, config_entry)
register_lcn_address_devices(hass, config_entry)
+ # clean up orphaned devices
+ purge_device_registry(hass, config_entry.entry_id, {**config_entry.data})
+
# forward config_entry to components
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py
index d0ce4815f19..65afae56f22 100644
--- a/homeassistant/components/lcn/binary_sensor.py
+++ b/homeassistant/components/lcn/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.script import scripts_with_entity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SOURCE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
@@ -35,7 +35,7 @@ from .helpers import InputType
def add_lcn_entities(
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
entity_configs: Iterable[ConfigType],
) -> None:
"""Add entities for this domain."""
@@ -54,7 +54,7 @@ def add_lcn_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LCN switch entities from a config entry."""
add_entities = partial(
diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py
index 1dff15c4f22..e91ae723714 100644
--- a/homeassistant/components/lcn/climate.py
+++ b/homeassistant/components/lcn/climate.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -43,7 +43,7 @@ PARALLEL_UPDATES = 0
def add_lcn_entities(
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
entity_configs: Iterable[ConfigType],
) -> None:
"""Add entities for this domain."""
@@ -57,7 +57,7 @@ def add_lcn_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LCN switch entities from a config entry."""
add_entities = partial(
diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py
index 042461b6af2..be713871aae 100644
--- a/homeassistant/components/lcn/cover.py
+++ b/homeassistant/components/lcn/cover.py
@@ -10,7 +10,7 @@ from homeassistant.components.cover import DOMAIN as DOMAIN_COVER, CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0
def add_lcn_entities(
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
entity_configs: Iterable[ConfigType],
) -> None:
"""Add entities for this domain."""
@@ -45,7 +45,7 @@ def add_lcn_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LCN cover entities from a config entry."""
add_entities = partial(
diff --git a/homeassistant/components/lcn/entity.py b/homeassistant/components/lcn/entity.py
index 12d8f966801..ffb680c4237 100644
--- a/homeassistant/components/lcn/entity.py
+++ b/homeassistant/components/lcn/entity.py
@@ -3,19 +3,18 @@
from collections.abc import Callable
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME, CONF_RESOURCE
+from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType
-from .const import CONF_DOMAIN_DATA, DOMAIN
+from .const import DOMAIN
from .helpers import (
AddressType,
DeviceConnectionType,
InputType,
generate_unique_id,
get_device_connection,
- get_device_model,
)
@@ -36,6 +35,14 @@ class LcnEntity(Entity):
self.address: AddressType = config[CONF_ADDRESS]
self._unregister_for_inputs: Callable | None = None
self._name: str = config[CONF_NAME]
+ self._attr_device_info = DeviceInfo(
+ identifiers={
+ (
+ DOMAIN,
+ generate_unique_id(self.config_entry.entry_id, self.address),
+ )
+ },
+ )
@property
def unique_id(self) -> str:
@@ -44,28 +51,6 @@ class LcnEntity(Entity):
self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE]
)
- @property
- def device_info(self) -> DeviceInfo | None:
- """Return device specific attributes."""
- address = f"{'g' if self.address[2] else 'm'}{self.address[0]:03d}{self.address[1]:03d}"
- model = (
- "LCN resource"
- f" ({get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])})"
- )
-
- return DeviceInfo(
- identifiers={(DOMAIN, self.unique_id)},
- name=f"{address}.{self.config[CONF_RESOURCE]}",
- model=model,
- manufacturer="Issendorff",
- via_device=(
- DOMAIN,
- generate_unique_id(
- self.config_entry.entry_id, self.config[CONF_ADDRESS]
- ),
- ),
- )
-
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self.device_connection = get_device_connection(
diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py
index b999c6f3770..2176c669251 100644
--- a/homeassistant/components/lcn/helpers.py
+++ b/homeassistant/components/lcn/helpers.py
@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from copy import deepcopy
-from itertools import chain
import re
from typing import cast
@@ -22,7 +21,6 @@ from homeassistant.const import (
CONF_NAME,
CONF_RESOURCE,
CONF_SENSORS,
- CONF_SOURCE,
CONF_SWITCHES,
)
from homeassistant.core import HomeAssistant
@@ -30,23 +28,14 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .const import (
- BINSENSOR_PORTS,
CONF_CLIMATES,
CONF_HARDWARE_SERIAL,
CONF_HARDWARE_TYPE,
- CONF_OUTPUT,
CONF_SCENES,
CONF_SOFTWARE_SERIAL,
CONNECTION,
DEVICE_CONNECTIONS,
DOMAIN,
- LED_PORTS,
- LOGICOP_PORTS,
- OUTPUT_PORTS,
- S0_INPUTS,
- SETPOINTS,
- THRESHOLDS,
- VARIABLES,
)
# typing
@@ -96,31 +85,6 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str:
raise ValueError("Unknown domain")
-def get_device_model(domain_name: str, domain_data: ConfigType) -> str:
- """Return the model for the specified domain_data."""
- if domain_name in ("switch", "light"):
- return "Output" if domain_data[CONF_OUTPUT] in OUTPUT_PORTS else "Relay"
- if domain_name in ("binary_sensor", "sensor"):
- if domain_data[CONF_SOURCE] in BINSENSOR_PORTS:
- return "Binary Sensor"
- if domain_data[CONF_SOURCE] in chain(
- VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS
- ):
- return "Variable"
- if domain_data[CONF_SOURCE] in LED_PORTS:
- return "Led"
- if domain_data[CONF_SOURCE] in LOGICOP_PORTS:
- return "Logical Operation"
- return "Key"
- if domain_name == "cover":
- return "Motor"
- if domain_name == "climate":
- return "Regulator"
- if domain_name == "scene":
- return "Scene"
- raise ValueError("Unknown domain")
-
-
def generate_unique_id(
entry_id: str,
address: AddressType,
@@ -169,13 +133,6 @@ def purge_device_registry(
) -> None:
"""Remove orphans from device registry which are not in entry data."""
device_registry = dr.async_get(hass)
- entity_registry = er.async_get(hass)
-
- # Find all devices that are referenced in the entity registry.
- references_entities = {
- entry.device_id
- for entry in entity_registry.entities.get_entries_for_config_entry_id(entry_id)
- }
# Find device that references the host.
references_host = set()
@@ -198,7 +155,6 @@ def purge_device_registry(
entry.id
for entry in dr.async_entries_for_config_entry(device_registry, entry_id)
}
- - references_entities
- references_host
- references_entry_data
)
diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py
index 9ec660325c8..cba7c0888b7 100644
--- a/homeassistant/components/lcn/light.py
+++ b/homeassistant/components/lcn/light.py
@@ -17,7 +17,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -37,7 +37,7 @@ PARALLEL_UPDATES = 0
def add_lcn_entities(
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
entity_configs: Iterable[ConfigType],
) -> None:
"""Add entities for this domain."""
@@ -54,7 +54,7 @@ def add_lcn_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LCN light entities from a config entry."""
add_entities = partial(
diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py
index 0f40926cf17..072d0a20757 100644
--- a/homeassistant/components/lcn/scene.py
+++ b/homeassistant/components/lcn/scene.py
@@ -10,7 +10,7 @@ from homeassistant.components.scene import DOMAIN as DOMAIN_SCENE, Scene
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES, CONF_SCENE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -29,7 +29,7 @@ PARALLEL_UPDATES = 0
def add_lcn_entities(
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
entity_configs: Iterable[ConfigType],
) -> None:
"""Add entities for this domain."""
@@ -43,7 +43,7 @@ def add_lcn_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LCN switch entities from a config entry."""
add_entities = partial(
diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py
index ada0857742c..0c78ea6637a 100644
--- a/homeassistant/components/lcn/sensor.py
+++ b/homeassistant/components/lcn/sensor.py
@@ -3,7 +3,6 @@
from collections.abc import Iterable
from functools import partial
from itertools import chain
-from typing import cast
import pypck
@@ -14,13 +13,19 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
+ CONCENTRATION_PARTS_PER_MILLION,
CONF_DOMAIN,
CONF_ENTITIES,
CONF_SOURCE,
CONF_UNIT_OF_MEASUREMENT,
+ LIGHT_LUX,
+ UnitOfElectricCurrent,
+ UnitOfElectricPotential,
+ UnitOfSpeed,
+ UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -45,12 +50,25 @@ DEVICE_CLASS_MAPPING = {
pypck.lcn_defs.VarUnit.METERPERSECOND: SensorDeviceClass.SPEED,
pypck.lcn_defs.VarUnit.VOLT: SensorDeviceClass.VOLTAGE,
pypck.lcn_defs.VarUnit.AMPERE: SensorDeviceClass.CURRENT,
+ pypck.lcn_defs.VarUnit.PPM: SensorDeviceClass.CO2,
+}
+
+UNIT_OF_MEASUREMENT_MAPPING = {
+ pypck.lcn_defs.VarUnit.CELSIUS: UnitOfTemperature.CELSIUS,
+ pypck.lcn_defs.VarUnit.KELVIN: UnitOfTemperature.KELVIN,
+ pypck.lcn_defs.VarUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
+ pypck.lcn_defs.VarUnit.LUX_T: LIGHT_LUX,
+ pypck.lcn_defs.VarUnit.LUX_I: LIGHT_LUX,
+ pypck.lcn_defs.VarUnit.METERPERSECOND: UnitOfSpeed.METERS_PER_SECOND,
+ pypck.lcn_defs.VarUnit.VOLT: UnitOfElectricPotential.VOLT,
+ pypck.lcn_defs.VarUnit.AMPERE: UnitOfElectricCurrent.AMPERE,
+ pypck.lcn_defs.VarUnit.PPM: CONCENTRATION_PARTS_PER_MILLION,
}
def add_lcn_entities(
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
entity_configs: Iterable[ConfigType],
) -> None:
"""Add entities for this domain."""
@@ -69,7 +87,7 @@ def add_lcn_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LCN switch entities from a config entry."""
add_entities = partial(
@@ -103,8 +121,10 @@ class LcnVariableSensor(LcnEntity, SensorEntity):
config[CONF_DOMAIN_DATA][CONF_UNIT_OF_MEASUREMENT]
)
- self._attr_native_unit_of_measurement = cast(str, self.unit.value)
- self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit, None)
+ self._attr_native_unit_of_measurement = UNIT_OF_MEASUREMENT_MAPPING.get(
+ self.unit
+ )
+ self._attr_device_class = DEVICE_CLASS_MAPPING.get(self.unit)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json
index 0bdd85a3678..0a8112d997a 100644
--- a/homeassistant/components/lcn/strings.json
+++ b/homeassistant/components/lcn/strings.json
@@ -396,19 +396,19 @@
},
"address_to_device_id": {
"name": "Address to device ID",
- "description": "Convert LCN address to device ID.",
+ "description": "Converts an LCN address into a device ID.",
"fields": {
"id": {
"name": "Module or group ID",
- "description": "Target module or group ID."
+ "description": "Module or group number of the target."
},
"segment_id": {
"name": "Segment ID",
- "description": "Target segment ID."
+ "description": "Segment number of the target."
},
"type": {
"name": "Type",
- "description": "Target type."
+ "description": "Module type of the target."
},
"host": {
"name": "Host name",
diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py
index dd940bd38b3..6267a081bc9 100644
--- a/homeassistant/components/lcn/switch.py
+++ b/homeassistant/components/lcn/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntit
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DOMAIN, CONF_ENTITIES
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .const import (
@@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0
def add_lcn_switch_entities(
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
entity_configs: Iterable[ConfigType],
) -> None:
"""Add entities for this domain."""
@@ -53,7 +53,7 @@ def add_lcn_switch_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LCN switch entities from a config entry."""
add_entities = partial(
diff --git a/homeassistant/components/ld2410_ble/binary_sensor.py b/homeassistant/components/ld2410_ble/binary_sensor.py
index c52bc34b699..3ba43e0d6dc 100644
--- a/homeassistant/components/ld2410_ble/binary_sensor.py
+++ b/homeassistant/components/ld2410_ble/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import LD2410BLE, LD2410BLECoordinator
@@ -31,7 +31,7 @@ ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform for LD2410BLE."""
data: LD2410BLEData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json
index 36d0150642e..3d8f8793e25 100644
--- a/homeassistant/components/ld2410_ble/manifest.json
+++ b/homeassistant/components/ld2410_ble/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["bluetooth-data-tools==1.23.4", "ld2410-ble==0.1.1"]
+ "requirements": ["bluetooth-data-tools==1.27.0", "ld2410-ble==0.1.1"]
}
diff --git a/homeassistant/components/ld2410_ble/sensor.py b/homeassistant/components/ld2410_ble/sensor.py
index 6daa1397161..db4e42580c4 100644
--- a/homeassistant/components/ld2410_ble/sensor.py
+++ b/homeassistant/components/ld2410_ble/sensor.py
@@ -11,7 +11,7 @@ from homeassistant.const import EntityCategory, UnitOfLength
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import LD2410BLE, LD2410BLECoordinator
@@ -122,7 +122,7 @@ SENSOR_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform for LD2410BLE."""
data: LD2410BLEData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/leaone/sensor.py b/homeassistant/components/leaone/sensor.py
index 62948868870..c815a0964e0 100644
--- a/homeassistant/components/leaone/sensor.py
+++ b/homeassistant/components/leaone/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfMass,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
@@ -107,7 +107,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Leaone BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/led_ble/light.py b/homeassistant/components/led_ble/light.py
index 3bca7269eba..2facda734d5 100644
--- a/homeassistant/components/led_ble/light.py
+++ b/homeassistant/components/led_ble/light.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, cast
from led_ble import LEDBLE
@@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -32,7 +32,7 @@ from .models import LEDBLEData
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the light platform for LEDBLE."""
data: LEDBLEData = hass.data[DOMAIN][entry.entry_id]
@@ -83,7 +83,7 @@ class LEDBLEEntity(CoordinatorEntity[DataUpdateCoordinator[None]], LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
- brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness)
+ brightness = cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness))
if effect := kwargs.get(ATTR_EFFECT):
await self._async_set_effect(effect, brightness)
return
diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json
index 309399e6958..6fa2c00da9f 100644
--- a/homeassistant/components/led_ble/manifest.json
+++ b/homeassistant/components/led_ble/manifest.json
@@ -35,5 +35,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling",
- "requirements": ["bluetooth-data-tools==1.23.4", "led-ble==1.1.6"]
+ "requirements": ["bluetooth-data-tools==1.27.0", "led-ble==1.1.7"]
}
diff --git a/homeassistant/components/lektrico/binary_sensor.py b/homeassistant/components/lektrico/binary_sensor.py
index d0a3e39690c..37e55ade798 100644
--- a/homeassistant/components/lektrico/binary_sensor.py
+++ b/homeassistant/components/lektrico/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator
from .entity import LektricoEntity
@@ -101,7 +101,7 @@ BINARY_SENSORS: tuple[LektricoBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LektricoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lektrico binary sensor entities based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/lektrico/button.py b/homeassistant/components/lektrico/button.py
index 62aef12ff53..e598773321d 100644
--- a/homeassistant/components/lektrico/button.py
+++ b/homeassistant/components/lektrico/button.py
@@ -13,7 +13,7 @@ from homeassistant.components.button import (
)
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator
from .entity import LektricoEntity
@@ -60,7 +60,7 @@ BUTTONS_FOR_LB_DEVICES: tuple[LektricoButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LektricoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lektrico charger based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/lektrico/number.py b/homeassistant/components/lektrico/number.py
index 8054ba8afe5..c54ee938607 100644
--- a/homeassistant/components/lektrico/number.py
+++ b/homeassistant/components/lektrico/number.py
@@ -15,7 +15,7 @@ from homeassistant.const import (
UnitOfElectricCurrent,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator
from .entity import LektricoEntity
@@ -58,7 +58,7 @@ NUMBERS: tuple[LektricoNumberEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LektricoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lektrico number entities based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/lektrico/select.py b/homeassistant/components/lektrico/select.py
index ef45d97d697..513a82365af 100644
--- a/homeassistant/components/lektrico/select.py
+++ b/homeassistant/components/lektrico/select.py
@@ -9,7 +9,7 @@ from lektricowifi import Device
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator
from .entity import LektricoEntity
@@ -46,7 +46,7 @@ SELECTS: tuple[LektricoSelectEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LektricoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lektrico select entities based on a config entry."""
diff --git a/homeassistant/components/lektrico/sensor.py b/homeassistant/components/lektrico/sensor.py
index d55d91c4cd4..927011459b0 100644
--- a/homeassistant/components/lektrico/sensor.py
+++ b/homeassistant/components/lektrico/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import IntegrationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator
@@ -283,7 +283,7 @@ SENSORS_FOR_LB_3_PHASE: tuple[LektricoSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LektricoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lektrico charger based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/lektrico/strings.json b/homeassistant/components/lektrico/strings.json
index e24700c9b09..eb223b4758b 100644
--- a/homeassistant/components/lektrico/strings.json
+++ b/homeassistant/components/lektrico/strings.json
@@ -24,7 +24,7 @@
"entity": {
"binary_sensor": {
"state_e_activated": {
- "name": "Ev error"
+ "name": "EV error"
},
"overtemp": {
"name": "Thermal throttling"
@@ -45,10 +45,10 @@
"name": "Overvoltage"
},
"rcd_error": {
- "name": "Rcd error"
+ "name": "RCD error"
},
"cp_diode_failure": {
- "name": "Ev diode short"
+ "name": "EV diode short"
},
"contactor_failure": {
"name": "Relay contacts welded"
@@ -64,7 +64,7 @@
},
"number": {
"led_max_brightness": {
- "name": "Led brightness"
+ "name": "LED brightness"
},
"dynamic_limit": {
"name": "Dynamic limit"
@@ -86,12 +86,12 @@
"name": "State",
"state": {
"available": "Available",
- "charging": "Charging",
- "connected": "Connected",
+ "charging": "[%key:common::state::charging%]",
+ "connected": "[%key:common::state::connected%]",
"error": "Error",
- "locked": "Locked",
+ "locked": "[%key:common::state::locked%]",
"need_auth": "Waiting for authentication",
- "paused": "Paused",
+ "paused": "[%key:common::state::paused%]",
"paused_by_scheduler": "Paused by scheduler",
"updating_firmware": "Updating firmware"
}
diff --git a/homeassistant/components/lektrico/switch.py b/homeassistant/components/lektrico/switch.py
index 0fdfbd2ad41..065e96f84b8 100644
--- a/homeassistant/components/lektrico/switch.py
+++ b/homeassistant/components/lektrico/switch.py
@@ -9,7 +9,7 @@ from lektricowifi import Device
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_TYPE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LektricoConfigEntry, LektricoDeviceDataUpdateCoordinator
from .entity import LektricoEntity
@@ -59,7 +59,7 @@ SWITCHS_FOR_3_PHASE_CHARGERS: tuple[LektricoSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LektricoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lektrico switch entities based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/letpot/__init__.py b/homeassistant/components/letpot/__init__.py
index bc84c22d4a2..50c73f949a3 100644
--- a/homeassistant/components/letpot/__init__.py
+++ b/homeassistant/components/letpot/__init__.py
@@ -22,7 +22,12 @@ from .const import (
)
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
-PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME]
+PLATFORMS: list[Platform] = [
+ Platform.BINARY_SENSOR,
+ Platform.SENSOR,
+ Platform.SWITCH,
+ Platform.TIME,
+]
async def async_setup_entry(hass: HomeAssistant, entry: LetPotConfigEntry) -> bool:
diff --git a/homeassistant/components/letpot/binary_sensor.py b/homeassistant/components/letpot/binary_sensor.py
new file mode 100644
index 00000000000..bfc7a5ab4a7
--- /dev/null
+++ b/homeassistant/components/letpot/binary_sensor.py
@@ -0,0 +1,122 @@
+"""Support for LetPot binary sensor entities."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from letpot.models import DeviceFeature, LetPotDeviceStatus
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
+from .entity import LetPotEntity, LetPotEntityDescription
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class LetPotBinarySensorEntityDescription(
+ LetPotEntityDescription, BinarySensorEntityDescription
+):
+ """Describes a LetPot binary sensor entity."""
+
+ is_on_fn: Callable[[LetPotDeviceStatus], bool]
+
+
+BINARY_SENSORS: tuple[LetPotBinarySensorEntityDescription, ...] = (
+ LetPotBinarySensorEntityDescription(
+ key="low_nutrients",
+ translation_key="low_nutrients",
+ is_on_fn=lambda status: bool(status.errors.low_nutrients),
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ supported_fn=(
+ lambda coordinator: coordinator.data.errors.low_nutrients is not None
+ ),
+ ),
+ LetPotBinarySensorEntityDescription(
+ key="low_water",
+ translation_key="low_water",
+ is_on_fn=lambda status: bool(status.errors.low_water),
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ supported_fn=lambda coordinator: coordinator.data.errors.low_water is not None,
+ ),
+ LetPotBinarySensorEntityDescription(
+ key="pump",
+ translation_key="pump",
+ is_on_fn=lambda status: status.pump_status == 1,
+ device_class=BinarySensorDeviceClass.RUNNING,
+ supported_fn=(
+ lambda coordinator: DeviceFeature.PUMP_STATUS
+ in coordinator.device_client.device_features
+ ),
+ ),
+ LetPotBinarySensorEntityDescription(
+ key="pump_error",
+ translation_key="pump_error",
+ is_on_fn=lambda status: bool(status.errors.pump_malfunction),
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ supported_fn=(
+ lambda coordinator: coordinator.data.errors.pump_malfunction is not None
+ ),
+ ),
+ LetPotBinarySensorEntityDescription(
+ key="refill_error",
+ translation_key="refill_error",
+ is_on_fn=lambda status: bool(status.errors.refill_error),
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ supported_fn=(
+ lambda coordinator: coordinator.data.errors.refill_error is not None
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: LetPotConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up LetPot binary sensor entities based on a config entry and device status/features."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ LetPotBinarySensorEntity(coordinator, description)
+ for description in BINARY_SENSORS
+ for coordinator in coordinators
+ if description.supported_fn(coordinator)
+ )
+
+
+class LetPotBinarySensorEntity(LetPotEntity, BinarySensorEntity):
+ """Defines a LetPot binary sensor entity."""
+
+ entity_description: LetPotBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: LetPotDeviceCoordinator,
+ description: LetPotBinarySensorEntityDescription,
+ ) -> None:
+ """Initialize LetPot binary sensor entity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
+
+ @property
+ def is_on(self) -> bool:
+ """Return if the binary sensor is on."""
+ return self.entity_description.is_on_fn(self.coordinator.data)
diff --git a/homeassistant/components/letpot/entity.py b/homeassistant/components/letpot/entity.py
index b4d505f4092..5e2c46fee84 100644
--- a/homeassistant/components/letpot/entity.py
+++ b/homeassistant/components/letpot/entity.py
@@ -1,18 +1,27 @@
"""Base class for LetPot entities."""
from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
from typing import Any, Concatenate
from letpot.exceptions import LetPotConnectionException, LetPotException
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LetPotDeviceCoordinator
+@dataclass(frozen=True, kw_only=True)
+class LetPotEntityDescription(EntityDescription):
+ """Description for all LetPot entities."""
+
+ supported_fn: Callable[[LetPotDeviceCoordinator], bool] = lambda _: True
+
+
class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]):
"""Defines a base LetPot entity."""
diff --git a/homeassistant/components/letpot/icons.json b/homeassistant/components/letpot/icons.json
index 2a2b727adcd..43541b57150 100644
--- a/homeassistant/components/letpot/icons.json
+++ b/homeassistant/components/letpot/icons.json
@@ -1,5 +1,30 @@
{
"entity": {
+ "binary_sensor": {
+ "low_nutrients": {
+ "default": "mdi:beaker-alert",
+ "state": {
+ "off": "mdi:beaker"
+ }
+ },
+ "low_water": {
+ "default": "mdi:water-percent-alert",
+ "state": {
+ "off": "mdi:water-percent"
+ }
+ },
+ "pump": {
+ "default": "mdi:pump",
+ "state": {
+ "off": "mdi:pump-off"
+ }
+ }
+ },
+ "sensor": {
+ "water_level": {
+ "default": "mdi:water-percent"
+ }
+ },
"switch": {
"alarm_sound": {
"default": "mdi:bell-ring",
diff --git a/homeassistant/components/letpot/quality_scale.yaml b/homeassistant/components/letpot/quality_scale.yaml
index 0eda413a461..9804a5ec3a4 100644
--- a/homeassistant/components/letpot/quality_scale.yaml
+++ b/homeassistant/components/letpot/quality_scale.yaml
@@ -44,7 +44,7 @@ rules:
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow: done
- test-coverage: todo
+ test-coverage: done
# Gold
devices: done
@@ -59,9 +59,9 @@ rules:
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
- entity-category: todo
- entity-device-class: todo
- entity-disabled-by-default: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
diff --git a/homeassistant/components/letpot/sensor.py b/homeassistant/components/letpot/sensor.py
new file mode 100644
index 00000000000..b0b113eb063
--- /dev/null
+++ b/homeassistant/components/letpot/sensor.py
@@ -0,0 +1,110 @@
+"""Support for LetPot sensor entities."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from letpot.models import DeviceFeature, LetPotDeviceStatus, TemperatureUnit
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import PERCENTAGE, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
+from .entity import LetPotEntity, LetPotEntityDescription
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+LETPOT_TEMPERATURE_UNIT_HA_UNIT = {
+ TemperatureUnit.CELSIUS: UnitOfTemperature.CELSIUS,
+ TemperatureUnit.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT,
+}
+
+
+@dataclass(frozen=True, kw_only=True)
+class LetPotSensorEntityDescription(LetPotEntityDescription, SensorEntityDescription):
+ """Describes a LetPot sensor entity."""
+
+ native_unit_of_measurement_fn: Callable[[LetPotDeviceStatus], str | None]
+ value_fn: Callable[[LetPotDeviceStatus], StateType]
+
+
+SENSORS: tuple[LetPotSensorEntityDescription, ...] = (
+ LetPotSensorEntityDescription(
+ key="temperature",
+ value_fn=lambda status: status.temperature_value,
+ native_unit_of_measurement_fn=(
+ lambda status: LETPOT_TEMPERATURE_UNIT_HA_UNIT[
+ status.temperature_unit or TemperatureUnit.CELSIUS
+ ]
+ ),
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ supported_fn=(
+ lambda coordinator: DeviceFeature.TEMPERATURE
+ in coordinator.device_client.device_features
+ ),
+ ),
+ LetPotSensorEntityDescription(
+ key="water_level",
+ translation_key="water_level",
+ value_fn=lambda status: status.water_level,
+ native_unit_of_measurement_fn=lambda _: PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ supported_fn=(
+ lambda coordinator: DeviceFeature.WATER_LEVEL
+ in coordinator.device_client.device_features
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: LetPotConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up LetPot sensor entities based on a device features."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ LetPotSensorEntity(coordinator, description)
+ for description in SENSORS
+ for coordinator in coordinators
+ if description.supported_fn(coordinator)
+ )
+
+
+class LetPotSensorEntity(LetPotEntity, SensorEntity):
+ """Defines a LetPot sensor entity."""
+
+ entity_description: LetPotSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: LetPotDeviceCoordinator,
+ description: LetPotSensorEntityDescription,
+ ) -> None:
+ """Initialize LetPot sensor entity."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
+
+ @property
+ def native_unit_of_measurement(self) -> str | None:
+ """Return the native unit of measurement."""
+ return self.entity_description.native_unit_of_measurement_fn(
+ self.coordinator.data
+ )
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/letpot/strings.json b/homeassistant/components/letpot/strings.json
index 12913085644..cdc5a36a15f 100644
--- a/homeassistant/components/letpot/strings.json
+++ b/homeassistant/components/letpot/strings.json
@@ -32,6 +32,28 @@
}
},
"entity": {
+ "binary_sensor": {
+ "low_nutrients": {
+ "name": "Low nutrients"
+ },
+ "low_water": {
+ "name": "Low water"
+ },
+ "pump": {
+ "name": "Pump"
+ },
+ "pump_error": {
+ "name": "Pump error"
+ },
+ "refill_error": {
+ "name": "Refill error"
+ }
+ },
+ "sensor": {
+ "water_level": {
+ "name": "Water level"
+ }
+ },
"switch": {
"alarm_sound": {
"name": "Alarm sound"
diff --git a/homeassistant/components/letpot/switch.py b/homeassistant/components/letpot/switch.py
index ab02f2860c6..0b00318c53b 100644
--- a/homeassistant/components/letpot/switch.py
+++ b/homeassistant/components/letpot/switch.py
@@ -10,10 +10,10 @@ from letpot.models import DeviceFeature, LetPotDeviceStatus
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
-from .entity import LetPotEntity, exception_handler
+from .entity import LetPotEntity, LetPotEntityDescription, exception_handler
# Each change pushes a 'full' device status with the change. The library will cache
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
@@ -21,14 +21,33 @@ PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
-class LetPotSwitchEntityDescription(SwitchEntityDescription):
+class LetPotSwitchEntityDescription(LetPotEntityDescription, SwitchEntityDescription):
"""Describes a LetPot switch entity."""
value_fn: Callable[[LetPotDeviceStatus], bool | None]
set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]]
-BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
+SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
+ LetPotSwitchEntityDescription(
+ key="alarm_sound",
+ translation_key="alarm_sound",
+ value_fn=lambda status: status.system_sound,
+ set_value_fn=lambda device_client, value: device_client.set_sound(value),
+ entity_category=EntityCategory.CONFIG,
+ supported_fn=lambda coordinator: coordinator.data.system_sound is not None,
+ ),
+ LetPotSwitchEntityDescription(
+ key="auto_mode",
+ translation_key="auto_mode",
+ value_fn=lambda status: status.water_mode == 1,
+ set_value_fn=lambda device_client, value: device_client.set_water_mode(value),
+ entity_category=EntityCategory.CONFIG,
+ supported_fn=(
+ lambda coordinator: DeviceFeature.PUMP_AUTO
+ in coordinator.device_client.device_features
+ ),
+ ),
LetPotSwitchEntityDescription(
key="power",
translation_key="power",
@@ -44,44 +63,21 @@ BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
entity_category=EntityCategory.CONFIG,
),
)
-ALARM_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription(
- key="alarm_sound",
- translation_key="alarm_sound",
- value_fn=lambda status: status.system_sound,
- set_value_fn=lambda device_client, value: device_client.set_sound(value),
- entity_category=EntityCategory.CONFIG,
-)
-AUTO_MODE_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription(
- key="auto_mode",
- translation_key="auto_mode",
- value_fn=lambda status: status.water_mode == 1,
- set_value_fn=lambda device_client, value: device_client.set_water_mode(value),
- entity_category=EntityCategory.CONFIG,
-)
async def async_setup_entry(
hass: HomeAssistant,
entry: LetPotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LetPot switch entities based on a config entry and device status/features."""
coordinators = entry.runtime_data
entities: list[SwitchEntity] = [
LetPotSwitchEntity(coordinator, description)
- for description in BASE_SWITCHES
+ for description in SWITCHES
for coordinator in coordinators
+ if description.supported_fn(coordinator)
]
- entities.extend(
- LetPotSwitchEntity(coordinator, ALARM_SWITCH)
- for coordinator in coordinators
- if coordinator.data.system_sound is not None
- )
- entities.extend(
- LetPotSwitchEntity(coordinator, AUTO_MODE_SWITCH)
- for coordinator in coordinators
- if DeviceFeature.PUMP_AUTO in coordinator.device_client.device_features
- )
async_add_entities(entities)
diff --git a/homeassistant/components/letpot/time.py b/homeassistant/components/letpot/time.py
index cca088c8e61..bae61df6a28 100644
--- a/homeassistant/components/letpot/time.py
+++ b/homeassistant/components/letpot/time.py
@@ -11,7 +11,7 @@ from letpot.models import LetPotDeviceStatus
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LetPotConfigEntry, LetPotDeviceCoordinator
from .entity import LetPotEntity, exception_handler
@@ -54,7 +54,7 @@ TIME_SENSORS: tuple[LetPotTimeEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: LetPotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LetPot time entities based on a config entry."""
coordinators = entry.runtime_data
diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py
index b3f8f8e0437..de652eeef08 100644
--- a/homeassistant/components/lg_netcast/media_player.py
+++ b/homeassistant/components/lg_netcast/media_player.py
@@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_MODEL, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.trigger import PluggableAction
from .const import ATTR_MANUFACTURER, DOMAIN
@@ -47,7 +47,7 @@ SUPPORT_LGTV = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a LG Netcast Media Player from a config_entry."""
diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py
index cebe1d33728..c3ea22ee08f 100644
--- a/homeassistant/components/lg_soundbar/media_player.py
+++ b/homeassistant/components/lg_soundbar/media_player.py
@@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -23,7 +23,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up media_player from a config entry created in the integrations UI."""
async_add_entities(
diff --git a/homeassistant/components/lg_thinq/__init__.py b/homeassistant/components/lg_thinq/__init__.py
index 72d81af4ff0..47282b6cc22 100644
--- a/homeassistant/components/lg_thinq/__init__.py
+++ b/homeassistant/components/lg_thinq/__init__.py
@@ -22,7 +22,7 @@ from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.event import async_track_time_interval
-from .const import CONF_CONNECT_CLIENT_ID, MQTT_SUBSCRIPTION_INTERVAL
+from .const import CONF_CONNECT_CLIENT_ID, DOMAIN, MQTT_SUBSCRIPTION_INTERVAL
from .coordinator import DeviceDataUpdateCoordinator, async_setup_device_coordinator
from .mqtt import ThinQMQTT
@@ -47,6 +47,7 @@ PLATFORMS = [
Platform.SENSOR,
Platform.SWITCH,
Platform.VACUUM,
+ Platform.WATER_HEATER,
]
_LOGGER = logging.getLogger(__name__)
@@ -136,7 +137,15 @@ async def async_setup_mqtt(
entry.runtime_data.mqtt_client = mqtt_client
# Try to connect.
- result = await mqtt_client.async_connect()
+ try:
+ result = await mqtt_client.async_connect()
+ except (AttributeError, ThinQAPIException, TypeError, ValueError) as exc:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="failed_to_connect_mqtt",
+ translation_placeholders={"error": str(exc)},
+ ) from exc
+
if not result:
_LOGGER.error("Failed to set up mqtt connection")
return
diff --git a/homeassistant/components/lg_thinq/binary_sensor.py b/homeassistant/components/lg_thinq/binary_sensor.py
index 845bf8c3079..61b600037a7 100644
--- a/homeassistant/components/lg_thinq/binary_sensor.py
+++ b/homeassistant/components/lg_thinq/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ThinqConfigEntry
from .entity import ThinQEntity
@@ -76,7 +76,8 @@ BINARY_SENSOR_DESC: dict[ThinQProperty, ThinQBinarySensorEntityDescription] = {
),
ThinQProperty.WATER_HEATER_OPERATION_MODE: ThinQBinarySensorEntityDescription(
key=ThinQProperty.WATER_HEATER_OPERATION_MODE,
- translation_key="operation_mode",
+ device_class=BinarySensorDeviceClass.POWER,
+ translation_key=ThinQProperty.WATER_HEATER_OPERATION_MODE,
on_key="power_on",
),
ThinQProperty.ONE_TOUCH_FILTER: ThinQBinarySensorEntityDescription(
@@ -136,7 +137,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for binary sensor platform."""
entities: list[ThinQBinarySensorEntity] = []
diff --git a/homeassistant/components/lg_thinq/climate.py b/homeassistant/components/lg_thinq/climate.py
index 5cf9ccbd442..98a86a8d355 100644
--- a/homeassistant/components/lg_thinq/climate.py
+++ b/homeassistant/components/lg_thinq/climate.py
@@ -2,7 +2,6 @@
from __future__ import annotations
-from dataclasses import dataclass
import logging
from typing import Any
@@ -10,8 +9,11 @@ from thinqconnect import DeviceType
from thinqconnect.integration import ExtendedProperty
from homeassistant.components.climate import (
+ ATTR_HVAC_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
+ SWING_OFF,
+ SWING_ON,
ClimateEntity,
ClimateEntityDescription,
ClimateEntityFeature,
@@ -19,38 +21,26 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.temperature import display_temp
from . import ThinqConfigEntry
from .coordinator import DeviceDataUpdateCoordinator
from .entity import ThinQEntity
-
-@dataclass(frozen=True, kw_only=True)
-class ThinQClimateEntityDescription(ClimateEntityDescription):
- """Describes ThinQ climate entity."""
-
- min_temp: float | None = None
- max_temp: float | None = None
- step: float | None = None
-
-
-DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ThinQClimateEntityDescription, ...]] = {
+DEVICE_TYPE_CLIMATE_MAP: dict[DeviceType, tuple[ClimateEntityDescription, ...]] = {
DeviceType.AIR_CONDITIONER: (
- ThinQClimateEntityDescription(
+ ClimateEntityDescription(
key=ExtendedProperty.CLIMATE_AIR_CONDITIONER,
name=None,
translation_key=ExtendedProperty.CLIMATE_AIR_CONDITIONER,
),
),
DeviceType.SYSTEM_BOILER: (
- ThinQClimateEntityDescription(
+ ClimateEntityDescription(
key=ExtendedProperty.CLIMATE_SYSTEM_BOILER,
name=None,
- min_temp=16,
- max_temp=30,
- step=1,
+ translation_key=ExtendedProperty.CLIMATE_SYSTEM_BOILER,
),
),
}
@@ -63,23 +53,24 @@ STR_TO_HVAC: dict[str, HVACMode] = {
"heat": HVACMode.HEAT,
}
-HVAC_TO_STR: dict[HVACMode, str] = {
- HVACMode.AUTO: "auto",
- HVACMode.COOL: "cool",
- HVACMode.DRY: "air_dry",
- HVACMode.FAN_ONLY: "fan",
- HVACMode.HEAT: "heat",
-}
+HVAC_TO_STR = {v: k for k, v in STR_TO_HVAC.items()}
THINQ_PRESET_MODE: list[str] = ["air_clean", "aroma", "energy_saving"]
+STR_TO_SWING = {
+ "true": SWING_ON,
+ "false": SWING_OFF,
+}
+
+SWING_TO_STR = {v: k for k, v in STR_TO_SWING.items()}
+
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for climate platform."""
entities: list[ThinQClimateEntity] = []
@@ -102,12 +93,10 @@ async def async_setup_entry(
class ThinQClimateEntity(ThinQEntity, ClimateEntity):
"""Represent a thinq climate platform."""
- entity_description: ThinQClimateEntityDescription
-
def __init__(
self,
coordinator: DeviceDataUpdateCoordinator,
- entity_description: ThinQClimateEntityDescription,
+ entity_description: ClimateEntityDescription,
property_id: str,
) -> None:
"""Initialize a climate entity."""
@@ -121,7 +110,9 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self._attr_hvac_modes = [HVACMode.OFF]
self._attr_hvac_mode = HVACMode.OFF
self._attr_preset_modes = []
- self._attr_temperature_unit = UnitOfTemperature.CELSIUS
+ self._attr_temperature_unit = (
+ self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
+ )
self._requested_hvac_mode: str | None = None
# Set up HVAC modes.
@@ -142,6 +133,14 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self._attr_supported_features |= (
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
+ # Supports swing mode.
+ if self.data.swing_modes:
+ self._attr_swing_modes = [SWING_ON, SWING_OFF]
+ self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
+
+ if self.data.swing_horizontal_modes:
+ self._attr_swing_horizontal_modes = [SWING_ON, SWING_OFF]
+ self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
def _update_status(self) -> None:
"""Update status itself."""
@@ -150,6 +149,13 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
# Update fan, hvac and preset mode.
if self.supported_features & ClimateEntityFeature.FAN_MODE:
self._attr_fan_mode = self.data.fan_mode
+ if self.supported_features & ClimateEntityFeature.SWING_MODE:
+ self._attr_swing_mode = STR_TO_SWING.get(self.data.swing_mode)
+ if self.supported_features & ClimateEntityFeature.SWING_HORIZONTAL_MODE:
+ self._attr_swing_horizontal_mode = STR_TO_SWING.get(
+ self.data.swing_horizontal_mode
+ )
+
if self.data.is_on:
hvac_mode = self._requested_hvac_mode or self.data.hvac_mode
if hvac_mode in STR_TO_HVAC:
@@ -166,24 +172,23 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self._attr_current_temperature = self.data.current_temp
# Update min, max and step.
- if (max_temp := self.entity_description.max_temp) is not None or (
- max_temp := self.data.max
- ) is not None:
- self._attr_max_temp = max_temp
- if (min_temp := self.entity_description.min_temp) is not None or (
- min_temp := self.data.min
- ) is not None:
- self._attr_min_temp = min_temp
- if (step := self.entity_description.step) is not None or (
- step := self.data.step
- ) is not None:
- self._attr_target_temperature_step = step
+ if self.data.max is not None:
+ self._attr_max_temp = self.data.max
+ if self.data.min is not None:
+ self._attr_min_temp = self.data.min
+
+ self._attr_target_temperature_step = self.data.step
# Update target temperatures.
self._attr_target_temperature = self.data.target_temp
self._attr_target_temperature_high = self.data.target_temp_high
self._attr_target_temperature_low = self.data.target_temp_low
+ # Update unit.
+ self._attr_temperature_unit = (
+ self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
+ )
+
_LOGGER.debug(
"[%s:%s] update status: c:%s, t:%s, l:%s, h:%s, hvac:%s, unit:%s, step:%s",
self.coordinator.device_name,
@@ -268,6 +273,34 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self.coordinator.api.async_set_fan_mode(self.property_id, fan_mode)
)
+ async def async_set_swing_mode(self, swing_mode: str) -> None:
+ """Set new swing mode."""
+ _LOGGER.debug(
+ "[%s:%s] async_set_swing_mode: %s",
+ self.coordinator.device_name,
+ self.property_id,
+ swing_mode,
+ )
+ await self.async_call_api(
+ self.coordinator.api.async_set_swing_mode(
+ self.property_id, SWING_TO_STR.get(swing_mode)
+ )
+ )
+
+ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set new swing horizontal mode."""
+ _LOGGER.debug(
+ "[%s:%s] async_set_swing_horizontal_mode: %s",
+ self.coordinator.device_name,
+ self.property_id,
+ swing_horizontal_mode,
+ )
+ await self.async_call_api(
+ self.coordinator.api.async_set_swing_horizontal_mode(
+ self.property_id, SWING_TO_STR.get(swing_horizontal_mode)
+ )
+ )
+
def _round_by_step(self, temperature: float) -> float:
"""Round the value by step."""
if (
@@ -290,6 +323,10 @@ class ThinQClimateEntity(ThinQEntity, ClimateEntity):
self.property_id,
kwargs,
)
+ if hvac_mode := kwargs.get(ATTR_HVAC_MODE):
+ await self.async_set_hvac_mode(HVACMode(hvac_mode))
+ if hvac_mode == HVACMode.OFF:
+ return
if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None:
if (
diff --git a/homeassistant/components/lg_thinq/const.py b/homeassistant/components/lg_thinq/const.py
index a65dee715db..20c6455241a 100644
--- a/homeassistant/components/lg_thinq/const.py
+++ b/homeassistant/components/lg_thinq/const.py
@@ -3,6 +3,8 @@
from datetime import timedelta
from typing import Final
+from homeassistant.const import UnitOfTemperature
+
# Config flow
DOMAIN = "lg_thinq"
COMPANY = "LGE"
@@ -18,3 +20,10 @@ MQTT_SUBSCRIPTION_INTERVAL: Final = timedelta(days=1)
# MQTT: Message types
DEVICE_PUSH_MESSAGE: Final = "DEVICE_PUSH"
DEVICE_STATUS_MESSAGE: Final = "DEVICE_STATUS"
+
+# Unit conversion map
+DEVICE_UNIT_TO_HA: dict[str, str] = {
+ "F": UnitOfTemperature.FAHRENHEIT,
+ "C": UnitOfTemperature.CELSIUS,
+}
+REVERSE_DEVICE_UNIT_TO_HA = {v: k for k, v in DEVICE_UNIT_TO_HA.items()}
diff --git a/homeassistant/components/lg_thinq/coordinator.py b/homeassistant/components/lg_thinq/coordinator.py
index d6991d15297..9f84c422277 100644
--- a/homeassistant/components/lg_thinq/coordinator.py
+++ b/homeassistant/components/lg_thinq/coordinator.py
@@ -2,19 +2,21 @@
from __future__ import annotations
+from collections.abc import Mapping
import logging
from typing import TYPE_CHECKING, Any
from thinqconnect import ThinQAPIException
from thinqconnect.integration import HABridge
-from homeassistant.core import HomeAssistant
+from homeassistant.const import EVENT_CORE_CONFIG_UPDATE
+from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
if TYPE_CHECKING:
from . import ThinqConfigEntry
-from .const import DOMAIN
+from .const import DOMAIN, REVERSE_DEVICE_UNIT_TO_HA
_LOGGER = logging.getLogger(__name__)
@@ -54,6 +56,42 @@ class DeviceDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
f"{self.device_id}_{self.sub_id}" if self.sub_id else self.device_id
)
+ # Set your preferred temperature unit. This will allow us to retrieve
+ # temperature values from the API in a converted value corresponding to
+ # preferred unit.
+ self._update_preferred_temperature_unit()
+
+ # Add a callback to handle core config update.
+ self.unit_system: str | None = None
+ self.config_entry.async_on_unload(
+ self.hass.bus.async_listen(
+ event_type=EVENT_CORE_CONFIG_UPDATE,
+ listener=self._handle_update_config,
+ event_filter=self.async_config_update_filter,
+ )
+ )
+
+ async def _handle_update_config(self, _: Event) -> None:
+ """Handle update core config."""
+ self._update_preferred_temperature_unit()
+
+ await self.async_refresh()
+
+ @callback
+ def async_config_update_filter(self, event_data: Mapping[str, Any]) -> bool:
+ """Filter out unwanted events."""
+ if (unit_system := event_data.get("unit_system")) != self.unit_system:
+ self.unit_system = unit_system
+ return True
+
+ return False
+
+ def _update_preferred_temperature_unit(self) -> None:
+ """Update preferred temperature unit."""
+ self.api.set_preferred_temperature_unit(
+ REVERSE_DEVICE_UNIT_TO_HA.get(self.hass.config.units.temperature_unit)
+ )
+
async def _async_update_data(self) -> dict[str, Any]:
"""Request to the server to update the status from full response data."""
try:
diff --git a/homeassistant/components/lg_thinq/entity.py b/homeassistant/components/lg_thinq/entity.py
index 7856506559b..61d8199f321 100644
--- a/homeassistant/components/lg_thinq/entity.py
+++ b/homeassistant/components/lg_thinq/entity.py
@@ -10,25 +10,19 @@ from thinqconnect import ThinQAPIException
from thinqconnect.devices.const import Location
from thinqconnect.integration import PropertyState
-from homeassistant.const import UnitOfTemperature
from homeassistant.core import callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import COMPANY, DOMAIN
+from .const import COMPANY, DEVICE_UNIT_TO_HA, DOMAIN
from .coordinator import DeviceDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
EMPTY_STATE = PropertyState()
-UNIT_CONVERSION_MAP: dict[str, str] = {
- "F": UnitOfTemperature.FAHRENHEIT,
- "C": UnitOfTemperature.CELSIUS,
-}
-
class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
"""The base implementation of all lg thinq entities."""
@@ -75,7 +69,7 @@ class ThinQEntity(CoordinatorEntity[DeviceDataUpdateCoordinator]):
if unit is None:
return None
- return UNIT_CONVERSION_MAP.get(unit)
+ return DEVICE_UNIT_TO_HA.get(unit)
def _update_status(self) -> None:
"""Update status itself.
diff --git a/homeassistant/components/lg_thinq/event.py b/homeassistant/components/lg_thinq/event.py
index b963cba37cc..f9baadf7a05 100644
--- a/homeassistant/components/lg_thinq/event.py
+++ b/homeassistant/components/lg_thinq/event.py
@@ -9,7 +9,7 @@ from thinqconnect.integration import ActiveMode, ThinQPropertyEx
from homeassistant.components.event import EventEntity, EventEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ThinqConfigEntry
from .coordinator import DeviceDataUpdateCoordinator
@@ -57,7 +57,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for event platform."""
entities: list[ThinQEventEntity] = []
diff --git a/homeassistant/components/lg_thinq/fan.py b/homeassistant/components/lg_thinq/fan.py
index edcadf2598a..6d07c98744a 100644
--- a/homeassistant/components/lg_thinq/fan.py
+++ b/homeassistant/components/lg_thinq/fan.py
@@ -14,7 +14,7 @@ from homeassistant.components.fan import (
FanEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
@@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for fan platform."""
entities: list[ThinQFanEntity] = []
diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json
index 42ae5746f24..3b0baaaaf75 100644
--- a/homeassistant/components/lg_thinq/icons.json
+++ b/homeassistant/components/lg_thinq/icons.json
@@ -80,6 +80,9 @@
},
"one_touch_filter": {
"default": "mdi:air-filter"
+ },
+ "water_heater_operation_mode": {
+ "default": "mdi:power"
}
},
"climate": {
@@ -166,6 +169,9 @@
"current_job_mode": {
"default": "mdi:format-list-bulleted"
},
+ "current_job_mode_dehumidifier": {
+ "default": "mdi:format-list-bulleted"
+ },
"operation_mode": {
"default": "mdi:gesture-tap-button"
},
@@ -407,6 +413,12 @@
},
"power_level_for_location": {
"default": "mdi:radiator"
+ },
+ "cycle_count": {
+ "default": "mdi:counter"
+ },
+ "cycle_count_for_location": {
+ "default": "mdi:counter"
}
}
}
diff --git a/homeassistant/components/lg_thinq/manifest.json b/homeassistant/components/lg_thinq/manifest.json
index b00d28c1d4f..cffc61cb1c4 100644
--- a/homeassistant/components/lg_thinq/manifest.json
+++ b/homeassistant/components/lg_thinq/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/lg_thinq",
"iot_class": "cloud_push",
"loggers": ["thinqconnect"],
- "requirements": ["thinqconnect==1.0.4"]
+ "requirements": ["thinqconnect==1.0.5"]
}
diff --git a/homeassistant/components/lg_thinq/mqtt.py b/homeassistant/components/lg_thinq/mqtt.py
index 025f80f78b1..d6ff1f72b8f 100644
--- a/homeassistant/components/lg_thinq/mqtt.py
+++ b/homeassistant/components/lg_thinq/mqtt.py
@@ -43,19 +43,16 @@ class ThinQMQTT:
async def async_connect(self) -> bool:
"""Create a mqtt client and then try to connect."""
- try:
- self.client = await ThinQMQTTClient(
- self.thinq_api, self.client_id, self.on_message_received
- )
- if self.client is None:
- return False
- # Connect to server and create certificate.
- return await self.client.async_prepare_mqtt()
- except (ThinQAPIException, TypeError, ValueError):
- _LOGGER.exception("Failed to connect")
+ self.client = await ThinQMQTTClient(
+ self.thinq_api, self.client_id, self.on_message_received
+ )
+ if self.client is None:
return False
+ # Connect to server and create certificate.
+ return await self.client.async_prepare_mqtt()
+
async def async_disconnect(self, event: Event | None = None) -> None:
"""Unregister client and disconnects handlers."""
await self.async_end_subscribes()
diff --git a/homeassistant/components/lg_thinq/number.py b/homeassistant/components/lg_thinq/number.py
index 634c1a8fe84..ac8991d6bb5 100644
--- a/homeassistant/components/lg_thinq/number.py
+++ b/homeassistant/components/lg_thinq/number.py
@@ -16,7 +16,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ThinqConfigEntry
from .entity import ThinQEntity
@@ -118,20 +118,14 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] =
DeviceType.WASHTOWER_DRYER: WASHER_NUMBERS,
DeviceType.WASHTOWER: WASHER_NUMBERS,
DeviceType.WASHTOWER_WASHER: WASHER_NUMBERS,
- DeviceType.WATER_HEATER: (
- NumberEntityDescription(
- key=ThinQProperty.TARGET_TEMPERATURE,
- native_max_value=60,
- native_min_value=35,
- native_step=1,
- native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- translation_key=ThinQProperty.TARGET_TEMPERATURE,
- ),
- ),
+ DeviceType.WATER_HEATER: (NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],),
DeviceType.WINE_CELLAR: (
NUMBER_DESC[ThinQProperty.LIGHT_STATUS],
NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],
),
+ DeviceType.VENTILATOR: (
+ TIMER_NUMBER_DESC[ThinQProperty.SLEEP_TIMER_RELATIVE_HOUR_TO_STOP],
+ ),
}
_LOGGER = logging.getLogger(__name__)
@@ -140,7 +134,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for number platform."""
entities: list[ThinQNumberEntity] = []
@@ -179,7 +173,7 @@ class ThinQNumberEntity(ThinQEntity, NumberEntity):
) is not None:
self._attr_native_unit_of_measurement = unit_of_measurement
- # Undate range.
+ # Update range.
if (
self.entity_description.native_min_value is None
and (min_value := self.data.min) is not None
diff --git a/homeassistant/components/lg_thinq/select.py b/homeassistant/components/lg_thinq/select.py
index e555d616ca3..3f29ee9e5c8 100644
--- a/homeassistant/components/lg_thinq/select.py
+++ b/homeassistant/components/lg_thinq/select.py
@@ -10,7 +10,7 @@ from thinqconnect.integration import ActiveMode
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ThinqConfigEntry
from .coordinator import DeviceDataUpdateCoordinator
@@ -98,7 +98,13 @@ DEVICE_TYPE_SELECT_MAP: dict[DeviceType, tuple[SelectEntityDescription, ...]] =
AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],
SELECT_DESC[ThinQProperty.CURRENT_JOB_MODE],
),
- DeviceType.DEHUMIDIFIER: (AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],),
+ DeviceType.DEHUMIDIFIER: (
+ AIR_FLOW_SELECT_DESC[ThinQProperty.WIND_STRENGTH],
+ SelectEntityDescription(
+ key=ThinQProperty.CURRENT_JOB_MODE,
+ translation_key="current_job_mode_dehumidifier",
+ ),
+ ),
DeviceType.DISH_WASHER: (
OPERATION_SELECT_DESC[ThinQProperty.DISH_WASHER_OPERATION_MODE],
),
@@ -142,7 +148,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for select platform."""
entities: list[ThinQSelectEntity] = []
diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py
index 7baaab52403..754b07cb2db 100644
--- a/homeassistant/components/lg_thinq/sensor.py
+++ b/homeassistant/components/lg_thinq/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import ThinqConfigEntry
@@ -248,6 +248,24 @@ TEMPERATURE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
state_class=SensorStateClass.MEASUREMENT,
translation_key=ThinQProperty.CURRENT_TEMPERATURE,
),
+ ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE: SensorEntityDescription(
+ key=ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key=ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE,
+ ),
+ ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE: SensorEntityDescription(
+ key=ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key=ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE,
+ ),
+ ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE: SensorEntityDescription(
+ key=ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ translation_key=ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE,
+ ),
}
WATER_FILTER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
ThinQProperty.USED_TIME: SensorEntityDescription(
@@ -341,6 +359,10 @@ TIMER_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = {
}
WASHER_SENSORS: tuple[SensorEntityDescription, ...] = (
+ SensorEntityDescription(
+ key=ThinQProperty.CYCLE_COUNT,
+ translation_key=ThinQProperty.CYCLE_COUNT,
+ ),
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
TIMER_SENSOR_DESC[TimerProperty.TOTAL],
TIMER_SENSOR_DESC[TimerProperty.RELATIVE_TO_START_WM],
@@ -470,6 +492,11 @@ DEVICE_TYPE_SENSOR_MAP: dict[DeviceType, tuple[SensorEntityDescription, ...]] =
RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE],
),
DeviceType.STYLER: WASHER_SENSORS,
+ DeviceType.SYSTEM_BOILER: (
+ TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_AIR_CURRENT_TEMPERATURE],
+ TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_IN_WATER_CURRENT_TEMPERATURE],
+ TEMPERATURE_SENSOR_DESC[ThinQPropertyEx.ROOM_OUT_WATER_CURRENT_TEMPERATURE],
+ ),
DeviceType.WASHCOMBO_MAIN: WASHER_SENSORS,
DeviceType.WASHCOMBO_MINI: WASHER_SENSORS,
DeviceType.WASHER: WASHER_SENSORS,
@@ -492,7 +519,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for sensor platform."""
entities: list[ThinQSensorEntity] = []
@@ -554,36 +581,44 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity):
local_now = datetime.now(
tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
)
- if value in [0, None, time.min]:
- # Reset to None
+ self._device_state = (
+ self.coordinator.data[self._device_state_id].value
+ if self._device_state_id in self.coordinator.data
+ else None
+ )
+ if value in [0, None, time.min] or (
+ self._device_state == "power_off"
+ and self.entity_description.key
+ in [TimerProperty.REMAIN, TimerProperty.TOTAL]
+ ):
+ # Reset to None when power_off
value = None
elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
if self.entity_description.key in TIME_SENSOR_DESC:
- # Set timestamp for time
+ # Set timestamp for absolute time
value = local_now.replace(hour=value.hour, minute=value.minute)
else:
# Set timestamp for delta
- new_state = (
- self.coordinator.data[self._device_state_id].value
- if self._device_state_id in self.coordinator.data
- else None
- )
- if (
- self.native_value is not None
- and self._device_state == new_state
- ):
- # Skip update when same state
- return
-
- self._device_state = new_state
- time_delta = timedelta(
+ event_data = timedelta(
hours=value.hour, minutes=value.minute, seconds=value.second
)
- value = (
- (local_now - time_delta)
+ new_time = (
+ (local_now - event_data)
if self.entity_description.key == TimerProperty.RUNNING
- else (local_now + time_delta)
+ else (local_now + event_data)
)
+ # The remain_time may change during the wash/dry operation depending on various reasons.
+ # If there is a diff of more than 60sec, the new timestamp is used
+ if (
+ parse_native_value := dt_util.parse_datetime(
+ str(self.native_value)
+ )
+ ) is None or abs(new_time - parse_native_value) > timedelta(
+ seconds=60
+ ):
+ value = new_time
+ else:
+ value = self.native_value
elif self.entity_description.device_class == SensorDeviceClass.DURATION:
# Set duration
value = self._get_duration(
diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json
index dee2d21e05a..a5fb81e3818 100644
--- a/homeassistant/components/lg_thinq/strings.json
+++ b/homeassistant/components/lg_thinq/strings.json
@@ -19,7 +19,7 @@
"description": "Please enter a ThinQ [PAT(Personal Access Token)]({pat_url}) created with your LG ThinQ account.",
"data": {
"access_token": "Personal Access Token",
- "country": "Country"
+ "country": "[%key:common::config_flow::data::country%]"
}
}
}
@@ -105,6 +105,12 @@
},
"one_touch_filter": {
"name": "Fresh air filter"
+ },
+ "water_heater_operation_mode": {
+ "name": "[%key:component::binary_sensor::entity_component::power::name%]",
+ "state": {
+ "off": "[%key:common::state::standby%]"
+ }
}
},
"climate": {
@@ -113,11 +119,11 @@
"fan_mode": {
"state": {
"slow": "Slow",
- "low": "Low",
- "mid": "Medium",
- "high": "High",
+ "low": "[%key:common::state::low%]",
+ "mid": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
"power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]",
- "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]"
+ "auto": "[%key:common::state::auto%]"
}
},
"preset_mode": {
@@ -264,10 +270,10 @@
"name": "{location} schedule turn-on"
},
"relative_hour_to_start_wm": {
- "name": "Delay starts in"
+ "name": "Delayed start"
},
"relative_hour_to_start_wm_for_location": {
- "name": "{location} delay starts in"
+ "name": "{location} delayed start"
},
"relative_hour_to_stop": {
"name": "Schedule turn-off"
@@ -276,10 +282,10 @@
"name": "{location} schedule turn-off"
},
"relative_hour_to_stop_wm": {
- "name": "Delay ends in"
+ "name": "Delayed end"
},
"relative_hour_to_stop_wm_for_location": {
- "name": "{location} delay ends in"
+ "name": "{location} delayed end"
},
"sleep_timer_relative_hour_to_stop": {
"name": "Sleep timer"
@@ -297,7 +303,7 @@
"state": {
"invalid": "Invalid",
"weak": "Weak",
- "normal": "Normal",
+ "normal": "[%key:common::state::normal%]",
"strong": "Strong",
"very_strong": "Very strong"
}
@@ -305,6 +311,15 @@
"current_temperature": {
"name": "Current temperature"
},
+ "room_air_current_temperature": {
+ "name": "Indoor temperature"
+ },
+ "room_in_water_current_temperature": {
+ "name": "Inlet temperature"
+ },
+ "room_out_water_current_temperature": {
+ "name": "Outlet temperature"
+ },
"temperature": {
"name": "Temperature"
},
@@ -328,7 +343,7 @@
"growth_mode": {
"name": "Mode",
"state": {
- "standard": "Auto",
+ "standard": "[%key:common::state::auto%]",
"ext_leaf": "Vegetables",
"ext_herb": "Herbs",
"ext_flower": "Flowers",
@@ -338,7 +353,7 @@
"growth_mode_for_location": {
"name": "{location} mode",
"state": {
- "standard": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]",
+ "standard": "[%key:common::state::auto%]",
"ext_leaf": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::ext_leaf%]",
"ext_herb": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::ext_herb%]",
"ext_flower": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::ext_flower%]",
@@ -375,17 +390,17 @@
"temperature_state": {
"name": "[%key:component::sensor::entity_component::temperature::name%]",
"state": {
- "high": "High",
+ "high": "[%key:common::state::high%]",
"normal": "Good",
- "low": "Low"
+ "low": "[%key:common::state::low%]"
}
},
"temperature_state_for_location": {
"name": "[%key:component::lg_thinq::entity::number::target_temperature_for_location::name%]",
"state": {
- "high": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::high%]",
+ "high": "[%key:common::state::high%]",
"normal": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::normal%]",
- "low": "[%key:component::lg_thinq::entity::sensor::temperature_state::state::low%]"
+ "low": "[%key:common::state::low%]"
}
},
"current_state": {
@@ -396,7 +411,7 @@
"cancel": "Cancel",
"carbonation": "Carbonation",
"change_condition": "Settings Change",
- "charging": "Charging",
+ "charging": "[%key:common::state::charging%]",
"charging_complete": "Charging completed",
"checking_turbidity": "Detecting soil level",
"cleaning": "Cleaning",
@@ -483,7 +498,7 @@
"cancel": "[%key:component::lg_thinq::entity::sensor::current_state::state::cancel%]",
"carbonation": "[%key:component::lg_thinq::entity::sensor::current_state::state::carbonation%]",
"change_condition": "[%key:component::lg_thinq::entity::sensor::current_state::state::change_condition%]",
- "charging": "[%key:component::lg_thinq::entity::sensor::current_state::state::charging%]",
+ "charging": "[%key:common::state::charging%]",
"charging_complete": "[%key:component::lg_thinq::entity::sensor::current_state::state::charging_complete%]",
"checking_turbidity": "[%key:component::lg_thinq::entity::sensor::current_state::state::checking_turbidity%]",
"cleaning": "[%key:component::lg_thinq::entity::sensor::current_state::state::cleaning%]",
@@ -566,7 +581,7 @@
"name": "[%key:component::lg_thinq::entity::binary_sensor::one_touch_filter::name%]",
"state": {
"off": "[%key:common::state::off%]",
- "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]",
+ "auto": "[%key:common::state::auto%]",
"power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]",
"replace": "Replace filter",
"smart_power": "Smart safe storage",
@@ -584,7 +599,7 @@
"name": "Operating mode",
"state": {
"air_clean": "Purify",
- "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]",
+ "auto": "[%key:common::state::auto%]",
"clothes_dry": "Laundry",
"edge": "Edge cleaning",
"heat_pump": "Heat pump",
@@ -592,7 +607,7 @@
"intensive_dry": "Spot",
"macro": "Custom mode",
"mop": "Mop",
- "normal": "Normal",
+ "normal": "[%key:common::state::normal%]",
"off": "[%key:common::state::off%]",
"quiet_humidity": "Silent",
"rapid_humidity": "Jet",
@@ -611,7 +626,7 @@
"auto": "Low power",
"high": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]",
"mop": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::mop%]",
- "normal": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::normal%]",
+ "normal": "[%key:common::state::normal%]",
"off": "[%key:common::state::off%]",
"turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]"
}
@@ -634,11 +649,11 @@
"current_dish_washing_course": {
"name": "Current cycle",
"state": {
- "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]",
+ "auto": "[%key:common::state::auto%]",
"heavy": "Intensive",
"delicate": "Delicate",
"turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]",
- "normal": "Normal",
+ "normal": "[%key:common::state::normal%]",
"rinse": "Rinse",
"refresh": "Refresh",
"express": "Express",
@@ -766,8 +781,8 @@
"name": "Battery",
"state": {
"high": "Full",
- "mid": "Medium",
- "low": "Low",
+ "mid": "[%key:common::state::medium%]",
+ "low": "[%key:common::state::low%]",
"warning": "Empty"
}
},
@@ -848,6 +863,12 @@
},
"power_level_for_location": {
"name": "{location} power level"
+ },
+ "cycle_count": {
+ "name": "Cycles"
+ },
+ "cycle_count_for_location": {
+ "name": "{location} cycles"
}
},
"select": {
@@ -855,12 +876,12 @@
"name": "Speed",
"state": {
"slow": "[%key:component::lg_thinq::entity::climate::climate_air_conditioner::state_attributes::fan_mode::state::slow%]",
- "low": "Low",
- "mid": "Medium",
- "high": "High",
+ "low": "[%key:common::state::low%]",
+ "mid": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
"power": "Turbo",
"turbo": "[%key:component::lg_thinq::entity::select::wind_strength::state::power%]",
- "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]",
+ "auto": "[%key:common::state::auto%]",
"wind_1": "Step 1",
"wind_2": "Step 2",
"wind_3": "Step 3",
@@ -884,7 +905,7 @@
"name": "Operating mode",
"state": {
"air_clean": "Purifying",
- "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]",
+ "auto": "[%key:common::state::auto%]",
"baby_care": "[%key:component::lg_thinq::entity::sensor::personalization_mode::state::baby%]",
"circulator": "Booster",
"clean": "Single",
@@ -907,11 +928,23 @@
"vacation": "Vacation"
}
},
+ "current_job_mode_dehumidifier": {
+ "name": "[%key:component::lg_thinq::entity::sensor::current_job_mode::name%]",
+ "state": {
+ "air_clean": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::air_clean%]",
+ "clothes_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::clothes_dry%]",
+ "intensive_dry": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::intensive_dry%]",
+ "quiet_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::quiet_humidity%]",
+ "rapid_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::rapid_humidity%]",
+ "smart_humidity": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::smart_humidity%]"
+ }
+ },
"operation_mode": {
"name": "Operation",
"state": {
"cancel": "[%key:component::lg_thinq::entity::sensor::current_state::state::cancel%]",
"power_off": "Power off",
+ "power_on": "Power on",
"preheating": "Preheating",
"start": "[%key:common::action::start%]",
"stop": "[%key:common::action::stop%]",
@@ -923,6 +956,7 @@
"state": {
"cancel": "[%key:component::lg_thinq::entity::sensor::current_state::state::cancel%]",
"power_off": "[%key:component::lg_thinq::entity::select::operation_mode::state::power_off%]",
+ "power_on": "[%key:component::lg_thinq::entity::select::operation_mode::state::power_on%]",
"preheating": "[%key:component::lg_thinq::entity::select::operation_mode::state::preheating%]",
"start": "[%key:common::action::start%]",
"stop": "[%key:common::action::stop%]",
@@ -982,7 +1016,7 @@
"name": "[%key:component::lg_thinq::entity::binary_sensor::one_touch_filter::name%]",
"state": {
"off": "[%key:common::state::off%]",
- "auto": "[%key:component::lg_thinq::entity::sensor::growth_mode::state::standard%]",
+ "auto": "[%key:common::state::auto%]",
"power": "[%key:component::lg_thinq::entity::sensor::current_job_mode::state::high%]",
"replace": "[%key:component::lg_thinq::entity::sensor::fresh_air_filter::state::replace%]",
"smart_power": "[%key:component::lg_thinq::entity::sensor::fresh_air_filter::state::smart_power%]",
@@ -1000,5 +1034,10 @@
}
}
}
+ },
+ "exceptions": {
+ "failed_to_connect_mqtt": {
+ "message": "Failed to connect MQTT: {error}"
+ }
}
}
diff --git a/homeassistant/components/lg_thinq/switch.py b/homeassistant/components/lg_thinq/switch.py
index 6d69ce9a314..06363140193 100644
--- a/homeassistant/components/lg_thinq/switch.py
+++ b/homeassistant/components/lg_thinq/switch.py
@@ -17,7 +17,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ThinqConfigEntry
from .entity import ThinQEntity
@@ -172,7 +172,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for switch platform."""
entities: list[ThinQSwitchEntity] = []
diff --git a/homeassistant/components/lg_thinq/vacuum.py b/homeassistant/components/lg_thinq/vacuum.py
index 6cbb731869c..6cf2a9086b1 100644
--- a/homeassistant/components/lg_thinq/vacuum.py
+++ b/homeassistant/components/lg_thinq/vacuum.py
@@ -15,7 +15,7 @@ from homeassistant.components.vacuum import (
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ThinqConfigEntry
from .entity import ThinQEntity
@@ -73,7 +73,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ThinqConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an entry for vacuum platform."""
entities: list[ThinQStateVacuumEntity] = []
diff --git a/homeassistant/components/lg_thinq/water_heater.py b/homeassistant/components/lg_thinq/water_heater.py
new file mode 100644
index 00000000000..5a5c8d024b6
--- /dev/null
+++ b/homeassistant/components/lg_thinq/water_heater.py
@@ -0,0 +1,201 @@
+"""Support for waterheater entities."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from thinqconnect import DeviceType
+from thinqconnect.integration import ExtendedProperty
+
+from homeassistant.components.water_heater import (
+ ATTR_OPERATION_MODE,
+ STATE_ECO,
+ STATE_HEAT_PUMP,
+ STATE_OFF,
+ STATE_PERFORMANCE,
+ WaterHeaterEntity,
+ WaterHeaterEntityDescription,
+ WaterHeaterEntityFeature,
+)
+from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import ThinqConfigEntry
+from .coordinator import DeviceDataUpdateCoordinator
+from .entity import ThinQEntity
+
+DEVICE_TYPE_WH_MAP: dict[DeviceType, WaterHeaterEntityDescription] = {
+ DeviceType.WATER_HEATER: WaterHeaterEntityDescription(
+ key=ExtendedProperty.WATER_HEATER,
+ name=None,
+ ),
+ DeviceType.SYSTEM_BOILER: WaterHeaterEntityDescription(
+ key=ExtendedProperty.WATER_BOILER,
+ name=None,
+ ),
+}
+
+# Mapping between device and HA operation modes
+DEVICE_OP_MODE_TO_HA = {
+ "auto": STATE_ECO,
+ "heat_pump": STATE_HEAT_PUMP,
+ "turbo": STATE_PERFORMANCE,
+ "vacation": STATE_OFF,
+}
+HA_STATE_TO_DEVICE_OP_MODE = {v: k for k, v in DEVICE_OP_MODE_TO_HA.items()}
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ThinqConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up an entry for water_heater platform."""
+ entities: list[ThinQWaterHeaterEntity] = []
+ for coordinator in entry.runtime_data.coordinators.values():
+ if (
+ description := DEVICE_TYPE_WH_MAP.get(coordinator.api.device.device_type)
+ ) is not None:
+ if coordinator.api.device.device_type == DeviceType.WATER_HEATER:
+ entities.append(
+ ThinQWaterHeaterEntity(
+ coordinator, description, ExtendedProperty.WATER_HEATER
+ )
+ )
+ elif coordinator.api.device.device_type == DeviceType.SYSTEM_BOILER:
+ entities.append(
+ ThinQWaterBoilerEntity(
+ coordinator, description, ExtendedProperty.WATER_BOILER
+ )
+ )
+ if entities:
+ async_add_entities(entities)
+
+
+class ThinQWaterHeaterEntity(ThinQEntity, WaterHeaterEntity):
+ """Represent a ThinQ water heater entity."""
+
+ def __init__(
+ self,
+ coordinator: DeviceDataUpdateCoordinator,
+ entity_description: WaterHeaterEntityDescription,
+ property_id: str,
+ ) -> None:
+ """Initialize a water_heater entity."""
+ super().__init__(coordinator, entity_description, property_id)
+ self._attr_supported_features = (
+ WaterHeaterEntityFeature.TARGET_TEMPERATURE
+ | WaterHeaterEntityFeature.OPERATION_MODE
+ )
+ self._attr_temperature_unit = (
+ self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
+ )
+ if modes := self.data.job_modes:
+ self._attr_operation_list = [
+ DEVICE_OP_MODE_TO_HA.get(mode, mode) for mode in modes
+ ]
+ else:
+ self._attr_operation_list = [STATE_HEAT_PUMP]
+
+ def _update_status(self) -> None:
+ """Update status itself."""
+ super()._update_status()
+ self._attr_current_temperature = self.data.current_temp
+ self._attr_target_temperature = self.data.target_temp
+
+ if self.data.max is not None:
+ self._attr_max_temp = self.data.max
+ if self.data.min is not None:
+ self._attr_min_temp = self.data.min
+ if self.data.step is not None:
+ self._attr_target_temperature_step = self.data.step
+
+ self._attr_temperature_unit = (
+ self._get_unit_of_measurement(self.data.unit) or UnitOfTemperature.CELSIUS
+ )
+ if self.data.is_on:
+ self._attr_current_operation = (
+ DEVICE_OP_MODE_TO_HA.get(job_mode, job_mode)
+ if (job_mode := self.data.job_mode) is not None
+ else STATE_HEAT_PUMP
+ )
+ else:
+ self._attr_current_operation = STATE_OFF
+
+ _LOGGER.debug(
+ "[%s:%s] update status: c:%s, t:%s, op_mode:%s, op_list:%s, is_on:%s",
+ self.coordinator.device_name,
+ self.property_id,
+ self.current_temperature,
+ self.target_temperature,
+ self.current_operation,
+ self.operation_list,
+ self.data.is_on,
+ )
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new target temperatures."""
+ _LOGGER.debug(
+ "[%s:%s] async_set_temperature: %s",
+ self.coordinator.device_name,
+ self.property_id,
+ kwargs,
+ )
+ if (operation_mode := kwargs.get(ATTR_OPERATION_MODE)) is not None:
+ await self.async_set_operation_mode(str(operation_mode))
+ if operation_mode == STATE_OFF:
+ return
+
+ if (
+ temperature := kwargs.get(ATTR_TEMPERATURE)
+ ) is not None and temperature != self.target_temperature:
+ await self.async_call_api(
+ self.coordinator.api.async_set_target_temperature(
+ self.property_id, temperature
+ )
+ )
+
+ async def async_set_operation_mode(self, operation_mode: str) -> None:
+ """Set new operation mode."""
+ mode = HA_STATE_TO_DEVICE_OP_MODE.get(operation_mode, operation_mode)
+ _LOGGER.debug(
+ "[%s:%s] async_set_operation_mode: %s",
+ self.coordinator.device_name,
+ self.property_id,
+ mode,
+ )
+ await self.async_call_api(
+ self.coordinator.api.async_set_job_mode(self.property_id, mode)
+ )
+
+
+class ThinQWaterBoilerEntity(ThinQWaterHeaterEntity):
+ """Represent a ThinQ water boiler entity."""
+
+ def __init__(
+ self,
+ coordinator: DeviceDataUpdateCoordinator,
+ entity_description: WaterHeaterEntityDescription,
+ property_id: str,
+ ) -> None:
+ """Initialize a water_heater entity."""
+ super().__init__(coordinator, entity_description, property_id)
+ self._attr_supported_features |= WaterHeaterEntityFeature.ON_OFF
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ _LOGGER.debug(
+ "[%s:%s] async_turn_on", self.coordinator.device_name, self.property_id
+ )
+ await self.async_call_api(self.coordinator.api.async_turn_on(self.property_id))
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ _LOGGER.debug(
+ "[%s:%s] async_turn_off", self.coordinator.device_name, self.property_id
+ )
+ await self.async_call_api(self.coordinator.api.async_turn_off(self.property_id))
diff --git a/homeassistant/components/lidarr/sensor.py b/homeassistant/components/lidarr/sensor.py
index 7334241d0ed..81b2c570eab 100644
--- a/homeassistant/components/lidarr/sensor.py
+++ b/homeassistant/components/lidarr/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfInformation
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import BYTE_SIZES
from .coordinator import LidarrConfigEntry, LidarrDataUpdateCoordinator, T
@@ -114,7 +114,7 @@ SENSOR_TYPES: dict[str, LidarrSensorEntityDescription[Any]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: LidarrConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lidarr sensors based on a config entry."""
entities: list[LidarrSensor[Any]] = []
diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py
index 5c2d62545d6..60c1ac753e6 100644
--- a/homeassistant/components/life360/__init__.py
+++ b/homeassistant/components/life360/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -26,11 +26,14 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Unload config entry."""
- if all(
- config_entry.state is ConfigEntryState.NOT_LOADED
- for config_entry in hass.config_entries.async_entries(DOMAIN)
- if config_entry.entry_id != entry.entry_id
- ):
- ir.async_delete_issue(hass, DOMAIN, DOMAIN)
+ """Unload a config entry."""
return True
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Remove a config entry."""
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
+ ir.async_delete_issue(hass, DOMAIN, DOMAIN)
+ # Remove any remaining disabled or ignored entries
+ for _entry in hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
diff --git a/homeassistant/components/lifx/binary_sensor.py b/homeassistant/components/lifx/binary_sensor.py
index 454561a6f4e..f5a974b4626 100644
--- a/homeassistant/components/lifx/binary_sensor.py
+++ b/homeassistant/components/lifx/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, HEV_CYCLE_STATE
from .coordinator import LIFXUpdateCoordinator
@@ -26,7 +26,9 @@ HEV_CYCLE_STATE_SENSOR = BinarySensorEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LIFX from a config entry."""
coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/lifx/button.py b/homeassistant/components/lifx/button.py
index 694c91b4c27..25ab61aebae 100644
--- a/homeassistant/components/lifx/button.py
+++ b/homeassistant/components/lifx/button.py
@@ -10,7 +10,7 @@ from homeassistant.components.button import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, IDENTIFY, RESTART
from .coordinator import LIFXUpdateCoordinator
@@ -32,7 +32,7 @@ IDENTIFY_BUTTON_DESCRIPTION = ButtonEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LIFX from a config entry."""
domain_data = hass.data[DOMAIN]
diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py
index 2a8031b3874..5641786eb61 100644
--- a/homeassistant/components/lifx/light.py
+++ b/homeassistant/components/lifx/light.py
@@ -22,7 +22,7 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import VolDictType
@@ -79,7 +79,7 @@ HSBK_KELVIN = 3
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LIFX from a config entry."""
domain_data = hass.data[DOMAIN]
diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json
index 8d460c25322..18b9457ebf4 100644
--- a/homeassistant/components/lifx/manifest.json
+++ b/homeassistant/components/lifx/manifest.json
@@ -51,7 +51,7 @@
"iot_class": "local_polling",
"loggers": ["aiolifx", "aiolifx_effects", "bitstring"],
"requirements": [
- "aiolifx==1.1.2",
+ "aiolifx==1.1.4",
"aiolifx-effects==0.3.2",
"aiolifx-themes==0.6.4"
]
diff --git a/homeassistant/components/lifx/select.py b/homeassistant/components/lifx/select.py
index de3a5b431a9..13b81e2a784 100644
--- a/homeassistant/components/lifx/select.py
+++ b/homeassistant/components/lifx/select.py
@@ -8,7 +8,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_THEME,
@@ -38,7 +38,9 @@ THEME_ENTITY = SelectEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LIFX from a config entry."""
coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/lifx/sensor.py b/homeassistant/components/lifx/sensor.py
index 68f354024e4..96feba633f4 100644
--- a/homeassistant/components/lifx/sensor.py
+++ b/homeassistant/components/lifx/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_RSSI, DOMAIN
from .coordinator import LIFXUpdateCoordinator
@@ -32,7 +32,9 @@ RSSI_SENSOR = SensorEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LIFX sensor from config entry."""
coordinator: LIFXUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json
index 39102d904d5..be0485c6dff 100644
--- a/homeassistant/components/lifx/strings.json
+++ b/homeassistant/components/lifx/strings.json
@@ -66,7 +66,7 @@
}
},
"set_state": {
- "name": "Set State",
+ "name": "Set state",
"description": "Sets a color/brightness and possibly turn the light on/off.",
"fields": {
"infrared": {
@@ -201,7 +201,7 @@
},
"effect_morph": {
"name": "Morph effect",
- "description": "Starts the firmware-based Morph effect on LIFX Tiles on Candle.",
+ "description": "Starts the firmware-based Morph effect on LIFX Tiles or Candle.",
"fields": {
"speed": {
"name": "Speed",
@@ -209,11 +209,11 @@
},
"palette": {
"name": "Palette",
- "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect. Overrides the theme attribute."
+ "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect. Overrides the 'Theme' attribute."
},
"theme": {
"name": "[%key:component::lifx::entity::select::theme::name%]",
- "description": "Predefined color theme to use for the effect. Overridden by the palette attribute."
+ "description": "Predefined color theme to use for the effect. Overridden by the 'Palette' attribute."
},
"power_on": {
"name": "Power on",
@@ -223,27 +223,27 @@
},
"effect_sky": {
"name": "Sky effect",
- "description": "Starts the firmware-based Sky effect on LIFX Ceiling.",
+ "description": "Starts a firmware-based effect on LIFX Ceiling lights that animates a sky scene across the device.",
"fields": {
"speed": {
"name": "Speed",
- "description": "How long the Sunrise and Sunset sky types will take to complete. For the Cloud sky type, it is the speed of the clouds across the device."
+ "description": "How long the Sunrise and Sunset sky types will take to complete. For the Clouds sky type, it is the speed of the clouds across the device."
},
"sky_type": {
"name": "Sky type",
"description": "The style of sky that will be animated by the effect."
},
"cloud_saturation_min": {
- "name": "Cloud saturation Minimum",
- "description": "Minimum cloud saturation."
+ "name": "Cloud saturation minimum",
+ "description": "The minimum cloud saturation for the Clouds sky type."
},
"cloud_saturation_max": {
- "name": "Cloud Saturation maximum",
- "description": "Maximum cloud saturation."
+ "name": "Cloud saturation maximum",
+ "description": "The maximum cloud saturation for the Clouds sky type."
},
"palette": {
"name": "Palette",
- "description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to use for this effect."
+ "description": "List of 1 to 6 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to use for this effect."
},
"power_on": {
"name": "Power on",
@@ -256,16 +256,16 @@
"description": "Stops a running effect."
},
"paint_theme": {
- "name": "Paint Theme",
- "description": "Paint either a provided theme or custom palette across one or more LIFX lights.",
+ "name": "Paint theme",
+ "description": "Paints either a provided theme or custom palette across one or more LIFX lights.",
"fields": {
"palette": {
"name": "Palette",
- "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and kelvin (1500-9000) values to paint across the target lights. Overrides the theme attribute."
+ "description": "List of at least 2 and at most 16 colors as hue (0-360), saturation (0-100), brightness (0-100) and Kelvin (1500-9000) values to paint across the target lights. Overrides the 'Theme' attribute."
},
"theme": {
"name": "[%key:component::lifx::entity::select::theme::name%]",
- "description": "Predefined color theme to paint. Overridden by the palette attribute."
+ "description": "Predefined color theme to paint. Overridden by the 'Palette' attribute."
},
"transition": {
"name": "Transition",
diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py
index 637ba45c7d9..7b548533058 100644
--- a/homeassistant/components/light/__init__.py
+++ b/homeassistant/components/light/__init__.py
@@ -465,7 +465,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
):
params.pop(_DEPRECATED_ATTR_COLOR_TEMP.value)
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
- brightness = params.get(ATTR_BRIGHTNESS, light.brightness)
+ brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
color_temp,
brightness,
diff --git a/homeassistant/components/light/icons.json b/homeassistant/components/light/icons.json
index df98def090e..6218c733f4c 100644
--- a/homeassistant/components/light/icons.json
+++ b/homeassistant/components/light/icons.json
@@ -1,7 +1,15 @@
{
"entity_component": {
"_": {
- "default": "mdi:lightbulb"
+ "default": "mdi:lightbulb",
+ "state_attributes": {
+ "effect": {
+ "default": "mdi:circle-medium",
+ "state": {
+ "off": "mdi:star-off"
+ }
+ }
+ }
}
},
"services": {
diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py
index 83f2ee58b5e..250e1f5b2c1 100644
--- a/homeassistant/components/light/intent.py
+++ b/homeassistant/components/light/intent.py
@@ -28,13 +28,21 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_TURN_ON,
optional_slots={
- ("color", ATTR_RGB_COLOR): color_util.color_name_to_rgb,
- ("temperature", ATTR_COLOR_TEMP_KELVIN): cv.positive_int,
- ("brightness", ATTR_BRIGHTNESS_PCT): vol.All(
- vol.Coerce(int), vol.Range(0, 100)
+ "color": intent.IntentSlotInfo(
+ service_data_name=ATTR_RGB_COLOR,
+ value_schema=color_util.color_name_to_rgb,
+ ),
+ "temperature": intent.IntentSlotInfo(
+ service_data_name=ATTR_COLOR_TEMP_KELVIN,
+ value_schema=cv.positive_int,
+ ),
+ "brightness": intent.IntentSlotInfo(
+ service_data_name=ATTR_BRIGHTNESS_PCT,
+ description="The brightness percentage of the light between 0 and 100, where 0 is off and 100 is fully lit",
+ value_schema=vol.All(vol.Coerce(int), vol.Range(0, 100)),
),
},
- description="Sets the brightness or color of a light",
+ description="Sets the brightness percentage or color of a light",
platforms={DOMAIN},
),
)
diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml
index 2a1fbd11afd..2cd5921d794 100644
--- a/homeassistant/components/light/services.yaml
+++ b/homeassistant/components/light/services.yaml
@@ -199,7 +199,7 @@ turn_on:
example: "[255, 100, 100]"
selector:
color_rgb:
- kelvin: &kelvin
+ color_temp_kelvin: &color_temp_kelvin
filter: *color_temp_support
selector:
color_temp:
@@ -293,11 +293,10 @@ turn_on:
- light.LightEntityFeature.FLASH
selector:
select:
+ translation_key: flash
options:
- - label: "Long"
- value: "long"
- - label: "Short"
- value: "short"
+ - long
+ - short
turn_off:
target:
@@ -317,7 +316,7 @@ toggle:
fields:
transition: *transition
rgb_color: *rgb_color
- kelvin: *kelvin
+ color_temp_kelvin: *color_temp_kelvin
brightness_pct: *brightness_pct
effect: *effect
advanced_fields:
diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json
index b874e48406e..7a53f2569e7 100644
--- a/homeassistant/components/light/strings.json
+++ b/homeassistant/components/light/strings.json
@@ -19,8 +19,8 @@
"field_flash_name": "Flash",
"field_hs_color_description": "Color in hue/sat format. A list of two integers. Hue is 0-360 and Sat is 0-100.",
"field_hs_color_name": "Hue/Sat color",
- "field_kelvin_description": "Color temperature in Kelvin.",
- "field_kelvin_name": "Color temperature",
+ "field_color_temp_kelvin_description": "Color temperature in Kelvin.",
+ "field_color_temp_kelvin_name": "Color temperature",
"field_profile_description": "Name of a light profile to use.",
"field_profile_name": "Profile",
"field_rgb_color_description": "The color in RGB format. A list of three integers between 0 and 255 representing the values of red, green, and blue.",
@@ -93,7 +93,10 @@
"name": "Color temperature (Kelvin)"
},
"effect": {
- "name": "Effect"
+ "name": "Effect",
+ "state": {
+ "off": "[%key:common::state::off%]"
+ }
},
"effect_list": {
"name": "Available effects"
@@ -280,12 +283,18 @@
"yellow": "Yellow",
"yellowgreen": "Yellow green"
}
+ },
+ "flash": {
+ "options": {
+ "short": "Short",
+ "long": "Long"
+ }
}
},
"services": {
"turn_on": {
"name": "[%key:common::action::turn_on%]",
- "description": "Turn on one or more lights and adjust properties of the light, even when they are turned on already.",
+ "description": "Turns on one or more lights and adjusts their properties, even when they are turned on already.",
"fields": {
"transition": {
"name": "[%key:component::light::common::field_transition_name%]",
@@ -319,9 +328,9 @@
"name": "[%key:component::light::common::field_color_temp_name%]",
"description": "[%key:component::light::common::field_color_temp_description%]"
},
- "kelvin": {
- "name": "[%key:component::light::common::field_kelvin_name%]",
- "description": "[%key:component::light::common::field_kelvin_description%]"
+ "color_temp_kelvin": {
+ "name": "[%key:component::light::common::field_color_temp_kelvin_name%]",
+ "description": "[%key:component::light::common::field_color_temp_kelvin_description%]"
},
"brightness": {
"name": "[%key:component::light::common::field_brightness_name%]",
@@ -364,7 +373,7 @@
},
"turn_off": {
"name": "[%key:common::action::turn_off%]",
- "description": "Turn off one or more lights.",
+ "description": "Turns off one or more lights.",
"fields": {
"transition": {
"name": "[%key:component::light::common::field_transition_name%]",
@@ -383,7 +392,7 @@
},
"toggle": {
"name": "[%key:common::action::toggle%]",
- "description": "Toggles one or more lights, from on to off, or, off to on, based on their current state.",
+ "description": "Toggles one or more lights, from on to off, or off to on, based on their current state.",
"fields": {
"transition": {
"name": "[%key:component::light::common::field_transition_name%]",
@@ -417,9 +426,9 @@
"name": "[%key:component::light::common::field_color_temp_name%]",
"description": "[%key:component::light::common::field_color_temp_description%]"
},
- "kelvin": {
- "name": "[%key:component::light::common::field_kelvin_name%]",
- "description": "[%key:component::light::common::field_kelvin_description%]"
+ "color_temp_kelvin": {
+ "name": "[%key:component::light::common::field_color_temp_kelvin_name%]",
+ "description": "[%key:component::light::common::field_color_temp_kelvin_description%]"
},
"brightness": {
"name": "[%key:component::light::common::field_brightness_name%]",
diff --git a/homeassistant/components/linak/__init__.py b/homeassistant/components/linak/__init__.py
new file mode 100644
index 00000000000..4e3c37807ba
--- /dev/null
+++ b/homeassistant/components/linak/__init__.py
@@ -0,0 +1 @@
+"""LINAK virtual integration."""
diff --git a/homeassistant/components/linak/manifest.json b/homeassistant/components/linak/manifest.json
new file mode 100644
index 00000000000..db1ddd67bda
--- /dev/null
+++ b/homeassistant/components/linak/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "linak",
+ "name": "LINAK",
+ "integration_type": "virtual",
+ "supported_by": "idasen_desk"
+}
diff --git a/homeassistant/components/linear_garage_door/__init__.py b/homeassistant/components/linear_garage_door/__init__.py
index 5e524fbb512..c2a6c6a7ed1 100644
--- a/homeassistant/components/linear_garage_door/__init__.py
+++ b/homeassistant/components/linear_garage_door/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -43,14 +43,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- if all(
- config_entry.state is ConfigEntryState.NOT_LOADED
- for config_entry in hass.config_entries.async_entries(DOMAIN)
- if config_entry.entry_id != entry.entry_id
- ):
- ir.async_delete_issue(hass, DOMAIN, DOMAIN)
-
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Remove a config entry."""
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
+ ir.async_delete_issue(hass, DOMAIN, DOMAIN)
+ # Remove any remaining disabled or ignored entries
+ for _entry in hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
diff --git a/homeassistant/components/linear_garage_door/cover.py b/homeassistant/components/linear_garage_door/cover.py
index 1f7ae7ce114..7b0510f00d1 100644
--- a/homeassistant/components/linear_garage_door/cover.py
+++ b/homeassistant/components/linear_garage_door/cover.py
@@ -10,7 +10,7 @@ from homeassistant.components.cover import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LinearUpdateCoordinator
@@ -24,7 +24,7 @@ SCAN_INTERVAL = timedelta(seconds=10)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Linear Garage Door cover."""
coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/linear_garage_door/light.py b/homeassistant/components/linear_garage_door/light.py
index 3679491712f..ac03894d446 100644
--- a/homeassistant/components/linear_garage_door/light.py
+++ b/homeassistant/components/linear_garage_door/light.py
@@ -7,7 +7,7 @@ from linear_garage_door import Linear
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LinearUpdateCoordinator
@@ -19,7 +19,7 @@ SUPPORTED_SUBDEVICES = ["Light"]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Linear Garage Door cover."""
coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/linkedgo/__init__.py b/homeassistant/components/linkedgo/__init__.py
new file mode 100644
index 00000000000..e26fefa6b96
--- /dev/null
+++ b/homeassistant/components/linkedgo/__init__.py
@@ -0,0 +1 @@
+"""LinkedGo virtual integration."""
diff --git a/homeassistant/components/linkedgo/manifest.json b/homeassistant/components/linkedgo/manifest.json
new file mode 100644
index 00000000000..03c650cac08
--- /dev/null
+++ b/homeassistant/components/linkedgo/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "linkedgo",
+ "name": "LinkedGo",
+ "integration_type": "virtual",
+ "supported_by": "shelly"
+}
diff --git a/homeassistant/components/linkplay/button.py b/homeassistant/components/linkplay/button.py
index 1c93ebcdc3e..8865cf00aa5 100644
--- a/homeassistant/components/linkplay/button.py
+++ b/homeassistant/components/linkplay/button.py
@@ -16,7 +16,7 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LinkPlayConfigEntry
from .entity import LinkPlayBaseEntity, exception_wrap
@@ -50,7 +50,7 @@ BUTTON_TYPES: tuple[LinkPlayButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LinkPlayConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the LinkPlay buttons from config entry."""
diff --git a/homeassistant/components/linkplay/entity.py b/homeassistant/components/linkplay/entity.py
index 74e067f5eb3..0bfb34af42c 100644
--- a/homeassistant/components/linkplay/entity.py
+++ b/homeassistant/components/linkplay/entity.py
@@ -4,13 +4,13 @@ from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from linkplay.bridge import LinkPlayBridge
+from linkplay.manufacturers import MANUFACTURER_GENERIC, get_info_from_project
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import Entity
from . import DOMAIN, LinkPlayRequestException
-from .utils import MANUFACTURER_GENERIC, get_info_from_project
def exception_wrap[_LinkPlayEntityT: LinkPlayBaseEntity, **_P, _R](
diff --git a/homeassistant/components/linkplay/manifest.json b/homeassistant/components/linkplay/manifest.json
index ec9a8759a30..b57a7b68881 100644
--- a/homeassistant/components/linkplay/manifest.json
+++ b/homeassistant/components/linkplay/manifest.json
@@ -7,6 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["linkplay"],
- "requirements": ["python-linkplay==0.1.3"],
+ "requirements": ["python-linkplay==0.2.3"],
"zeroconf": ["_linkplay._tcp.local."]
}
diff --git a/homeassistant/components/linkplay/media_player.py b/homeassistant/components/linkplay/media_player.py
index 456fbf23289..16b0d5f75f1 100644
--- a/homeassistant/components/linkplay/media_player.py
+++ b/homeassistant/components/linkplay/media_player.py
@@ -30,7 +30,7 @@ from homeassistant.helpers import (
entity_platform,
entity_registry as er,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from . import LinkPlayConfigEntry, LinkPlayData
@@ -86,16 +86,10 @@ REPEAT_MAP: dict[LoopMode, RepeatMode] = {
REPEAT_MAP_INV: dict[RepeatMode, LoopMode] = {v: k for k, v in REPEAT_MAP.items()}
-EQUALIZER_MAP: dict[EqualizerMode, str] = {
- EqualizerMode.NONE: "None",
- EqualizerMode.CLASSIC: "Classic",
- EqualizerMode.POP: "Pop",
- EqualizerMode.JAZZ: "Jazz",
- EqualizerMode.VOCAL: "Vocal",
+EQUALIZER_MAP_INV: dict[str, EqualizerMode] = {
+ mode.value: mode for mode in EqualizerMode
}
-EQUALIZER_MAP_INV: dict[str, EqualizerMode] = {v: k for k, v in EQUALIZER_MAP.items()}
-
DEFAULT_FEATURES: MediaPlayerEntityFeature = (
MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
@@ -125,11 +119,13 @@ SERVICE_PLAY_PRESET_SCHEMA = cv.make_entity_service_schema(
}
)
+RETRY_POLL_MAXIMUM = 3
+
async def async_setup_entry(
hass: HomeAssistant,
entry: LinkPlayConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a media player from a config entry."""
@@ -146,7 +142,6 @@ async def async_setup_entry(
class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity):
"""Representation of a LinkPlay media player."""
- _attr_sound_mode_list = list(EQUALIZER_MAP.values())
_attr_device_class = MediaPlayerDeviceClass.RECEIVER
_attr_media_content_type = MediaType.MUSIC
_attr_name = None
@@ -156,19 +151,26 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity):
super().__init__(bridge)
self._attr_unique_id = bridge.device.uuid
+ self._retry_count = 0
self._attr_source_list = [
SOURCE_MAP[playing_mode] for playing_mode in bridge.device.playmode_support
]
+ self._attr_sound_mode_list = [
+ mode.value for mode in bridge.player.available_equalizer_modes
+ ]
@exception_wrap
async def async_update(self) -> None:
"""Update the state of the media player."""
try:
await self._bridge.player.update_status()
+ self._retry_count = 0
self._update_properties()
except LinkPlayRequestException:
- self._attr_available = False
+ self._retry_count += 1
+ if self._retry_count >= RETRY_POLL_MAXIMUM:
+ self._attr_available = False
@exception_wrap
async def async_select_source(self, source: str) -> None:
@@ -342,7 +344,7 @@ class LinkPlayMediaPlayerEntity(LinkPlayBaseEntity, MediaPlayerEntity):
self._attr_is_volume_muted = self._bridge.player.muted
self._attr_repeat = REPEAT_MAP[self._bridge.player.loop_mode]
self._attr_shuffle = self._bridge.player.loop_mode == LoopMode.RANDOM_PLAYBACK
- self._attr_sound_mode = EQUALIZER_MAP[self._bridge.player.equalizer_mode]
+ self._attr_sound_mode = self._bridge.player.equalizer_mode.value
self._attr_supported_features = DEFAULT_FEATURES
if self._bridge.player.status == PlayingStatus.PLAYING:
diff --git a/homeassistant/components/linkplay/strings.json b/homeassistant/components/linkplay/strings.json
index 31b4649e131..5d68754879c 100644
--- a/homeassistant/components/linkplay/strings.json
+++ b/homeassistant/components/linkplay/strings.json
@@ -11,7 +11,7 @@
}
},
"discovery_confirm": {
- "description": "Do you want to setup {name}?"
+ "description": "Do you want to set up {name}?"
}
},
"abort": {
@@ -26,11 +26,11 @@
"services": {
"play_preset": {
"name": "Play preset",
- "description": "Play the preset number on the device.",
+ "description": "Plays a preset on the device.",
"fields": {
"preset_number": {
"name": "Preset number",
- "description": "The preset number on the device to play."
+ "description": "The number of the preset to play."
}
}
}
@@ -44,7 +44,7 @@
},
"exceptions": {
"invalid_grouping_entity": {
- "message": "Entity with id {entity_id} can't be added to the LinkPlay multiroom. Is the entity a LinkPlay mediaplayer?"
+ "message": "Entity with ID {entity_id} can't be added to the LinkPlay multiroom. Is the entity a LinkPlay media player?"
}
}
}
diff --git a/homeassistant/components/linkplay/utils.py b/homeassistant/components/linkplay/utils.py
index 00bb691362b..63d04a3afc4 100644
--- a/homeassistant/components/linkplay/utils.py
+++ b/homeassistant/components/linkplay/utils.py
@@ -1,7 +1,5 @@
"""Utilities for the LinkPlay component."""
-from typing import Final
-
from aiohttp import ClientSession
from linkplay.utils import async_create_unverified_client_session
@@ -10,72 +8,6 @@ from homeassistant.core import Event, HomeAssistant, callback
from .const import DATA_SESSION, DOMAIN
-MANUFACTURER_ARTSOUND: Final[str] = "ArtSound"
-MANUFACTURER_ARYLIC: Final[str] = "Arylic"
-MANUFACTURER_IEAST: Final[str] = "iEAST"
-MANUFACTURER_WIIM: Final[str] = "WiiM"
-MANUFACTURER_GGMM: Final[str] = "GGMM"
-MANUFACTURER_MEDION: Final[str] = "Medion"
-MANUFACTURER_GENERIC: Final[str] = "Generic"
-MODELS_ARTSOUND_SMART_ZONE4: Final[str] = "Smart Zone 4 AMP"
-MODELS_ARTSOUND_SMART_HYDE: Final[str] = "Smart Hyde"
-MODELS_ARYLIC_S50: Final[str] = "S50+"
-MODELS_ARYLIC_S50_PRO: Final[str] = "S50 Pro"
-MODELS_ARYLIC_A30: Final[str] = "A30"
-MODELS_ARYLIC_A50: Final[str] = "A50"
-MODELS_ARYLIC_A50S: Final[str] = "A50+"
-MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0"
-MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3"
-MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4"
-MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1"
-MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3"
-MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp"
-MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5"
-MODELS_WIIM_AMP: Final[str] = "WiiM Amp"
-MODELS_WIIM_MINI: Final[str] = "WiiM Mini"
-MODELS_GGMM_GGMM_E2: Final[str] = "GGMM E2"
-MODELS_MEDION_MD_43970: Final[str] = "Life P66970 (MD 43970)"
-MODELS_GENERIC: Final[str] = "Generic"
-
-PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = {
- "SMART_ZONE4_AMP": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_ZONE4),
- "SMART_HYDE": (MANUFACTURER_ARTSOUND, MODELS_ARTSOUND_SMART_HYDE),
- "ARYLIC_S50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50),
- "RP0016_S50PRO_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S50_PRO),
- "RP0011_WB60_S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A30),
- "X-50": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50),
- "ARYLIC_A50S": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_A50S),
- "RP0011_WB60": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP),
- "UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3),
- "UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4),
- "UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3),
- "ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP),
- "UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "ARYLIC_S50A": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "RP0010_D5_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "RP0001": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "RP0013_WA31S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "RP0010_D5": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "RP0013_WA31S_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "RP0014_A50D_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "ARYLIC_A50TE": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "ARYLIC_A50N": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
- "iEAST-02": (MANUFACTURER_IEAST, MODELS_IEAST_AUDIOCAST_M5),
- "WiiM_Amp_4layer": (MANUFACTURER_WIIM, MODELS_WIIM_AMP),
- "Muzo_Mini": (MANUFACTURER_WIIM, MODELS_WIIM_MINI),
- "GGMM_E2A": (MANUFACTURER_GGMM, MODELS_GGMM_GGMM_E2),
- "A16": (MANUFACTURER_MEDION, MODELS_MEDION_MD_43970),
-}
-
-
-def get_info_from_project(project: str) -> tuple[str, str]:
- """Get manufacturer and model info based on given project."""
- return PROJECTID_LOOKUP.get(project, (MANUFACTURER_GENERIC, MODELS_GENERIC))
-
async def async_get_client_session(hass: HomeAssistant) -> ClientSession:
"""Get a ClientSession that can be used with LinkPlay devices."""
diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py
index f2b9af9adb4..95870927072 100644
--- a/homeassistant/components/litejet/light.py
+++ b/homeassistant/components/litejet/light.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_DEFAULT_TRANSITION, DOMAIN
@@ -27,7 +27,7 @@ ATTR_NUMBER = "number"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py
index 712e223aa3e..dd96b5accb6 100644
--- a/homeassistant/components/litejet/scene.py
+++ b/homeassistant/components/litejet/scene.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -22,7 +22,7 @@ ATTR_NUMBER = "number"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py
index 28f751f3ec1..1b46ba360c3 100644
--- a/homeassistant/components/litejet/switch.py
+++ b/homeassistant/components/litejet/switch.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -19,7 +19,7 @@ ATTR_NUMBER = "number"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py
index 700985d285f..ca9af22f1e9 100644
--- a/homeassistant/components/litterrobot/binary_sensor.py
+++ b/homeassistant/components/litterrobot/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
@@ -63,7 +63,7 @@ BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, .
async def async_setup_entry(
hass: HomeAssistant,
entry: LitterRobotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Litter-Robot binary sensors using config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/litterrobot/button.py b/homeassistant/components/litterrobot/button.py
index 758548b3a67..da6ac53ccec 100644
--- a/homeassistant/components/litterrobot/button.py
+++ b/homeassistant/components/litterrobot/button.py
@@ -11,7 +11,7 @@ from pylitterbot import FeederRobot, LitterRobot3, LitterRobot4, Robot
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
@@ -48,7 +48,7 @@ ROBOT_BUTTON_MAP: dict[type[Robot], RobotButtonEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: LitterRobotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/litterrobot/select.py b/homeassistant/components/litterrobot/select.py
index f6e3781f3df..be3a9915940 100644
--- a/homeassistant/components/litterrobot/select.py
+++ b/homeassistant/components/litterrobot/select.py
@@ -12,7 +12,7 @@ from pylitterbot.robot.litterrobot4 import BrightnessLevel
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator
from .entity import LitterRobotEntity, _WhiskerEntityT
@@ -68,7 +68,7 @@ ROBOT_SELECT_MAP: dict[type[Robot], RobotSelectEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: LitterRobotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Litter-Robot selects using config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/litterrobot/sensor.py b/homeassistant/components/litterrobot/sensor.py
index 3e25a0556c6..a638f24cf2a 100644
--- a/homeassistant/components/litterrobot/sensor.py
+++ b/homeassistant/components/litterrobot/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfMass
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
@@ -160,7 +160,7 @@ PET_SENSORS: list[RobotSensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
entry: LitterRobotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Litter-Robot sensors using config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json
index 19b007de068..55dbc0ea645 100644
--- a/homeassistant/components/litterrobot/strings.json
+++ b/homeassistant/components/litterrobot/strings.json
@@ -77,31 +77,31 @@
"status_code": {
"name": "Status code",
"state": {
- "br": "Bonnet Removed",
- "ccc": "Clean Cycle Complete",
- "ccp": "Clean Cycle In Progress",
- "cd": "Cat Detected",
- "csf": "Cat Sensor Fault",
- "csi": "Cat Sensor Interrupted",
- "cst": "Cat Sensor Timing",
- "df1": "Drawer Almost Full - 2 Cycles Left",
- "df2": "Drawer Almost Full - 1 Cycle Left",
- "dfs": "Drawer Full",
- "dhf": "Dump + Home Position Fault",
- "dpf": "Dump Position Fault",
- "ec": "Empty Cycle",
- "hpf": "Home Position Fault",
+ "br": "Bonnet removed",
+ "ccc": "Clean cycle complete",
+ "ccp": "Clean cycle in progress",
+ "cd": "Cat detected",
+ "csf": "Cat sensor fault",
+ "csi": "Cat sensor interrupted",
+ "cst": "Cat sensor timing",
+ "df1": "Drawer almost full - 2 cycles left",
+ "df2": "Drawer almost full - 1 cycle left",
+ "dfs": "Drawer full",
+ "dhf": "Dump + home position fault",
+ "dpf": "Dump position fault",
+ "ec": "Empty cycle",
+ "hpf": "Home position fault",
"off": "[%key:common::state::off%]",
"offline": "Offline",
- "otf": "Over Torque Fault",
+ "otf": "Over torque fault",
"p": "[%key:common::state::paused%]",
- "pd": "Pinch Detect",
- "pwrd": "Powering Down",
- "pwru": "Powering Up",
+ "pd": "Pinch detect",
+ "pwrd": "Powering down",
+ "pwru": "Powering up",
"rdy": "Ready",
- "scf": "Cat Sensor Fault At Startup",
- "sdf": "Drawer Full At Startup",
- "spf": "Pinch Detect At Startup"
+ "scf": "Cat sensor fault at startup",
+ "sdf": "Drawer full at startup",
+ "spf": "Pinch detect at startup"
}
},
"waste_drawer": {
@@ -118,9 +118,9 @@
"brightness_level": {
"name": "Panel brightness",
"state": {
- "low": "Low",
- "medium": "Medium",
- "high": "High"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
}
},
diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py
index 4839748c068..5924f8f094a 100644
--- a/homeassistant/components/litterrobot/switch.py
+++ b/homeassistant/components/litterrobot/switch.py
@@ -11,7 +11,7 @@ from pylitterbot import FeederRobot, LitterRobot
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
@@ -45,7 +45,7 @@ ROBOT_SWITCHES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: LitterRobotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Litter-Robot switches using config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/litterrobot/time.py b/homeassistant/components/litterrobot/time.py
index 69d81d63eae..3573418613b 100644
--- a/homeassistant/components/litterrobot/time.py
+++ b/homeassistant/components/litterrobot/time.py
@@ -12,7 +12,7 @@ from pylitterbot import LitterRobot3
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
@@ -49,7 +49,7 @@ LITTER_ROBOT_3_SLEEP_START = RobotTimeEntityDescription[LitterRobot3](
async def async_setup_entry(
hass: HomeAssistant,
entry: LitterRobotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py
index 53ab23e9db8..4d9dfe5074d 100644
--- a/homeassistant/components/litterrobot/update.py
+++ b/homeassistant/components/litterrobot/update.py
@@ -15,7 +15,7 @@ from homeassistant.components.update import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity
@@ -31,7 +31,7 @@ FIRMWARE_UPDATE_ENTITY = UpdateEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: LitterRobotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Litter-Robot update platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py
index 314fab6a621..9989c306b51 100644
--- a/homeassistant/components/litterrobot/vacuum.py
+++ b/homeassistant/components/litterrobot/vacuum.py
@@ -17,7 +17,7 @@ from homeassistant.components.vacuum import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
@@ -46,7 +46,7 @@ LITTER_BOX_ENTITY = StateVacuumEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: LitterRobotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Litter-Robot cleaner using config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/livisi/binary_sensor.py b/homeassistant/components/livisi/binary_sensor.py
index d4edd59f2d7..50eb4cd28b9 100644
--- a/homeassistant/components/livisi/binary_sensor.py
+++ b/homeassistant/components/livisi/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, WDS_DEVICE_TYPE
from .coordinator import LivisiDataUpdateCoordinator
@@ -21,7 +21,7 @@ from .entity import LivisiEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary_sensor device."""
coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/livisi/climate.py b/homeassistant/components/livisi/climate.py
index 3ecdcb486c0..1f5e3360c7d 100644
--- a/homeassistant/components/livisi/climate.py
+++ b/homeassistant/components/livisi/climate.py
@@ -16,7 +16,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
@@ -33,7 +33,7 @@ from .entity import LivisiEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up climate device."""
coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/livisi/manifest.json b/homeassistant/components/livisi/manifest.json
index 1077cacf2c4..46ffad162f3 100644
--- a/homeassistant/components/livisi/manifest.json
+++ b/homeassistant/components/livisi/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/livisi",
"iot_class": "local_polling",
- "requirements": ["livisi==0.0.24"]
+ "requirements": ["livisi==0.0.25"]
}
diff --git a/homeassistant/components/livisi/switch.py b/homeassistant/components/livisi/switch.py
index fa604c5fc87..5599a4af0d4 100644
--- a/homeassistant/components/livisi/switch.py
+++ b/homeassistant/components/livisi/switch.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LIVISI_STATE_CHANGE, LOGGER, SWITCH_DEVICE_TYPES
from .coordinator import LivisiDataUpdateCoordinator
@@ -19,7 +19,7 @@ from .entity import LivisiEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch device."""
coordinator: LivisiDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/local_calendar/calendar.py b/homeassistant/components/local_calendar/calendar.py
index eb7b0c20d91..df6f994a46c 100644
--- a/homeassistant/components/local_calendar/calendar.py
+++ b/homeassistant/components/local_calendar/calendar.py
@@ -26,7 +26,7 @@ from homeassistant.components.calendar import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import CONF_CALENDAR_NAME, DOMAIN
@@ -40,7 +40,7 @@ PRODID = "-//homeassistant.io//local_calendar 1.0//EN"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the local calendar platform."""
store = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/local_calendar/config_flow.py b/homeassistant/components/local_calendar/config_flow.py
index fef45f786f9..f5b3220fb8c 100644
--- a/homeassistant/components/local_calendar/config_flow.py
+++ b/homeassistant/components/local_calendar/config_flow.py
@@ -97,8 +97,7 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
user_input[CONF_ICS_FILE],
self.data[CONF_STORAGE_KEY],
)
- except HomeAssistantError as err:
- _LOGGER.debug("Error saving uploaded file: %s", err)
+ except InvalidIcsFile:
errors[CONF_ICS_FILE] = "invalid_ics_file"
else:
return self.async_create_entry(
@@ -112,6 +111,10 @@ class LocalCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
)
+class InvalidIcsFile(HomeAssistantError):
+ """Error to indicate that the uploaded file is not a valid ICS file."""
+
+
def save_uploaded_ics_file(
hass: HomeAssistant, uploaded_file_id: str, storage_key: str
):
@@ -122,6 +125,10 @@ def save_uploaded_ics_file(
try:
CalendarStream.from_ics(ics)
except CalendarParseError as err:
- raise HomeAssistantError("Failed to upload file: Invalid ICS file") from err
+ _LOGGER.error("Error reading the calendar information: %s", err.message)
+ _LOGGER.debug(
+ "Additional calendar error detail: %s", str(err.detailed_error)
+ )
+ raise InvalidIcsFile("Failed to upload file: Invalid ICS file") from err
dest_path = Path(hass.config.path(STORAGE_PATH.format(key=storage_key)))
shutil.move(file, dest_path)
diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json
index 21a4134a8b6..90cd5a6d2ac 100644
--- a/homeassistant/components/local_calendar/manifest.json
+++ b/homeassistant/components/local_calendar/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
- "requirements": ["ical==8.3.0"]
+ "requirements": ["ical==9.1.0"]
}
diff --git a/homeassistant/components/local_calendar/strings.json b/homeassistant/components/local_calendar/strings.json
index 2b61fc9ab3e..6d68b46b5b0 100644
--- a/homeassistant/components/local_calendar/strings.json
+++ b/homeassistant/components/local_calendar/strings.json
@@ -17,7 +17,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"error": {
- "invalid_ics_file": "Invalid .ics file"
+ "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details."
}
},
"selector": {
diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py
index db421bbce1d..8be0389678d 100644
--- a/homeassistant/components/local_file/camera.py
+++ b/homeassistant/components/local_file/camera.py
@@ -20,7 +20,10 @@ from homeassistant.helpers import (
entity_platform,
issue_registry as ir,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify
@@ -40,7 +43,7 @@ PLATFORM_SCHEMA = CAMERA_PLATFORM_SCHEMA.extend(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Camera for local file from a config entry."""
diff --git a/homeassistant/components/local_ip/sensor.py b/homeassistant/components/local_ip/sensor.py
index 7f855220563..a4cb9f2d60e 100644
--- a/homeassistant/components/local_ip/sensor.py
+++ b/homeassistant/components/local_ip/sensor.py
@@ -5,7 +5,7 @@ from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SENSOR
@@ -13,7 +13,7 @@ from .const import SENSOR
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform from config_entry."""
name = entry.data.get(CONF_NAME) or "Local IP"
diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json
index 68154f10885..a630c18c669 100644
--- a/homeassistant/components/local_todo/manifest.json
+++ b/homeassistant/components/local_todo/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
- "requirements": ["ical==8.3.0"]
+ "requirements": ["ical==9.1.0"]
}
diff --git a/homeassistant/components/local_todo/strings.json b/homeassistant/components/local_todo/strings.json
index 2403fae60a5..ebf7810494c 100644
--- a/homeassistant/components/local_todo/strings.json
+++ b/homeassistant/components/local_todo/strings.json
@@ -6,7 +6,8 @@
"description": "Please choose a name for your new To-do list",
"data": {
"todo_list_name": "To-do list name"
- }
+ },
+ "submit": "Create"
}
},
"abort": {
diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py
index c496fd6b6ba..30df24ea854 100644
--- a/homeassistant/components/local_todo/todo.py
+++ b/homeassistant/components/local_todo/todo.py
@@ -17,7 +17,7 @@ from homeassistant.components.todo import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.setup import SetupPhases, async_pause_setup
from homeassistant.util import dt as dt_util
@@ -65,7 +65,7 @@ def _migrate_calendar(calendar: Calendar) -> bool:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LocalTodoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the local_todo todo platform."""
diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py
index 47a498331eb..f7ae9039729 100644
--- a/homeassistant/components/locative/device_tracker.py
+++ b/homeassistant/components/locative/device_tracker.py
@@ -4,13 +4,15 @@ from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as LT_DOMAIN, TRACKER_UPDATE
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Configure a dispatcher connection based on a config entry."""
diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json
index fd8636acf97..fd2854b7932 100644
--- a/homeassistant/components/lock/strings.json
+++ b/homeassistant/components/lock/strings.json
@@ -28,7 +28,7 @@
"locked": "[%key:common::state::locked%]",
"locking": "Locking",
"open": "[%key:common::state::open%]",
- "opening": "Opening",
+ "opening": "[%key:common::state::opening%]",
"unlocked": "[%key:common::state::unlocked%]",
"unlocking": "Unlocking"
},
diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py
index 40b904c1279..f27a470a23d 100644
--- a/homeassistant/components/logbook/models.py
+++ b/homeassistant/components/logbook/models.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast
+from typing import TYPE_CHECKING, Any, Final, NamedTuple, cast, final
from propcache.api import cached_property
from sqlalchemy.engine.row import Row
@@ -114,6 +114,7 @@ DATA_POS: Final = 11
CONTEXT_POS: Final = 12
+@final # Final to allow direct checking of the type instead of using isinstance
class EventAsRow(NamedTuple):
"""Convert an event to a row.
diff --git a/homeassistant/components/logbook/strings.json b/homeassistant/components/logbook/strings.json
index 27ad49b0e3a..5a38b57a9b7 100644
--- a/homeassistant/components/logbook/strings.json
+++ b/homeassistant/components/logbook/strings.json
@@ -7,7 +7,7 @@
"fields": {
"name": {
"name": "[%key:common::config_flow::data::name%]",
- "description": "Custom name for an entity, can be referenced using an `entity_id`."
+ "description": "Custom name for an entity, can be referenced using the 'Entity ID' field."
},
"message": {
"name": "Message",
diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py
index e3d0d8a29fa..4b767f66d69 100644
--- a/homeassistant/components/logbook/websocket_api.py
+++ b/homeassistant/components/logbook/websocket_api.py
@@ -47,7 +47,7 @@ class LogbookLiveStream:
subscriptions: list[CALLBACK_TYPE]
end_time_unsub: CALLBACK_TYPE | None = None
task: asyncio.Task | None = None
- wait_sync_task: asyncio.Task | None = None
+ wait_sync_future: asyncio.Future[None] | None = None
@callback
@@ -329,8 +329,8 @@ async def ws_event_stream(
subscriptions.clear()
if live_stream.task:
live_stream.task.cancel()
- if live_stream.wait_sync_task:
- live_stream.wait_sync_task.cancel()
+ if live_stream.wait_sync_future:
+ live_stream.wait_sync_future.cancel()
if live_stream.end_time_unsub:
live_stream.end_time_unsub()
live_stream.end_time_unsub = None
@@ -399,10 +399,12 @@ async def ws_event_stream(
)
)
- live_stream.wait_sync_task = create_eager_task(
- get_instance(hass).async_block_till_done()
- )
- await live_stream.wait_sync_task
+ if sync_future := get_instance(hass).async_get_commit_future():
+ # Set the future so we can cancel it if the client
+ # unsubscribes before the commit is done so we don't
+ # query the database needlessly
+ live_stream.wait_sync_future = sync_future
+ await live_stream.wait_sync_future
#
# Fetch any events from the database that have
diff --git a/homeassistant/components/logger/helpers.py b/homeassistant/components/logger/helpers.py
index 034266428a3..00cea7e8aa5 100644
--- a/homeassistant/components/logger/helpers.py
+++ b/homeassistant/components/logger/helpers.py
@@ -203,7 +203,7 @@ class LoggerSettings:
else:
loggers = {domain}
- combined_logs = {logger: LOGSEVERITY[settings.level] for logger in loggers}
+ combined_logs = dict.fromkeys(loggers, LOGSEVERITY[settings.level])
# Don't override the log levels with the ones from YAML
# since we want whatever the user is asking for to be honored.
diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py
index 81133433d05..a4d34fcb2d6 100644
--- a/homeassistant/components/london_air/sensor.py
+++ b/homeassistant/components/london_air/sensor.py
@@ -37,10 +37,12 @@ AUTHORITIES = [
"Enfield",
"Greenwich",
"Hackney",
+ "Hammersmith and Fulham",
"Haringey",
"Harrow",
"Havering",
"Hillingdon",
+ "Hounslow",
"Islington",
"Kensington and Chelsea",
"Kingston",
diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py
index 2fbabc12747..247282309e4 100644
--- a/homeassistant/components/lookin/__init__.py
+++ b/homeassistant/components/lookin/__init__.py
@@ -19,7 +19,7 @@ from aiolookin import (
)
from aiolookin.models import UDPCommandType, UDPEvent
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
@@ -192,12 +192,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER]
await manager.async_stop()
return unload_ok
diff --git a/homeassistant/components/lookin/climate.py b/homeassistant/components/lookin/climate.py
index 051a18c9a32..9cef56bcf9f 100644
--- a/homeassistant/components/lookin/climate.py
+++ b/homeassistant/components/lookin/climate.py
@@ -28,7 +28,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, TYPE_TO_PLATFORM
from .coordinator import LookinDataUpdateCoordinator
@@ -65,7 +65,7 @@ LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the climate platform for lookin from a config entry."""
lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/lookin/light.py b/homeassistant/components/lookin/light.py
index 804d0ebef01..d46cb96d6c0 100644
--- a/homeassistant/components/lookin/light.py
+++ b/homeassistant/components/lookin/light.py
@@ -9,7 +9,7 @@ from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, TYPE_TO_PLATFORM
from .entity import LookinPowerPushRemoteEntity
@@ -21,7 +21,7 @@ LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the light platform for lookin from a config entry."""
lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/lookin/media_player.py b/homeassistant/components/lookin/media_player.py
index b3dda9c9e0c..a3568d9f155 100644
--- a/homeassistant/components/lookin/media_player.py
+++ b/homeassistant/components/lookin/media_player.py
@@ -15,7 +15,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, TYPE_TO_PLATFORM
from .coordinator import LookinDataUpdateCoordinator
@@ -44,7 +44,7 @@ _FUNCTION_NAME_TO_FEATURE = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the media_player platform for lookin from a config entry."""
lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/lookin/sensor.py b/homeassistant/components/lookin/sensor.py
index cae4f7782a8..89e1ed6aa69 100644
--- a/homeassistant/components/lookin/sensor.py
+++ b/homeassistant/components/lookin/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import LookinDeviceCoordinatorEntity
@@ -43,7 +43,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lookin sensors from the config entry."""
lookin_data: LookinData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/loqed/lock.py b/homeassistant/components/loqed/lock.py
index be6b39176d6..2064537df52 100644
--- a/homeassistant/components/loqed/lock.py
+++ b/homeassistant/components/loqed/lock.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.lock import LockEntity, LockEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LoqedDataCoordinator
from .const import DOMAIN
@@ -20,7 +20,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Loqed lock platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/loqed/sensor.py b/homeassistant/components/loqed/sensor.py
index 1d4595db8e9..c28b55b4f98 100644
--- a/homeassistant/components/loqed/sensor.py
+++ b/homeassistant/components/loqed/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.const import (
EntityCategory,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LoqedDataCoordinator, StatusMessage
@@ -42,7 +42,9 @@ SENSORS: Final[tuple[SensorEntityDescription, ...]] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Loqed lock platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py
index 4d8472da9a2..c0262f42f6c 100644
--- a/homeassistant/components/lovelace/__init__.py
+++ b/homeassistant/components/lovelace/__init__.py
@@ -6,7 +6,7 @@ from typing import Any
import voluptuous as vol
-from homeassistant.components import frontend, websocket_api
+from homeassistant.components import frontend, onboarding, websocket_api
from homeassistant.config import (
async_hass_config_yaml,
async_process_component_and_handle_errors,
@@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import collection, config_validation as cv
from homeassistant.helpers.frame import report_usage
from homeassistant.helpers.service import async_register_admin_service
+from homeassistant.helpers.translation import async_get_translations
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_integration
from homeassistant.util import slugify
@@ -282,6 +283,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
STORAGE_DASHBOARD_UPDATE_FIELDS,
).async_setup(hass)
+ def create_map_dashboard() -> None:
+ """Create a map dashboard."""
+ hass.async_create_task(_create_map_dashboard(hass, dashboards_collection))
+
+ if not onboarding.async_is_onboarded(hass):
+ onboarding.async_add_listener(hass, create_map_dashboard)
+
return True
@@ -323,3 +331,25 @@ def _register_panel(
kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON)
frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs)
+
+
+async def _create_map_dashboard(
+ hass: HomeAssistant, dashboards_collection: dashboard.DashboardsCollection
+) -> None:
+ """Create a map dashboard."""
+ translations = await async_get_translations(
+ hass, hass.config.language, "dashboard", {onboarding.DOMAIN}
+ )
+ title = translations["component.onboarding.dashboard.map.title"]
+
+ await dashboards_collection.async_create_item(
+ {
+ CONF_ALLOW_SINGLE_WORD: True,
+ CONF_ICON: "mdi:map",
+ CONF_TITLE: title,
+ CONF_URL_PATH: "map",
+ }
+ )
+
+ map_store = hass.data[LOVELACE_DATA].dashboards["map"]
+ await map_store.async_save({"strategy": {"type": "map"}})
diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py
index 8b9def63fda..2189386a4bb 100644
--- a/homeassistant/components/luftdaten/sensor.py
+++ b/homeassistant/components/luftdaten/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -72,7 +72,9 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Sensor.Community sensor based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py
index 4b3d12ad743..03feabae0dc 100644
--- a/homeassistant/components/lupusec/alarm_control_panel.py
+++ b/homeassistant/components/lupusec/alarm_control_panel.py
@@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .entity import LupusecDevice
@@ -25,7 +25,7 @@ SCAN_INTERVAL = timedelta(seconds=2)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an alarm control panel for a Lupusec device."""
data = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py
index b2413e2b462..bcd21adc1aa 100644
--- a/homeassistant/components/lupusec/binary_sensor.py
+++ b/homeassistant/components/lupusec/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .entity import LupusecBaseSensor
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a binary sensors for a Lupusec device."""
diff --git a/homeassistant/components/lupusec/entity.py b/homeassistant/components/lupusec/entity.py
index dc0dac89dc8..8cfb559b84f 100644
--- a/homeassistant/components/lupusec/entity.py
+++ b/homeassistant/components/lupusec/entity.py
@@ -18,7 +18,7 @@ class LupusecDevice(Entity):
self._device = device
self._attr_unique_id = device.device_id
- def update(self):
+ def update(self) -> None:
"""Update automation state."""
self._device.refresh()
diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py
index 23f3c927880..a70df90f8e7 100644
--- a/homeassistant/components/lupusec/switch.py
+++ b/homeassistant/components/lupusec/switch.py
@@ -11,7 +11,7 @@ import lupupy.constants as CONST
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .entity import LupusecBaseSensor
@@ -22,7 +22,7 @@ SCAN_INTERVAL = timedelta(seconds=2)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lupusec switch devices."""
diff --git a/homeassistant/components/lutron/binary_sensor.py b/homeassistant/components/lutron/binary_sensor.py
index c33b545413d..5bed760e1ac 100644
--- a/homeassistant/components/lutron/binary_sensor.py
+++ b/homeassistant/components/lutron/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, LutronData
from .entity import LutronDevice
@@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron binary_sensor platform.
diff --git a/homeassistant/components/lutron/config_flow.py b/homeassistant/components/lutron/config_flow.py
index 6a48e0d4b67..3f55a2b131b 100644
--- a/homeassistant/components/lutron/config_flow.py
+++ b/homeassistant/components/lutron/config_flow.py
@@ -9,10 +9,21 @@ from urllib.error import HTTPError
from pylutron import Lutron
import voluptuous as vol
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import callback
+from homeassistant.helpers.selector import (
+ NumberSelector,
+ NumberSelectorConfig,
+ NumberSelectorMode,
+)
-from .const import DOMAIN
+from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -68,3 +79,36 @@ class LutronConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: ConfigEntry,
+ ) -> OptionsFlowHandler:
+ """Get the options flow for this handler."""
+ return OptionsFlowHandler()
+
+
+class OptionsFlowHandler(OptionsFlow):
+ """Handle option flow for lutron."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle options flow."""
+ if user_input is not None:
+ return self.async_create_entry(title="", data=user_input)
+
+ data_schema = vol.Schema(
+ {
+ vol.Required(
+ CONF_DEFAULT_DIMMER_LEVEL,
+ default=self.config_entry.options.get(
+ CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL
+ ),
+ ): NumberSelector(
+ NumberSelectorConfig(min=1, max=255, mode=NumberSelectorMode.SLIDER)
+ )
+ }
+ )
+ return self.async_show_form(step_id="init", data_schema=data_schema)
diff --git a/homeassistant/components/lutron/const.py b/homeassistant/components/lutron/const.py
index 3862f7eb1d8..b69e35f38ba 100644
--- a/homeassistant/components/lutron/const.py
+++ b/homeassistant/components/lutron/const.py
@@ -1,3 +1,7 @@
"""Lutron constants."""
DOMAIN = "lutron"
+
+CONF_DEFAULT_DIMMER_LEVEL = "default_dimmer_level"
+
+DEFAULT_DIMMER_LEVEL = 255 / 2
diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py
index 2f80798aee4..e8f3ad09879 100644
--- a/homeassistant/components/lutron/cover.py
+++ b/homeassistant/components/lutron/cover.py
@@ -15,7 +15,7 @@ from homeassistant.components.cover import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, LutronData
from .entity import LutronDevice
@@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron cover platform.
diff --git a/homeassistant/components/lutron/event.py b/homeassistant/components/lutron/event.py
index 7b1b9e65137..942e165b97f 100644
--- a/homeassistant/components/lutron/event.py
+++ b/homeassistant/components/lutron/event.py
@@ -8,7 +8,7 @@ from homeassistant.components.event import EventEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ID
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from . import ATTR_ACTION, ATTR_FULL_ID, ATTR_UUID, DOMAIN, LutronData
@@ -33,7 +33,7 @@ LEGACY_EVENT_TYPES: dict[LutronEventType, str] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron event platform."""
entry_data: LutronData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/lutron/fan.py b/homeassistant/components/lutron/fan.py
index 7db8b12c8d0..5928c3c2da3 100644
--- a/homeassistant/components/lutron/fan.py
+++ b/homeassistant/components/lutron/fan.py
@@ -10,7 +10,7 @@ from pylutron import Output
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, LutronData
from .entity import LutronDevice
@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron fan platform.
diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py
index 7e8829b231c..a7489e13b7b 100644
--- a/homeassistant/components/lutron/light.py
+++ b/homeassistant/components/lutron/light.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Mapping
from typing import Any
-from pylutron import Output
+from pylutron import Lutron, LutronEntity, Output
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -17,16 +17,17 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, LutronData
+from .const import CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL
from .entity import LutronDevice
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron light platform.
@@ -37,7 +38,7 @@ async def async_setup_entry(
async_add_entities(
(
- LutronLight(area_name, device, entry_data.client)
+ LutronLight(area_name, device, entry_data.client, config_entry)
for area_name, device in entry_data.lights
),
True,
@@ -64,6 +65,17 @@ class LutronLight(LutronDevice, LightEntity):
_prev_brightness: int | None = None
_attr_name = None
+ def __init__(
+ self,
+ area_name: str,
+ lutron_device: LutronEntity,
+ controller: Lutron,
+ config_entry: ConfigEntry,
+ ) -> None:
+ """Initialize the device."""
+ super().__init__(area_name, lutron_device, controller)
+ self._config_entry = config_entry
+
def turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
if flash := kwargs.get(ATTR_FLASH):
@@ -72,7 +84,9 @@ class LutronLight(LutronDevice, LightEntity):
if ATTR_BRIGHTNESS in kwargs and self._lutron_device.is_dimmable:
brightness = kwargs[ATTR_BRIGHTNESS]
elif self._prev_brightness == 0:
- brightness = 255 / 2
+ brightness = self._config_entry.options.get(
+ CONF_DEFAULT_DIMMER_LEVEL, DEFAULT_DIMMER_LEVEL
+ )
else:
brightness = self._prev_brightness
self._prev_brightness = brightness
diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json
index 82bdfad4774..8d3da47795a 100644
--- a/homeassistant/components/lutron/manifest.json
+++ b/homeassistant/components/lutron/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/lutron",
"iot_class": "local_polling",
"loggers": ["pylutron"],
- "requirements": ["pylutron==0.2.16"],
+ "requirements": ["pylutron==0.2.18"],
"single_config_entry": true
}
diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py
index 9e8070713a9..4889f9056ac 100644
--- a/homeassistant/components/lutron/scene.py
+++ b/homeassistant/components/lutron/scene.py
@@ -9,7 +9,7 @@ from pylutron import Button, Keypad, Lutron
from homeassistant.components.scene import Scene
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, LutronData
from .entity import LutronKeypad
@@ -18,7 +18,7 @@ from .entity import LutronKeypad
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron scene platform.
diff --git a/homeassistant/components/lutron/strings.json b/homeassistant/components/lutron/strings.json
index b73e0bd15ed..37db509e294 100644
--- a/homeassistant/components/lutron/strings.json
+++ b/homeassistant/components/lutron/strings.json
@@ -19,6 +19,15 @@
}
}
},
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "default_dimmer_level": "Default light level when first turning on a light from Home Assistant"
+ }
+ }
+ }
+ },
"entity": {
"event": {
"button": {
diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py
index c8b93dd7398..e1e97d1774a 100644
--- a/homeassistant/components/lutron/switch.py
+++ b/homeassistant/components/lutron/switch.py
@@ -10,7 +10,7 @@ from pylutron import Button, Keypad, Led, Lutron, Output
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, LutronData
from .entity import LutronDevice, LutronKeypad
@@ -19,7 +19,7 @@ from .entity import LutronDevice, LutronKeypad
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron switch platform.
diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py
index d697d6244b5..b489fe9dba7 100644
--- a/homeassistant/components/lutron_caseta/__init__.py
+++ b/homeassistant/components/lutron_caseta/__init__.py
@@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
-import contextlib
from itertools import chain
import logging
import ssl
@@ -37,11 +36,12 @@ from .const import (
ATTR_SERIAL,
ATTR_TYPE,
BRIDGE_DEVICE_ID,
- BRIDGE_TIMEOUT,
CONF_CA_CERTS,
CONF_CERTFILE,
CONF_KEYFILE,
CONF_SUBTYPE,
+ CONFIGURE_TIMEOUT,
+ CONNECT_TIMEOUT,
DOMAIN,
LUTRON_CASETA_BUTTON_EVENT,
MANUFACTURER,
@@ -161,28 +161,40 @@ async def async_setup_entry(
keyfile = hass.config.path(entry.data[CONF_KEYFILE])
certfile = hass.config.path(entry.data[CONF_CERTFILE])
ca_certs = hass.config.path(entry.data[CONF_CA_CERTS])
- bridge = None
+ connected_future: asyncio.Future[None] = hass.loop.create_future()
+
+ def _on_connect() -> None:
+ nonlocal connected_future
+ if not connected_future.done():
+ connected_future.set_result(None)
try:
bridge = Smartbridge.create_tls(
- hostname=host, keyfile=keyfile, certfile=certfile, ca_certs=ca_certs
+ hostname=host,
+ keyfile=keyfile,
+ certfile=certfile,
+ ca_certs=ca_certs,
+ on_connect_callback=_on_connect,
)
except ssl.SSLError:
_LOGGER.error("Invalid certificate used to connect to bridge at %s", host)
return False
- timed_out = True
- with contextlib.suppress(TimeoutError):
- async with asyncio.timeout(BRIDGE_TIMEOUT):
- await bridge.connect()
- timed_out = False
+ connect_task = hass.async_create_task(bridge.connect())
+ for future, name, timeout in (
+ (connected_future, "connect", CONNECT_TIMEOUT),
+ (connect_task, "configure", CONFIGURE_TIMEOUT),
+ ):
+ try:
+ async with asyncio.timeout(timeout):
+ await future
+ except TimeoutError as ex:
+ connect_task.cancel()
+ await bridge.close()
+ raise ConfigEntryNotReady(f"Timed out on {name} for {host}") from ex
- if timed_out or not bridge.is_connected():
- await bridge.close()
- if timed_out:
- raise ConfigEntryNotReady(f"Timed out while trying to connect to {host}")
- if not bridge.is_connected():
- raise ConfigEntryNotReady(f"Cannot connect to {host}")
+ if not bridge.is_connected():
+ raise ConfigEntryNotReady(f"Connection failed to {host}")
_LOGGER.debug("Connected to Lutron Caseta bridge via LEAP at %s", host)
await _async_migrate_unique_ids(hass, entry)
diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py
index b51756692c1..cb0f0da5227 100644
--- a/homeassistant/components/lutron_caseta/binary_sensor.py
+++ b/homeassistant/components/lutron_caseta/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import ATTR_SUGGESTED_AREA
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as CASETA_DOMAIN
from .const import CONFIG_URL, MANUFACTURER, UNASSIGNED_AREA
@@ -21,7 +21,7 @@ from .util import area_name_from_id
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LutronCasetaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron Caseta binary_sensor platform.
diff --git a/homeassistant/components/lutron_caseta/button.py b/homeassistant/components/lutron_caseta/button.py
index e56758b0af6..f2da502d346 100644
--- a/homeassistant/components/lutron_caseta/button.py
+++ b/homeassistant/components/lutron_caseta/button.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .device_trigger import LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP
from .entity import LutronCasetaEntity
@@ -17,7 +17,7 @@ from .models import LutronCasetaConfigEntry, LutronCasetaData
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LutronCasetaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Lutron pico and keypad buttons."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py
index 767c3d2f2b7..115da5cb101 100644
--- a/homeassistant/components/lutron_caseta/config_flow.py
+++ b/homeassistant/components/lutron_caseta/config_flow.py
@@ -20,10 +20,11 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
ABORT_REASON_CANNOT_CONNECT,
BRIDGE_DEVICE_ID,
- BRIDGE_TIMEOUT,
CONF_CA_CERTS,
CONF_CERTFILE,
CONF_KEYFILE,
+ CONFIGURE_TIMEOUT,
+ CONNECT_TIMEOUT,
DOMAIN,
ERROR_CANNOT_CONNECT,
STEP_IMPORT_FAILED,
@@ -122,7 +123,8 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN):
assets = None
try:
assets = await async_pair(self.data[CONF_HOST])
- except (TimeoutError, OSError):
+ except (TimeoutError, OSError) as exc:
+ _LOGGER.debug("Pairing failed", exc_info=exc)
errors["base"] = "cannot_connect"
if not errors:
@@ -232,7 +234,7 @@ class LutronCasetaFlowHandler(ConfigFlow, domain=DOMAIN):
return None
try:
- async with asyncio.timeout(BRIDGE_TIMEOUT):
+ async with asyncio.timeout(CONNECT_TIMEOUT + CONFIGURE_TIMEOUT):
await bridge.connect()
except TimeoutError:
_LOGGER.error(
diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py
index 809b9e8d007..26a83de6f4b 100644
--- a/homeassistant/components/lutron_caseta/const.py
+++ b/homeassistant/components/lutron_caseta/const.py
@@ -34,7 +34,8 @@ ACTION_RELEASE = "release"
CONF_SUBTYPE = "subtype"
-BRIDGE_TIMEOUT = 35
+CONNECT_TIMEOUT = 9
+CONFIGURE_TIMEOUT = 50
UNASSIGNED_AREA = "Unassigned"
diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py
index d8fac38ce2b..e05fddb996f 100644
--- a/homeassistant/components/lutron_caseta/cover.py
+++ b/homeassistant/components/lutron_caseta/cover.py
@@ -11,7 +11,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import LutronCasetaUpdatableEntity
from .models import LutronCasetaConfigEntry
@@ -108,13 +108,14 @@ PYLUTRON_TYPE_TO_CLASSES = {
"QsWirelessHorizontalSheerBlind": LutronCasetaShade,
"Shade": LutronCasetaShade,
"PalladiomWireFreeShade": LutronCasetaShade,
+ "SerenaEssentialsRollerShade": LutronCasetaShade,
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LutronCasetaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron Caseta cover platform.
diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py
index 0b432f88045..31c9a0e171d 100644
--- a/homeassistant/components/lutron_caseta/device_trigger.py
+++ b/homeassistant/components/lutron_caseta/device_trigger.py
@@ -277,6 +277,21 @@ FOUR_GROUP_REMOTE_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend(
}
)
+# See mappings at https://github.com/home-assistant/core/issues/137548#issuecomment-2643440119
+PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP = {
+ "on": 2, # 'Number': 2 in LIP
+ "off": 4, # 'Number': 4 in LIP
+}
+PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP = {
+ "on": 0, # 'ButtonNumber': 0 in LEAP
+ "off": 2, # 'ButtonNumber': 2 in LEAP
+}
+PADDLE_SWITCH_PICO_TRIGGER_SCHEMA = LUTRON_BUTTON_TRIGGER_SCHEMA.extend(
+ {
+ vol.Required(CONF_SUBTYPE): vol.In(PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP),
+ }
+)
+
DEVICE_TYPE_SCHEMA_MAP = {
"Pico2Button": PICO_2_BUTTON_TRIGGER_SCHEMA,
@@ -288,6 +303,7 @@ DEVICE_TYPE_SCHEMA_MAP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA,
"FourGroupRemote": FOUR_GROUP_REMOTE_TRIGGER_SCHEMA,
+ "PaddleSwitchPico": PADDLE_SWITCH_PICO_TRIGGER_SCHEMA,
}
DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = {
@@ -300,6 +316,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LIP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LIP,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LIP,
"FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LIP,
+ "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LIP,
}
DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = {
@@ -312,6 +329,7 @@ DEVICE_TYPE_SUBTYPE_MAP_TO_LEAP = {
"Pico4ButtonZone": PICO_4_BUTTON_ZONE_BUTTON_TYPES_TO_LEAP,
"Pico4Button2Group": PICO_4_BUTTON_2_GROUP_BUTTON_TYPES_TO_LEAP,
"FourGroupRemote": FOUR_GROUP_REMOTE_BUTTON_TYPES_TO_LEAP,
+ "PaddleSwitchPico": PADDLE_SWITCH_PICO_BUTTON_TYPES_TO_LEAP,
}
LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP: dict[str, dict[int, str]] = {
@@ -326,6 +344,7 @@ TRIGGER_SCHEMA = vol.Any(
PICO_4_BUTTON_ZONE_TRIGGER_SCHEMA,
PICO_4_BUTTON_2_GROUP_TRIGGER_SCHEMA,
FOUR_GROUP_REMOTE_TRIGGER_SCHEMA,
+ PADDLE_SWITCH_PICO_TRIGGER_SCHEMA,
)
diff --git a/homeassistant/components/lutron_caseta/entity.py b/homeassistant/components/lutron_caseta/entity.py
index f954be74f1d..5ab211ed87b 100644
--- a/homeassistant/components/lutron_caseta/entity.py
+++ b/homeassistant/components/lutron_caseta/entity.py
@@ -63,7 +63,7 @@ class LutronCasetaEntity(Entity):
info[ATTR_SUGGESTED_AREA] = area
self._attr_device_info = info
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self._smartbridge.add_subscriber(self.device_id, self.async_write_ha_state)
diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py
index 69167929e14..1e7fe07b8ba 100644
--- a/homeassistant/components/lutron_caseta/fan.py
+++ b/homeassistant/components/lutron_caseta/fan.py
@@ -12,7 +12,7 @@ from homeassistant.components.fan import (
FanEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
@@ -28,7 +28,7 @@ ORDERED_NAMED_FAN_SPEEDS = [FAN_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_HIGH]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: LutronCasetaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron Caseta fan platform.
diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py
index 722c9a15d91..b920a95e435 100644
--- a/homeassistant/components/lutron_caseta/light.py
+++ b/homeassistant/components/lutron_caseta/light.py
@@ -22,7 +22,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DEVICE_TYPE_COLOR_TUNE,
@@ -66,7 +66,7 @@ def to_hass_level(level):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron Caseta light platform.
diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json
index bbb6df41a89..96b00a1f392 100644
--- a/homeassistant/components/lutron_caseta/manifest.json
+++ b/homeassistant/components/lutron_caseta/manifest.json
@@ -9,7 +9,7 @@
},
"iot_class": "local_push",
"loggers": ["pylutron_caseta"],
- "requirements": ["pylutron-caseta==0.23.0"],
+ "requirements": ["pylutron-caseta==0.24.0"],
"zeroconf": [
{
"type": "_lutron._tcp.local.",
diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py
index db4423495a4..671df82d8e0 100644
--- a/homeassistant/components/lutron_caseta/scene.py
+++ b/homeassistant/components/lutron_caseta/scene.py
@@ -8,7 +8,7 @@ from homeassistant.components.scene import Scene
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN as CASETA_DOMAIN
from .util import serial_to_unique_id
@@ -17,7 +17,7 @@ from .util import serial_to_unique_id
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron Caseta scene platform.
diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py
index 66f23926fbf..b71ccf4bfa8 100644
--- a/homeassistant/components/lutron_caseta/switch.py
+++ b/homeassistant/components/lutron_caseta/switch.py
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import LutronCasetaUpdatableEntity
@@ -13,7 +13,7 @@ from .entity import LutronCasetaUpdatableEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Lutron Caseta switch platform.
diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py
index c5d17cfb176..ffcf08b927a 100644
--- a/homeassistant/components/lyric/climate.py
+++ b/homeassistant/components/lyric/climate.py
@@ -35,7 +35,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -121,7 +121,9 @@ SCHEMA_HOLD_TIME: VolDictType = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell Lyric climate platform based on a config entry."""
coordinator: DataUpdateCoordinator[Lyric] = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py
index 38cb895a110..065ee0fba9d 100644
--- a/homeassistant/components/lyric/sensor.py
+++ b/homeassistant/components/lyric/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
@@ -159,7 +159,9 @@ def get_datetime_from_future_time(time_str: str) -> datetime:
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Honeywell Lyric sensor platform based on a config entry."""
coordinator: DataUpdateCoordinator[Lyric] = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json
index 83c65359643..bc48a791e70 100644
--- a/homeassistant/components/lyric/strings.json
+++ b/homeassistant/components/lyric/strings.json
@@ -53,12 +53,12 @@
},
"services": {
"set_hold_time": {
- "name": "Set Hold Time",
- "description": "Sets the time to hold until.",
+ "name": "Set hold time",
+ "description": "Sets the time period to keep the temperature and override the schedule.",
"fields": {
"time_period": {
- "name": "Time Period",
- "description": "Time to hold until."
+ "name": "Time period",
+ "description": "Duration for which to override the schedule."
}
}
}
diff --git a/homeassistant/components/madvr/binary_sensor.py b/homeassistant/components/madvr/binary_sensor.py
index b6820f94fea..45c915aba8c 100644
--- a/homeassistant/components/madvr/binary_sensor.py
+++ b/homeassistant/components/madvr/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MadVRConfigEntry, MadVRCoordinator
from .entity import MadVREntity
@@ -55,7 +55,7 @@ BINARY_SENSORS: tuple[MadvrBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: MadVRConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary sensor entities."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/madvr/remote.py b/homeassistant/components/madvr/remote.py
index 032a1d718f5..23e969e56e3 100644
--- a/homeassistant/components/madvr/remote.py
+++ b/homeassistant/components/madvr/remote.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.remote import RemoteEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MadVRConfigEntry, MadVRCoordinator
from .entity import MadVREntity
@@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: MadVRConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the madVR remote."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/madvr/sensor.py b/homeassistant/components/madvr/sensor.py
index e54e9dca476..783004f3b84 100644
--- a/homeassistant/components/madvr/sensor.py
+++ b/homeassistant/components/madvr/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
@@ -253,7 +253,7 @@ SENSORS: tuple[MadvrSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: MadVRConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor entities."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/madvr/strings.json b/homeassistant/components/madvr/strings.json
index 19f23afddaf..38b949ee5d6 100644
--- a/homeassistant/components/madvr/strings.json
+++ b/homeassistant/components/madvr/strings.json
@@ -3,7 +3,7 @@
"step": {
"user": {
"title": "Set up madVR Envy",
- "description": "Your device needs to be on in order to add the integation.",
+ "description": "Your device needs to be on in order to add the integration.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
@@ -15,7 +15,7 @@
},
"reconfigure": {
"title": "Reconfigure madVR Envy",
- "description": "Your device needs to be on in order to reconfigure the integation.",
+ "description": "Your device needs to be on in order to reconfigure the integration.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py
index ab8514c8321..17b8614a2e9 100644
--- a/homeassistant/components/mastodon/__init__.py
+++ b/homeassistant/components/mastodon/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from mastodon.Mastodon import Mastodon, MastodonError
+from mastodon.Mastodon import Account, Instance, InstanceV2, Mastodon, MastodonError
from homeassistant.const import (
CONF_ACCESS_TOKEN,
@@ -107,7 +107,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: MastodonConfigEntry) -
return True
-def setup_mastodon(entry: MastodonConfigEntry) -> tuple[Mastodon, dict, dict]:
+def setup_mastodon(
+ entry: MastodonConfigEntry,
+) -> tuple[Mastodon, InstanceV2 | Instance, Account]:
"""Get mastodon details."""
client = create_mastodon_client(
entry.data[CONF_BASE_URL],
diff --git a/homeassistant/components/mastodon/config_flow.py b/homeassistant/components/mastodon/config_flow.py
index 1b93cbecd98..1ae1e6b229e 100644
--- a/homeassistant/components/mastodon/config_flow.py
+++ b/homeassistant/components/mastodon/config_flow.py
@@ -4,7 +4,12 @@ from __future__ import annotations
from typing import Any
-from mastodon.Mastodon import MastodonNetworkError, MastodonUnauthorizedError
+from mastodon.Mastodon import (
+ Account,
+ Instance,
+ MastodonNetworkError,
+ MastodonUnauthorizedError,
+)
import voluptuous as vol
from yarl import URL
@@ -56,8 +61,8 @@ class MastodonConfigFlow(ConfigFlow, domain=DOMAIN):
client_secret: str,
access_token: str,
) -> tuple[
- dict[str, str] | None,
- dict[str, str] | None,
+ Instance | None,
+ Account | None,
dict[str, str],
]:
"""Check connection to the Mastodon instance."""
diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py
index b7e86eaad5a..2efda329467 100644
--- a/homeassistant/components/mastodon/const.py
+++ b/homeassistant/components/mastodon/const.py
@@ -12,17 +12,10 @@ DATA_HASS_CONFIG = "mastodon_hass_config"
DEFAULT_URL: Final = "https://mastodon.social"
DEFAULT_NAME: Final = "Mastodon"
-INSTANCE_VERSION: Final = "version"
-INSTANCE_URI: Final = "uri"
-INSTANCE_DOMAIN: Final = "domain"
-ACCOUNT_USERNAME: Final = "username"
-ACCOUNT_FOLLOWERS_COUNT: Final = "followers_count"
-ACCOUNT_FOLLOWING_COUNT: Final = "following_count"
-ACCOUNT_STATUSES_COUNT: Final = "statuses_count"
-
ATTR_CONFIG_ENTRY_ID = "config_entry_id"
ATTR_STATUS = "status"
ATTR_VISIBILITY = "visibility"
ATTR_CONTENT_WARNING = "content_warning"
ATTR_MEDIA_WARNING = "media_warning"
ATTR_MEDIA = "media"
+ATTR_MEDIA_DESCRIPTION = "media_description"
diff --git a/homeassistant/components/mastodon/coordinator.py b/homeassistant/components/mastodon/coordinator.py
index 5d2b193b4a8..99785eca80b 100644
--- a/homeassistant/components/mastodon/coordinator.py
+++ b/homeassistant/components/mastodon/coordinator.py
@@ -4,10 +4,9 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
-from typing import Any
from mastodon import Mastodon
-from mastodon.Mastodon import MastodonError
+from mastodon.Mastodon import Account, Instance, InstanceV2, MastodonError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -21,15 +20,15 @@ class MastodonData:
"""Mastodon data type."""
client: Mastodon
- instance: dict
- account: dict
+ instance: InstanceV2 | Instance
+ account: Account
coordinator: MastodonCoordinator
type MastodonConfigEntry = ConfigEntry[MastodonData]
-class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]):
+class MastodonCoordinator(DataUpdateCoordinator[Account]):
"""Class to manage fetching Mastodon data."""
config_entry: MastodonConfigEntry
@@ -47,9 +46,9 @@ class MastodonCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
self.client = client
- async def _async_update_data(self) -> dict[str, Any]:
+ async def _async_update_data(self) -> Account:
try:
- account: dict = await self.hass.async_add_executor_job(
+ account: Account = await self.hass.async_add_executor_job(
self.client.account_verify_credentials
)
except MastodonError as ex:
diff --git a/homeassistant/components/mastodon/diagnostics.py b/homeassistant/components/mastodon/diagnostics.py
index dc7c1b785ab..31444413dfd 100644
--- a/homeassistant/components/mastodon/diagnostics.py
+++ b/homeassistant/components/mastodon/diagnostics.py
@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any
+from mastodon.Mastodon import Account, Instance
+
from homeassistant.core import HomeAssistant
from .coordinator import MastodonConfigEntry
@@ -25,7 +27,7 @@ async def async_get_config_entry_diagnostics(
}
-def get_diagnostics(config_entry: MastodonConfigEntry) -> tuple[dict, dict]:
+def get_diagnostics(config_entry: MastodonConfigEntry) -> tuple[Instance, Account]:
"""Get mastodon diagnostics."""
client = config_entry.runtime_data.client
diff --git a/homeassistant/components/mastodon/entity.py b/homeassistant/components/mastodon/entity.py
index 2ae8c0d852e..60224e75e41 100644
--- a/homeassistant/components/mastodon/entity.py
+++ b/homeassistant/components/mastodon/entity.py
@@ -4,7 +4,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import DEFAULT_NAME, DOMAIN, INSTANCE_VERSION
+from .const import DEFAULT_NAME, DOMAIN
from .coordinator import MastodonConfigEntry, MastodonCoordinator
from .utils import construct_mastodon_username
@@ -40,7 +40,7 @@ class MastodonEntity(CoordinatorEntity[MastodonCoordinator]):
manufacturer="Mastodon gGmbH",
model=full_account_name,
entry_type=DeviceEntryType.SERVICE,
- sw_version=data.runtime_data.instance[INSTANCE_VERSION],
+ sw_version=data.runtime_data.instance.version,
name=name,
)
diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json
index 20c506e7766..d7b21ad3a0c 100644
--- a/homeassistant/components/mastodon/manifest.json
+++ b/homeassistant/components/mastodon/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["mastodon"],
- "requirements": ["Mastodon.py==1.8.1"]
+ "requirements": ["Mastodon.py==2.0.1"]
}
diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py
index 8e7e9dc1947..149ef1f6a48 100644
--- a/homeassistant/components/mastodon/notify.py
+++ b/homeassistant/components/mastodon/notify.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any, cast
from mastodon import Mastodon
-from mastodon.Mastodon import MastodonAPIError
+from mastodon.Mastodon import MastodonAPIError, MediaAttachment
import voluptuous as vol
from homeassistant.components.notify import (
@@ -52,7 +52,7 @@ async def async_get_service(
if discovery_info is None:
return None
- client: Mastodon = discovery_info.get("client")
+ client = cast(Mastodon, discovery_info.get("client"))
return MastodonNotificationService(hass, client)
@@ -114,7 +114,7 @@ class MastodonNotificationService(BaseNotificationService):
message,
visibility=target,
spoiler_text=content_warning,
- media_ids=mediadata["id"],
+ media_ids=mediadata.id,
sensitive=sensitive,
)
except MastodonAPIError as err:
@@ -134,12 +134,14 @@ class MastodonNotificationService(BaseNotificationService):
translation_key="unable_to_send_message",
) from err
- def _upload_media(self, media_path: Any = None) -> Any:
+ def _upload_media(self, media_path: Any = None) -> MediaAttachment:
"""Upload media."""
with open(media_path, "rb"):
media_type = get_media_type(media_path)
try:
- mediadata = self.client.media_post(media_path, mime_type=media_type)
+ mediadata: MediaAttachment = self.client.media_post(
+ media_path, mime_type=media_type
+ )
except MastodonAPIError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/mastodon/quality_scale.yaml b/homeassistant/components/mastodon/quality_scale.yaml
index 43636ed6924..f07f7e0a8ad 100644
--- a/homeassistant/components/mastodon/quality_scale.yaml
+++ b/homeassistant/components/mastodon/quality_scale.yaml
@@ -93,7 +93,4 @@ rules:
# Platinum
async-dependency: todo
inject-websession: todo
- strict-typing:
- status: todo
- comment: |
- Requirement 'Mastodon.py==1.8.1' appears untyped
+ strict-typing: done
diff --git a/homeassistant/components/mastodon/sensor.py b/homeassistant/components/mastodon/sensor.py
index 93ec77032ce..bfdc9c90333 100644
--- a/homeassistant/components/mastodon/sensor.py
+++ b/homeassistant/components/mastodon/sensor.py
@@ -4,7 +4,8 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
-from typing import Any
+
+from mastodon.Mastodon import Account
from homeassistant.components.sensor import (
SensorEntity,
@@ -12,14 +13,9 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
-from .const import (
- ACCOUNT_FOLLOWERS_COUNT,
- ACCOUNT_FOLLOWING_COUNT,
- ACCOUNT_STATUSES_COUNT,
-)
from .coordinator import MastodonConfigEntry
from .entity import MastodonEntity
@@ -31,7 +27,7 @@ PARALLEL_UPDATES = 0
class MastodonSensorEntityDescription(SensorEntityDescription):
"""Describes Mastodon sensor entity."""
- value_fn: Callable[[dict[str, Any]], StateType]
+ value_fn: Callable[[Account], StateType]
ENTITY_DESCRIPTIONS = (
@@ -39,19 +35,19 @@ ENTITY_DESCRIPTIONS = (
key="followers",
translation_key="followers",
state_class=SensorStateClass.TOTAL,
- value_fn=lambda data: data.get(ACCOUNT_FOLLOWERS_COUNT),
+ value_fn=lambda data: data.followers_count,
),
MastodonSensorEntityDescription(
key="following",
translation_key="following",
state_class=SensorStateClass.TOTAL,
- value_fn=lambda data: data.get(ACCOUNT_FOLLOWING_COUNT),
+ value_fn=lambda data: data.following_count,
),
MastodonSensorEntityDescription(
key="posts",
translation_key="posts",
state_class=SensorStateClass.TOTAL,
- value_fn=lambda data: data.get(ACCOUNT_STATUSES_COUNT),
+ value_fn=lambda data: data.statuses_count,
),
)
@@ -59,7 +55,7 @@ ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: MastodonConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform for entity."""
coordinator = entry.runtime_data.coordinator
diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py
index 2a919e5fa5f..68e95e726a1 100644
--- a/homeassistant/components/mastodon/services.py
+++ b/homeassistant/components/mastodon/services.py
@@ -5,7 +5,7 @@ from functools import partial
from typing import Any, cast
from mastodon import Mastodon
-from mastodon.Mastodon import MastodonAPIError
+from mastodon.Mastodon import MastodonAPIError, MediaAttachment
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
@@ -16,6 +16,7 @@ from .const import (
ATTR_CONFIG_ENTRY_ID,
ATTR_CONTENT_WARNING,
ATTR_MEDIA,
+ ATTR_MEDIA_DESCRIPTION,
ATTR_MEDIA_WARNING,
ATTR_STATUS,
ATTR_VISIBILITY,
@@ -42,6 +43,7 @@ SERVICE_POST_SCHEMA = vol.Schema(
vol.Optional(ATTR_VISIBILITY): vol.In([x.lower() for x in StatusVisibility]),
vol.Optional(ATTR_CONTENT_WARNING): str,
vol.Optional(ATTR_MEDIA): str,
+ vol.Optional(ATTR_MEDIA_DESCRIPTION): str,
vol.Optional(ATTR_MEDIA_WARNING): bool,
}
)
@@ -81,6 +83,7 @@ def setup_services(hass: HomeAssistant) -> None:
)
spoiler_text: str | None = call.data.get(ATTR_CONTENT_WARNING)
media_path: str | None = call.data.get(ATTR_MEDIA)
+ media_description: str | None = call.data.get(ATTR_MEDIA_DESCRIPTION)
media_warning: str | None = call.data.get(ATTR_MEDIA_WARNING)
await hass.async_add_executor_job(
@@ -91,6 +94,7 @@ def setup_services(hass: HomeAssistant) -> None:
visibility=visibility,
spoiler_text=spoiler_text,
media_path=media_path,
+ media_description=media_description,
sensitive=media_warning,
)
)
@@ -100,7 +104,7 @@ def setup_services(hass: HomeAssistant) -> None:
def _post(client: Mastodon, **kwargs: Any) -> None:
"""Post to Mastodon."""
- media_data: dict[str, Any] | None = None
+ media_data: MediaAttachment | None = None
media_path = kwargs.get("media_path")
if media_path:
@@ -112,9 +116,12 @@ def setup_services(hass: HomeAssistant) -> None:
)
media_type = get_media_type(media_path)
+ media_description = kwargs.get("media_description")
try:
media_data = client.media_post(
- media_file=media_path, mime_type=media_type
+ media_file=media_path,
+ mime_type=media_type,
+ description=media_description,
)
except MastodonAPIError as err:
@@ -125,11 +132,12 @@ def setup_services(hass: HomeAssistant) -> None:
) from err
kwargs.pop("media_path", None)
+ kwargs.pop("media_description", None)
try:
media_ids: str | None = None
if media_data:
- media_ids = media_data["id"]
+ media_ids = media_data.id
client.status_post(media_ids=media_ids, **kwargs)
except MastodonAPIError as err:
raise HomeAssistantError(
diff --git a/homeassistant/components/mastodon/services.yaml b/homeassistant/components/mastodon/services.yaml
index 161a0d152ca..206dc36c1a2 100644
--- a/homeassistant/components/mastodon/services.yaml
+++ b/homeassistant/components/mastodon/services.yaml
@@ -24,6 +24,10 @@ post:
media:
selector:
text:
+ media_description:
+ required: false
+ selector:
+ text:
media_warning:
required: true
selector:
diff --git a/homeassistant/components/mastodon/strings.json b/homeassistant/components/mastodon/strings.json
index 87858f768e4..9e6cf6db6bf 100644
--- a/homeassistant/components/mastodon/strings.json
+++ b/homeassistant/components/mastodon/strings.json
@@ -22,7 +22,7 @@
"error": {
"unauthorized_error": "The credentials are incorrect.",
"network_error": "The Mastodon instance was not found.",
- "unknown": "Unknown error occured when connecting to the Mastodon instance."
+ "unknown": "Unknown error occurred when connecting to the Mastodon instance."
}
},
"exceptions": {
@@ -89,6 +89,10 @@
"name": "Media",
"description": "Attach an image or video to the post."
},
+ "media_description": {
+ "name": "Media description",
+ "description": "If an image or video is attached, will add a description for this media for people with visual impairments."
+ },
"media_warning": {
"name": "Media warning",
"description": "If an image or video is attached, will mark the media as sensitive (default: no media warning)."
diff --git a/homeassistant/components/mastodon/utils.py b/homeassistant/components/mastodon/utils.py
index e9c2567b675..898578c931b 100644
--- a/homeassistant/components/mastodon/utils.py
+++ b/homeassistant/components/mastodon/utils.py
@@ -6,8 +6,9 @@ import mimetypes
from typing import Any
from mastodon import Mastodon
+from mastodon.Mastodon import Account, Instance, InstanceV2
-from .const import ACCOUNT_USERNAME, DEFAULT_NAME, INSTANCE_DOMAIN, INSTANCE_URI
+from .const import DEFAULT_NAME
def create_mastodon_client(
@@ -23,14 +24,13 @@ def create_mastodon_client(
def construct_mastodon_username(
- instance: dict[str, str] | None, account: dict[str, str] | None
+ instance: InstanceV2 | Instance | None, account: Account | None
) -> str:
"""Construct a mastodon username from the account and instance."""
if instance and account:
- return (
- f"@{account[ACCOUNT_USERNAME]}@"
- f"{instance.get(INSTANCE_URI, instance.get(INSTANCE_DOMAIN))}"
- )
+ if type(instance) is InstanceV2:
+ return f"@{account.username}@{instance.domain}"
+ return f"@{account.username}@{instance.uri}"
return DEFAULT_NAME
diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json
index b173a2c850b..6cab2c39c97 100644
--- a/homeassistant/components/matrix/manifest.json
+++ b/homeassistant/components/matrix/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
"quality_scale": "legacy",
- "requirements": ["matrix-nio==0.25.2", "Pillow==11.1.0"]
+ "requirements": ["matrix-nio==0.25.2", "Pillow==11.2.1"]
}
diff --git a/homeassistant/components/matter/binary_sensor.py b/homeassistant/components/matter/binary_sensor.py
index 484ed94fb90..a55df58cac7 100644
--- a/homeassistant/components/matter/binary_sensor.py
+++ b/homeassistant/components/matter/binary_sensor.py
@@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter
@@ -28,7 +28,7 @@ from .models import MatterDiscoverySchema
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter binary sensor from Config Entry."""
matter = get_matter(hass)
@@ -265,4 +265,61 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterBinarySensor,
required_attributes=(clusters.SmokeCoAlarm.Attributes.InterconnectCOAlarm,),
),
+ MatterDiscoverySchema(
+ platform=Platform.BINARY_SENSOR,
+ entity_description=MatterBinarySensorEntityDescription(
+ key="EnergyEvseChargingStatusSensor",
+ translation_key="evse_charging_status",
+ device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
+ measurement_to_ha={
+ clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False,
+ clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: False,
+ clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: False,
+ clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True,
+ clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: False,
+ clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False,
+ clusters.EnergyEvse.Enums.StateEnum.kFault: False,
+ }.get,
+ ),
+ entity_class=MatterBinarySensor,
+ required_attributes=(clusters.EnergyEvse.Attributes.State,),
+ allow_multi=True, # also used for sensor entity
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.BINARY_SENSOR,
+ entity_description=MatterBinarySensorEntityDescription(
+ key="EnergyEvsePlugStateSensor",
+ translation_key="evse_plug_state",
+ device_class=BinarySensorDeviceClass.PLUG,
+ measurement_to_ha={
+ clusters.EnergyEvse.Enums.StateEnum.kNotPluggedIn: False,
+ clusters.EnergyEvse.Enums.StateEnum.kPluggedInNoDemand: True,
+ clusters.EnergyEvse.Enums.StateEnum.kPluggedInDemand: True,
+ clusters.EnergyEvse.Enums.StateEnum.kPluggedInCharging: True,
+ clusters.EnergyEvse.Enums.StateEnum.kPluggedInDischarging: True,
+ clusters.EnergyEvse.Enums.StateEnum.kSessionEnding: False,
+ clusters.EnergyEvse.Enums.StateEnum.kFault: False,
+ }.get,
+ ),
+ entity_class=MatterBinarySensor,
+ required_attributes=(clusters.EnergyEvse.Attributes.State,),
+ allow_multi=True, # also used for sensor entity
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.BINARY_SENSOR,
+ entity_description=MatterBinarySensorEntityDescription(
+ key="EnergyEvseSupplyStateSensor",
+ translation_key="evse_supply_charging_state",
+ device_class=BinarySensorDeviceClass.RUNNING,
+ measurement_to_ha={
+ clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False,
+ clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True,
+ clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False,
+ clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False,
+ }.get,
+ ),
+ entity_class=MatterBinarySensor,
+ required_attributes=(clusters.EnergyEvse.Attributes.SupplyState,),
+ allow_multi=True, # also used for sensor entity
+ ),
]
diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py
index 634406d18eb..6a0a5fc5b1d 100644
--- a/homeassistant/components/matter/button.py
+++ b/homeassistant/components/matter/button.py
@@ -16,7 +16,7 @@ from homeassistant.components.button import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter
@@ -26,7 +26,7 @@ from .models import MatterDiscoverySchema
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter Button platform."""
matter = get_matter(hass)
diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py
index 25419c34e42..df57da4ded3 100644
--- a/homeassistant/components/matter/climate.py
+++ b/homeassistant/components/matter/climate.py
@@ -24,7 +24,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity
from .helpers import get_matter
@@ -174,7 +174,7 @@ class ThermostatRunningState(IntEnum):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter climate platform from Config Entry."""
matter = get_matter(hass)
diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py
index 5b109d52189..2e2d4390b30 100644
--- a/homeassistant/components/matter/cover.py
+++ b/homeassistant/components/matter/cover.py
@@ -19,7 +19,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER
from .entity import MatterEntity
@@ -48,7 +48,7 @@ class OperationalStatus(IntEnum):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter Cover from Config Entry."""
matter = get_matter(hass)
diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py
index 96696193466..fded57d34f5 100644
--- a/homeassistant/components/matter/entity.py
+++ b/homeassistant/components/matter/entity.py
@@ -61,6 +61,7 @@ class MatterEntityDescription(EntityDescription):
# convert the value from the primary attribute to the value used by HA
measurement_to_ha: Callable[[Any], Any] | None = None
ha_to_native_value: Callable[[Any], Any] | None = None
+ command_timeout: int | None = None
class MatterEntity(Entity):
diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py
index 3cb3fe385d4..fa7d96ed1ae 100644
--- a/homeassistant/components/matter/event.py
+++ b/homeassistant/components/matter/event.py
@@ -16,7 +16,7 @@ from homeassistant.components.event import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity
from .helpers import get_matter
@@ -39,7 +39,7 @@ EVENT_TYPES_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter switches from Config Entry."""
matter = get_matter(hass)
@@ -69,7 +69,7 @@ class MatterEventEntity(MatterEntity, EventEntity):
max_presses_supported = self.get_matter_attribute_value(
clusters.Switch.Attributes.MultiPressMax
)
- max_presses_supported = min(max_presses_supported or 1, 8)
+ max_presses_supported = min(max_presses_supported or 2, 8)
for i in range(max_presses_supported):
event_types.append(f"multi_press_{i + 1}") # noqa: PERF401
elif feature_map & SwitchFeature.kMomentarySwitch:
diff --git a/homeassistant/components/matter/fan.py b/homeassistant/components/matter/fan.py
index 8b8ebee619d..2c9e190d58a 100644
--- a/homeassistant/components/matter/fan.py
+++ b/homeassistant/components/matter/fan.py
@@ -16,7 +16,7 @@ from homeassistant.components.fan import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity
from .helpers import get_matter
@@ -45,7 +45,7 @@ PRESET_SLEEP_WIND = "sleep_wind"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter fan from Config Entry."""
matter = get_matter(hass)
diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json
index f9217cabcc4..fed51708870 100644
--- a/homeassistant/components/matter/icons.json
+++ b/homeassistant/components/matter/icons.json
@@ -71,6 +71,15 @@
},
"battery_replacement_description": {
"default": "mdi:battery-sync-outline"
+ },
+ "evse_state": {
+ "default": "mdi:ev-station"
+ },
+ "evse_supply_state": {
+ "default": "mdi:ev-station"
+ },
+ "evse_fault_state": {
+ "default": "mdi:ev-station"
}
},
"switch": {
@@ -80,6 +89,9 @@
"on": "mdi:lock",
"off": "mdi:lock-off"
}
+ },
+ "evse_charging_switch": {
+ "default": "mdi:ev-station"
}
}
}
diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py
index 5c20554f065..8ea804a8a7c 100644
--- a/homeassistant/components/matter/light.py
+++ b/homeassistant/components/matter/light.py
@@ -24,7 +24,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .const import LOGGER
@@ -77,7 +77,7 @@ TRANSITION_BLOCKLIST = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter Light from Config Entry."""
matter = get_matter(hass)
diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py
index 8524b39d584..81de7482d46 100644
--- a/homeassistant/components/matter/lock.py
+++ b/homeassistant/components/matter/lock.py
@@ -15,7 +15,7 @@ from homeassistant.components.lock import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CODE, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER
from .entity import MatterEntity
@@ -28,7 +28,7 @@ DoorLockFeature = clusters.DoorLock.Bitmaps.Feature
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter lock from Config Entry."""
matter = get_matter(hass)
diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json
index 669fa1af8c4..48f0bfa2e67 100644
--- a/homeassistant/components/matter/manifest.json
+++ b/homeassistant/components/matter/manifest.json
@@ -1,6 +1,6 @@
{
"domain": "matter",
- "name": "Matter (BETA)",
+ "name": "Matter",
"after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/matter"],
"config_flow": true,
diff --git a/homeassistant/components/matter/number.py b/homeassistant/components/matter/number.py
index 93b6b8f75c9..2c7a9651c60 100644
--- a/homeassistant/components/matter/number.py
+++ b/homeassistant/components/matter/number.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter
@@ -32,7 +32,7 @@ from .models import MatterDiscoverySchema
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter Number Input from Config Entry."""
matter = get_matter(hass)
@@ -169,8 +169,8 @@ DISCOVERY_SCHEMAS = [
device_class=NumberDeviceClass.TEMPERATURE,
entity_category=EntityCategory.CONFIG,
translation_key="temperature_offset",
- native_max_value=25,
- native_min_value=-25,
+ native_max_value=50,
+ native_min_value=-50,
native_step=0.5,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
measurement_to_ha=lambda x: None if x is None else x / 10,
diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py
index b2d1c7f8ddb..e78c34391cd 100644
--- a/homeassistant/components/matter/select.py
+++ b/homeassistant/components/matter/select.py
@@ -14,7 +14,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter
@@ -47,7 +47,7 @@ type SelectCluster = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter ModeSelect from Config Entry."""
matter = get_matter(hass)
diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py
index 3503e112db5..82d8ec1727c 100644
--- a/homeassistant/components/matter/sensor.py
+++ b/homeassistant/components/matter/sensor.py
@@ -40,7 +40,7 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from .entity import MatterEntity, MatterEntityDescription
@@ -77,11 +77,30 @@ OPERATIONAL_STATE_MAP = {
clusters.RvcOperationalState.Enums.OperationalStateEnum.kDocked: "docked",
}
+EVSE_FAULT_STATE_MAP = {
+ clusters.EnergyEvse.Enums.FaultStateEnum.kNoError: "no_error",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kMeterFailure: "meter_failure",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kOverVoltage: "over_voltage",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kUnderVoltage: "under_voltage",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kOverCurrent: "over_current",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kContactWetFailure: "contact_wet_failure",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kContactDryFailure: "contact_dry_failure",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kPowerLoss: "power_loss",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kPowerQuality: "power_quality",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kPilotShortCircuit: "pilot_short_circuit",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kEmergencyStop: "emergency_stop",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kEVDisconnected: "ev_disconnected",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kWrongPowerSupply: "wrong_power_supply",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kLiveNeutralSwap: "live_neutral_swap",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kOverTemperature: "over_temperature",
+ clusters.EnergyEvse.Enums.FaultStateEnum.kOther: "other",
+}
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter sensors from Config Entry."""
matter = get_matter(hass)
@@ -904,4 +923,77 @@ DISCOVERY_SCHEMAS = [
# don't discover this entry if the supported state list is empty
secondary_value_is_not=[],
),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="EnergyEvseFaultState",
+ translation_key="evse_fault_state",
+ device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ options=list(EVSE_FAULT_STATE_MAP.values()),
+ measurement_to_ha=EVSE_FAULT_STATE_MAP.get,
+ ),
+ entity_class=MatterSensor,
+ required_attributes=(clusters.EnergyEvse.Attributes.FaultState,),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="EnergyEvseCircuitCapacity",
+ translation_key="evse_circuit_capacity",
+ device_class=SensorDeviceClass.CURRENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+ suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ suggested_display_precision=2,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ entity_class=MatterSensor,
+ required_attributes=(clusters.EnergyEvse.Attributes.CircuitCapacity,),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="EnergyEvseMinimumChargeCurrent",
+ translation_key="evse_min_charge_current",
+ device_class=SensorDeviceClass.CURRENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+ suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ suggested_display_precision=2,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ entity_class=MatterSensor,
+ required_attributes=(clusters.EnergyEvse.Attributes.MinimumChargeCurrent,),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="EnergyEvseMaximumChargeCurrent",
+ translation_key="evse_max_charge_current",
+ device_class=SensorDeviceClass.CURRENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+ suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ suggested_display_precision=2,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ entity_class=MatterSensor,
+ required_attributes=(clusters.EnergyEvse.Attributes.MaximumChargeCurrent,),
+ ),
+ MatterDiscoverySchema(
+ platform=Platform.SENSOR,
+ entity_description=MatterSensorEntityDescription(
+ key="EnergyEvseUserMaximumChargeCurrent",
+ translation_key="evse_user_max_charge_current",
+ device_class=SensorDeviceClass.CURRENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE,
+ suggested_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ suggested_display_precision=2,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ entity_class=MatterSensor,
+ required_attributes=(clusters.EnergyEvse.Attributes.UserMaximumChargeCurrent,),
+ ),
]
diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json
index f299b5cb628..f6e7187f8c0 100644
--- a/homeassistant/components/matter/strings.json
+++ b/homeassistant/components/matter/strings.json
@@ -76,6 +76,15 @@
},
"muted": {
"name": "Muted"
+ },
+ "evse_charging_status": {
+ "name": "Charging status"
+ },
+ "evse_plug": {
+ "name": "Plug state"
+ },
+ "evse_supply_charging_state": {
+ "name": "Supply charging state"
}
},
"button": {
@@ -135,10 +144,10 @@
"state_attributes": {
"preset_mode": {
"state": {
- "low": "Low",
- "medium": "Medium",
- "high": "High",
- "auto": "Auto",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
+ "auto": "[%key:common::state::auto%]",
"natural_wind": "Natural wind",
"sleep_wind": "Sleep wind"
}
@@ -160,7 +169,7 @@
"name": "On/Off transition time"
},
"altitude": {
- "name": "Altitude above Sea Level"
+ "name": "Altitude above sea level"
},
"temperature_offset": {
"name": "Temperature offset"
@@ -189,9 +198,9 @@
"sensitivity_level": {
"name": "Sensitivity",
"state": {
- "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]",
+ "low": "[%key:common::state::low%]",
"standard": "Standard",
- "high": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::high%]"
+ "high": "[%key:common::state::high%]"
}
},
"startup_on_off": {
@@ -213,7 +222,7 @@
"name": "Number of rinses",
"state": {
"off": "[%key:common::state::off%]",
- "normal": "Normal",
+ "normal": "[%key:common::state::normal%]",
"extra": "Extra",
"max": "Max"
}
@@ -229,8 +238,8 @@
"contamination_state": {
"name": "Contamination state",
"state": {
- "normal": "Normal",
- "low": "[%key:component::matter::entity::fan::fan::state_attributes::preset_mode::state::low%]",
+ "normal": "[%key:common::state::normal%]",
+ "low": "[%key:common::state::low%]",
"warning": "Warning",
"critical": "Critical"
}
@@ -258,12 +267,12 @@
"operational_state": {
"name": "Operational state",
"state": {
- "stopped": "Stopped",
+ "stopped": "[%key:common::state::stopped%]",
"running": "Running",
"paused": "[%key:common::state::paused%]",
"error": "Error",
"seeking_charger": "Seeking charger",
- "charging": "Charging",
+ "charging": "[%key:common::state::charging%]",
"docked": "Docked"
}
},
@@ -278,6 +287,42 @@
},
"current_phase": {
"name": "Current phase"
+ },
+ "evse_fault_state": {
+ "name": "Fault state",
+ "state": {
+ "no_error": "OK",
+ "meter_failure": "Meter failure",
+ "over_voltage": "Overvoltage",
+ "under_voltage": "Undervoltage",
+ "over_current": "Overcurrent",
+ "contact_wet_failure": "Contact wet failure",
+ "contact_dry_failure": "Contact dry failure",
+ "power_loss": "Power loss",
+ "power_quality": "Power quality",
+ "pilot_short_circuit": "Pilot short circuit",
+ "emergency_stop": "Emergency stop",
+ "ev_disconnected": "EV disconnected",
+ "wrong_power_supply": "Wrong power supply",
+ "live_neutral_swap": "Live/neutral swap",
+ "over_temperature": "Overtemperature",
+ "other": "Other fault"
+ }
+ },
+ "evse_circuit_capacity": {
+ "name": "Circuit capacity"
+ },
+ "evse_charge_current": {
+ "name": "Charge current"
+ },
+ "evse_min_charge_current": {
+ "name": "Min charge current"
+ },
+ "evse_max_charge_current": {
+ "name": "Max charge current"
+ },
+ "evse_user_max_charge_current": {
+ "name": "User max charge current"
}
},
"switch": {
@@ -289,6 +334,9 @@
},
"child_lock": {
"name": "Child lock"
+ },
+ "evse_charging_switch": {
+ "name": "Enable charging"
}
},
"vacuum": {
diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py
index 890ca662295..870a9098492 100644
--- a/homeassistant/components/matter/switch.py
+++ b/homeassistant/components/matter/switch.py
@@ -2,10 +2,12 @@
from __future__ import annotations
+from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from chip.clusters import Objects as clusters
+from chip.clusters.Objects import ClusterCommand, NullValue
from matter_server.client.models import device_types
from homeassistant.components.switch import (
@@ -16,17 +18,24 @@ from homeassistant.components.switch import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter
from .models import MatterDiscoverySchema
+EVSE_SUPPLY_STATE_MAP = {
+ clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabled: False,
+ clusters.EnergyEvse.Enums.SupplyStateEnum.kChargingEnabled: True,
+ clusters.EnergyEvse.Enums.SupplyStateEnum.kDischargingEnabled: False,
+ clusters.EnergyEvse.Enums.SupplyStateEnum.kDisabledDiagnostics: False,
+}
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter switches from Config Entry."""
matter = get_matter(hass)
@@ -58,6 +67,66 @@ class MatterSwitch(MatterEntity, SwitchEntity):
)
+class MatterGenericCommandSwitch(MatterSwitch):
+ """Representation of a Matter switch."""
+
+ entity_description: MatterGenericCommandSwitchEntityDescription
+
+ _platform_translation_key = "switch"
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn switch on."""
+ if self.entity_description.on_command:
+ # custom command defined to set the new value
+ await self.send_device_command(
+ self.entity_description.on_command(),
+ self.entity_description.command_timeout,
+ )
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn switch off."""
+ if self.entity_description.off_command:
+ await self.send_device_command(
+ self.entity_description.off_command(),
+ self.entity_description.command_timeout,
+ )
+
+ @callback
+ def _update_from_device(self) -> None:
+ """Update from device."""
+ value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
+ if value_convert := self.entity_description.measurement_to_ha:
+ value = value_convert(value)
+ self._attr_is_on = value
+
+ async def send_device_command(
+ self,
+ command: ClusterCommand,
+ command_timeout: int | None = None,
+ **kwargs: Any,
+ ) -> None:
+ """Send device command with timeout."""
+ await self.matter_client.send_device_command(
+ node_id=self._endpoint.node.node_id,
+ endpoint_id=self._endpoint.endpoint_id,
+ command=command,
+ timed_request_timeout_ms=command_timeout,
+ **kwargs,
+ )
+
+
+@dataclass(frozen=True)
+class MatterGenericCommandSwitchEntityDescription(
+ SwitchEntityDescription, MatterEntityDescription
+):
+ """Describe Matter Generic command Switch entities."""
+
+ # command: a custom callback to create the command to send to the device
+ on_command: Callable[[], Any] | None = None
+ off_command: Callable[[], Any] | None = None
+ command_timeout: int | None = None
+
+
@dataclass(frozen=True)
class MatterNumericSwitchEntityDescription(
SwitchEntityDescription, MatterEntityDescription
@@ -194,4 +263,26 @@ DISCOVERY_SCHEMAS = [
),
vendor_id=(4874,),
),
+ MatterDiscoverySchema(
+ platform=Platform.SWITCH,
+ entity_description=MatterGenericCommandSwitchEntityDescription(
+ key="EnergyEvseChargingSwitch",
+ translation_key="evse_charging_switch",
+ on_command=lambda: clusters.EnergyEvse.Commands.EnableCharging(
+ chargingEnabledUntil=NullValue,
+ minimumChargeCurrent=0,
+ maximumChargeCurrent=0,
+ ),
+ off_command=clusters.EnergyEvse.Commands.Disable,
+ command_timeout=3000,
+ measurement_to_ha=EVSE_SUPPLY_STATE_MAP.get,
+ ),
+ entity_class=MatterGenericCommandSwitch,
+ required_attributes=(
+ clusters.EnergyEvse.Attributes.SupplyState,
+ clusters.EnergyEvse.Attributes.AcceptedCommandList,
+ ),
+ value_contains=clusters.EnergyEvse.Commands.EnableCharging.command_id,
+ allow_multi=True,
+ ),
]
diff --git a/homeassistant/components/matter/update.py b/homeassistant/components/matter/update.py
index 5ee9b2e5fa0..cea4fe0c810 100644
--- a/homeassistant/components/matter/update.py
+++ b/homeassistant/components/matter/update.py
@@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON, Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.restore_state import ExtraStoredData
@@ -60,7 +60,7 @@ class MatterUpdateExtraStoredData(ExtraStoredData):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter lock from Config Entry."""
matter = get_matter(hass)
@@ -251,7 +251,7 @@ DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
platform=Platform.UPDATE,
entity_description=UpdateEntityDescription(
- key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE, name=None
+ key="MatterUpdate", device_class=UpdateDeviceClass.FIRMWARE
),
entity_class=MatterUpdate,
required_attributes=(
diff --git a/homeassistant/components/matter/vacuum.py b/homeassistant/components/matter/vacuum.py
index de4a885d8fb..5ea1716a37d 100644
--- a/homeassistant/components/matter/vacuum.py
+++ b/homeassistant/components/matter/vacuum.py
@@ -17,7 +17,7 @@ from homeassistant.components.vacuum import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity
from .helpers import get_matter
@@ -50,7 +50,7 @@ class ModeTag(IntEnum):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter vacuum platform from Config Entry."""
matter = get_matter(hass)
diff --git a/homeassistant/components/matter/valve.py b/homeassistant/components/matter/valve.py
index 29946621853..bea11468c6b 100644
--- a/homeassistant/components/matter/valve.py
+++ b/homeassistant/components/matter/valve.py
@@ -14,7 +14,7 @@ from homeassistant.components.valve import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import MatterEntity
from .helpers import get_matter
@@ -28,7 +28,7 @@ ValveStateEnum = ValveConfigurationAndControl.Enums.ValveStateEnum
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter valve platform from Config Entry."""
matter = get_matter(hass)
diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py
index fd323060ac0..ccbb331573e 100644
--- a/homeassistant/components/mazda/__init__.py
+++ b/homeassistant/components/mazda/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- if all(
- config_entry.state is ConfigEntryState.NOT_LOADED
- for config_entry in hass.config_entries.async_entries(DOMAIN)
- if config_entry.entry_id != entry.entry_id
- ):
- ir.async_delete_issue(hass, DOMAIN, DOMAIN)
-
return True
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Remove a config entry."""
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
+ ir.async_delete_issue(hass, DOMAIN, DOMAIN)
+ # Remove any remaining disabled or ignored entries
+ for _entry in hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
diff --git a/homeassistant/components/mcp/__init__.py b/homeassistant/components/mcp/__init__.py
index 4a2b4da990d..a2a148dffd5 100644
--- a/homeassistant/components/mcp/__init__.py
+++ b/homeassistant/components/mcp/__init__.py
@@ -3,12 +3,15 @@
from __future__ import annotations
from dataclasses import dataclass
+from typing import cast
+from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import llm
+from homeassistant.helpers import config_entry_oauth2_flow, llm
-from .const import DOMAIN
-from .coordinator import ModelContextProtocolCoordinator
+from .application_credentials import authorization_server_context
+from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
+from .coordinator import ModelContextProtocolCoordinator, TokenManager
from .types import ModelContextProtocolConfigEntry
__all__ = [
@@ -20,11 +23,45 @@ __all__ = [
API_PROMPT = "The following tools are available from a remote server named {name}."
+async def async_get_config_entry_implementation(
+ hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
+) -> config_entry_oauth2_flow.AbstractOAuth2Implementation | None:
+ """OAuth implementation for the config entry."""
+ if "auth_implementation" not in entry.data:
+ return None
+ with authorization_server_context(
+ AuthorizationServer(
+ authorize_url=entry.data[CONF_AUTHORIZATION_URL],
+ token_url=entry.data[CONF_TOKEN_URL],
+ )
+ ):
+ return await config_entry_oauth2_flow.async_get_config_entry_implementation(
+ hass, entry
+ )
+
+
+async def _create_token_manager(
+ hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
+) -> TokenManager | None:
+ """Create a OAuth token manager for the config entry if the server requires authentication."""
+ if not (implementation := await async_get_config_entry_implementation(hass, entry)):
+ return None
+
+ session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
+
+ async def token_manager() -> str:
+ await session.async_ensure_token_valid()
+ return cast(str, session.token[CONF_ACCESS_TOKEN])
+
+ return token_manager
+
+
async def async_setup_entry(
hass: HomeAssistant, entry: ModelContextProtocolConfigEntry
) -> bool:
"""Set up Model Context Protocol from a config entry."""
- coordinator = ModelContextProtocolCoordinator(hass, entry)
+ token_manager = await _create_token_manager(hass, entry)
+ coordinator = ModelContextProtocolCoordinator(hass, entry, token_manager)
await coordinator.async_config_entry_first_refresh()
unsub = llm.async_register_api(
@@ -39,7 +76,6 @@ async def async_setup_entry(
entry.async_on_unload(unsub)
entry.runtime_data = coordinator
- entry.async_on_unload(coordinator.close)
return True
diff --git a/homeassistant/components/mcp/application_credentials.py b/homeassistant/components/mcp/application_credentials.py
new file mode 100644
index 00000000000..9b8bed894e4
--- /dev/null
+++ b/homeassistant/components/mcp/application_credentials.py
@@ -0,0 +1,35 @@
+"""Application credentials platform for Model Context Protocol."""
+
+from __future__ import annotations
+
+from collections.abc import Generator
+from contextlib import contextmanager
+import contextvars
+
+from homeassistant.components.application_credentials import AuthorizationServer
+from homeassistant.core import HomeAssistant
+
+CONF_ACTIVE_AUTHORIZATION_SERVER = "active_authorization_server"
+
+_mcp_context: contextvars.ContextVar[AuthorizationServer] = contextvars.ContextVar(
+ "mcp_authorization_server_context"
+)
+
+
+@contextmanager
+def authorization_server_context(
+ authorization_server: AuthorizationServer,
+) -> Generator[None]:
+ """Context manager for setting the active authorization server."""
+ token = _mcp_context.set(authorization_server)
+ try:
+ yield
+ finally:
+ _mcp_context.reset(token)
+
+
+async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
+ """Return authorization server, for the default auth implementation."""
+ if _mcp_context.get() is None:
+ raise RuntimeError("No MCP authorization server set in context")
+ return _mcp_context.get()
diff --git a/homeassistant/components/mcp/config_flow.py b/homeassistant/components/mcp/config_flow.py
index 92e0052c665..0f34962f7ee 100644
--- a/homeassistant/components/mcp/config_flow.py
+++ b/homeassistant/components/mcp/config_flow.py
@@ -2,20 +2,29 @@
from __future__ import annotations
+from collections.abc import Mapping
import logging
-from typing import Any
+from typing import Any, cast
import httpx
import voluptuous as vol
+from yarl import URL
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_URL
+from homeassistant.components.application_credentials import AuthorizationServer
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
+from homeassistant.const import CONF_TOKEN, CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.config_entry_oauth2_flow import (
+ AbstractOAuth2FlowHandler,
+ async_get_implementations,
+)
-from .const import DOMAIN
-from .coordinator import mcp_client
+from . import async_get_config_entry_implementation
+from .application_credentials import authorization_server_context
+from .const import CONF_ACCESS_TOKEN, CONF_AUTHORIZATION_URL, CONF_TOKEN_URL, DOMAIN
+from .coordinator import TokenManager, mcp_client
_LOGGER = logging.getLogger(__name__)
@@ -25,8 +34,62 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
+# OAuth server discovery endpoint for rfc8414
+OAUTH_DISCOVERY_ENDPOINT = ".well-known/oauth-authorization-server"
+MCP_DISCOVERY_HEADERS = {
+ "MCP-Protocol-Version": "2025-03-26",
+}
-async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
+
+async def async_discover_oauth_config(
+ hass: HomeAssistant, mcp_server_url: str
+) -> AuthorizationServer:
+ """Discover the OAuth configuration for the MCP server.
+
+ This implements the functionality in the MCP spec for discovery. If the MCP server URL
+ is https://api.example.com/v1/mcp, then:
+ - The authorization base URL is https://api.example.com
+ - The metadata endpoint MUST be at https://api.example.com/.well-known/oauth-authorization-server
+ - For servers that do not implement OAuth 2.0 Authorization Server Metadata, the client uses
+ default paths relative to the authorization base URL.
+ """
+ parsed_url = URL(mcp_server_url)
+ discovery_endpoint = str(parsed_url.with_path(OAUTH_DISCOVERY_ENDPOINT))
+ try:
+ async with httpx.AsyncClient(headers=MCP_DISCOVERY_HEADERS) as client:
+ response = await client.get(discovery_endpoint)
+ response.raise_for_status()
+ except httpx.TimeoutException as error:
+ _LOGGER.info("Timeout connecting to MCP server: %s", error)
+ raise TimeoutConnectError from error
+ except httpx.HTTPStatusError as error:
+ if error.response.status_code == 404:
+ _LOGGER.info("Authorization Server Metadata not found, using default paths")
+ return AuthorizationServer(
+ authorize_url=str(parsed_url.with_path("/authorize")),
+ token_url=str(parsed_url.with_path("/token")),
+ )
+ raise CannotConnect from error
+ except httpx.HTTPError as error:
+ _LOGGER.info("Cannot discover OAuth configuration: %s", error)
+ raise CannotConnect from error
+
+ data = response.json()
+ authorize_url = data["authorization_endpoint"]
+ token_url = data["token_endpoint"]
+ if authorize_url.startswith("/"):
+ authorize_url = str(parsed_url.with_path(authorize_url))
+ if token_url.startswith("/"):
+ token_url = str(parsed_url.with_path(token_url))
+ return AuthorizationServer(
+ authorize_url=authorize_url,
+ token_url=token_url,
+ )
+
+
+async def validate_input(
+ hass: HomeAssistant, data: dict[str, Any], token_manager: TokenManager | None = None
+) -> dict[str, Any]:
"""Validate the user input and connect to the MCP server."""
url = data[CONF_URL]
try:
@@ -34,7 +97,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
except vol.Invalid as error:
raise InvalidUrl from error
try:
- async with mcp_client(url) as session:
+ async with mcp_client(url, token_manager=token_manager) as session:
response = await session.initialize()
except httpx.TimeoutException as error:
_LOGGER.info("Timeout connecting to MCP server: %s", error)
@@ -56,10 +119,17 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
return {"title": response.serverInfo.name}
-class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
+class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a config flow for Model Context Protocol."""
VERSION = 1
+ DOMAIN = DOMAIN
+ logger = _LOGGER
+
+ def __init__(self) -> None:
+ """Initialize the config flow."""
+ super().__init__()
+ self.data: dict[str, Any] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -76,7 +146,8 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
- return self.async_abort(reason="invalid_auth")
+ self.data[CONF_URL] = user_input[CONF_URL]
+ return await self.async_step_auth_discovery()
except MissingCapabilities:
return self.async_abort(reason="missing_capabilities")
except Exception:
@@ -90,6 +161,130 @@ class ModelContextProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)
+ async def async_step_auth_discovery(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the OAuth server discovery step.
+
+ Since this OAuth server requires authentication, this step will attempt
+ to find the OAuth medata then run the OAuth authentication flow.
+ """
+ try:
+ authorization_server = await async_discover_oauth_config(
+ self.hass, self.data[CONF_URL]
+ )
+ except TimeoutConnectError:
+ return self.async_abort(reason="timeout_connect")
+ except CannotConnect:
+ return self.async_abort(reason="cannot_connect")
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ return self.async_abort(reason="unknown")
+ else:
+ _LOGGER.info("OAuth configuration: %s", authorization_server)
+ self.data.update(
+ {
+ CONF_AUTHORIZATION_URL: authorization_server.authorize_url,
+ CONF_TOKEN_URL: authorization_server.token_url,
+ }
+ )
+ return await self.async_step_credentials_choice()
+
+ def authorization_server(self) -> AuthorizationServer:
+ """Return the authorization server provided by the MCP server."""
+ return AuthorizationServer(
+ self.data[CONF_AUTHORIZATION_URL],
+ self.data[CONF_TOKEN_URL],
+ )
+
+ async def async_step_credentials_choice(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Step to ask they user if they would like to add credentials.
+
+ This is needed since we can't automatically assume existing credentials
+ should be used given they may be for another existing server.
+ """
+ with authorization_server_context(self.authorization_server()):
+ if not await async_get_implementations(self.hass, self.DOMAIN):
+ return await self.async_step_new_credentials()
+ return self.async_show_menu(
+ step_id="credentials_choice",
+ menu_options=["pick_implementation", "new_credentials"],
+ )
+
+ async def async_step_new_credentials(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Step to take the frontend flow to enter new credentials."""
+ return self.async_abort(reason="missing_credentials")
+
+ async def async_step_pick_implementation(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the pick implementation step.
+
+ This exists to dynamically set application credentials Authorization Server
+ based on the values form the OAuth discovery step.
+ """
+ with authorization_server_context(self.authorization_server()):
+ return await super().async_step_pick_implementation(user_input)
+
+ async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
+ """Create an entry for the flow.
+
+ Ok to override if you want to fetch extra info or even add another step.
+ """
+ config_entry_data = {
+ **self.data,
+ **data,
+ }
+
+ async def token_manager() -> str:
+ return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
+
+ try:
+ info = await validate_input(self.hass, config_entry_data, token_manager)
+ except TimeoutConnectError:
+ return self.async_abort(reason="timeout_connect")
+ except CannotConnect:
+ return self.async_abort(reason="cannot_connect")
+ except MissingCapabilities:
+ return self.async_abort(reason="missing_capabilities")
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ return self.async_abort(reason="unknown")
+
+ # Unique id based on the application credentials OAuth Client ID
+ if self.source == SOURCE_REAUTH:
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(), data=config_entry_data
+ )
+ await self.async_set_unique_id(config_entry_data["auth_implementation"])
+ return self.async_create_entry(
+ title=info["title"],
+ data=config_entry_data,
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth upon an API authentication error."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: Mapping[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm reauth dialog."""
+ if user_input is None:
+ return self.async_show_form(step_id="reauth_confirm")
+ config_entry = self._get_reauth_entry()
+ self.data = {**config_entry.data}
+ self.flow_impl = await async_get_config_entry_implementation( # type: ignore[assignment]
+ self.hass, config_entry
+ )
+ return await self.async_step_auth()
+
class InvalidUrl(HomeAssistantError):
"""Error to indicate the URL format is invalid."""
diff --git a/homeassistant/components/mcp/const.py b/homeassistant/components/mcp/const.py
index 675b2d7031c..13f63b02c73 100644
--- a/homeassistant/components/mcp/const.py
+++ b/homeassistant/components/mcp/const.py
@@ -1,3 +1,7 @@
"""Constants for the Model Context Protocol integration."""
DOMAIN = "mcp"
+
+CONF_ACCESS_TOKEN = "access_token"
+CONF_AUTHORIZATION_URL = "authorization_url"
+CONF_TOKEN_URL = "token_url"
diff --git a/homeassistant/components/mcp/coordinator.py b/homeassistant/components/mcp/coordinator.py
index a5c5ee55dbf..f560875292f 100644
--- a/homeassistant/components/mcp/coordinator.py
+++ b/homeassistant/components/mcp/coordinator.py
@@ -1,7 +1,7 @@
"""Types for the Model Context Protocol integration."""
import asyncio
-from collections.abc import AsyncGenerator
+from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
import datetime
import logging
@@ -15,7 +15,7 @@ from voluptuous_openapi import convert_to_voluptuous
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers import llm
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util.json import JsonObjectType
@@ -27,19 +27,32 @@ _LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = datetime.timedelta(minutes=30)
TIMEOUT = 10
+TokenManager = Callable[[], Awaitable[str]]
+
@asynccontextmanager
-async def mcp_client(url: str) -> AsyncGenerator[ClientSession]:
+async def mcp_client(
+ url: str,
+ token_manager: TokenManager | None = None,
+) -> AsyncGenerator[ClientSession]:
"""Create a server-sent event MCP client.
This is an asynccontext manager that exists to wrap other async context managers
so that the coordinator has a single object to manage.
"""
+ headers: dict[str, str] = {}
+ if token_manager is not None:
+ token = await token_manager()
+ headers["Authorization"] = f"Bearer {token}"
try:
- async with sse_client(url=url) as streams, ClientSession(*streams) as session:
+ async with (
+ sse_client(url=url, headers=headers) as streams,
+ ClientSession(*streams) as session,
+ ):
await session.initialize()
yield session
except ExceptionGroup as err:
+ _LOGGER.debug("Error creating MCP client: %s", err)
raise err.exceptions[0] from err
@@ -51,13 +64,15 @@ class ModelContextProtocolTool(llm.Tool):
name: str,
description: str | None,
parameters: vol.Schema,
- session: ClientSession,
+ server_url: str,
+ token_manager: TokenManager | None = None,
) -> None:
"""Initialize the tool."""
self.name = name
self.description = description
self.parameters = parameters
- self.session = session
+ self.server_url = server_url
+ self.token_manager = token_manager
async def async_call(
self,
@@ -67,10 +82,16 @@ class ModelContextProtocolTool(llm.Tool):
) -> JsonObjectType:
"""Call the tool."""
try:
- result = await self.session.call_tool(
- tool_input.tool_name, tool_input.tool_args
- )
+ async with asyncio.timeout(TIMEOUT):
+ async with mcp_client(self.server_url, self.token_manager) as session:
+ result = await session.call_tool(
+ tool_input.tool_name, tool_input.tool_args
+ )
+ except TimeoutError as error:
+ _LOGGER.debug("Timeout when calling tool: %s", error)
+ raise HomeAssistantError(f"Timeout when calling tool: {error}") from error
except httpx.HTTPStatusError as error:
+ _LOGGER.debug("Error when calling tool: %s", error)
raise HomeAssistantError(f"Error when calling tool: {error}") from error
return result.model_dump(exclude_unset=True, exclude_none=True)
@@ -79,10 +100,13 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
"""Define an object to hold MCP data."""
config_entry: ConfigEntry
- _session: ClientSession | None = None
- _setup_error: Exception | None = None
- def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ token_manager: TokenManager | None = None,
+ ) -> None:
"""Initialize ModelContextProtocolCoordinator."""
super().__init__(
hass,
@@ -91,52 +115,7 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
config_entry=config_entry,
update_interval=UPDATE_INTERVAL,
)
- self._stop = asyncio.Event()
-
- async def _async_setup(self) -> None:
- """Set up the client connection."""
- connected = asyncio.Event()
- stop = asyncio.Event()
- self.config_entry.async_create_background_task(
- self.hass, self._connect(connected, stop), "mcp-client"
- )
- try:
- async with asyncio.timeout(TIMEOUT):
- await connected.wait()
- self._stop = stop
- finally:
- if self._setup_error is not None:
- raise self._setup_error
-
- async def _connect(self, connected: asyncio.Event, stop: asyncio.Event) -> None:
- """Create a server-sent event MCP client."""
- url = self.config_entry.data[CONF_URL]
- try:
- async with (
- sse_client(url=url) as streams,
- ClientSession(*streams) as session,
- ):
- await session.initialize()
- self._session = session
- connected.set()
- await stop.wait()
- except httpx.HTTPStatusError as err:
- self._setup_error = err
- _LOGGER.debug("Error connecting to MCP server: %s", err)
- raise UpdateFailed(f"Error connecting to MCP server: {err}") from err
- except ExceptionGroup as err:
- self._setup_error = err.exceptions[0]
- _LOGGER.debug("Error connecting to MCP server: %s", err)
- raise UpdateFailed(
- "Error connecting to MCP server: {err.exceptions[0]}"
- ) from err.exceptions[0]
- finally:
- self._session = None
-
- async def close(self) -> None:
- """Close the client connection."""
- if self._stop is not None:
- self._stop.set()
+ self.token_manager = token_manager
async def _async_update_data(self) -> list[llm.Tool]:
"""Fetch data from API endpoint.
@@ -144,11 +123,24 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
This is the place to pre-process the data to lookup tables
so entities can quickly look up their data.
"""
- if self._session is None:
- raise UpdateFailed("No session available")
try:
- result = await self._session.list_tools()
+ async with asyncio.timeout(TIMEOUT):
+ async with mcp_client(
+ self.config_entry.data[CONF_URL], self.token_manager
+ ) as session:
+ result = await session.list_tools()
+ except TimeoutError as error:
+ _LOGGER.debug("Timeout when listing tools: %s", error)
+ raise UpdateFailed(f"Timeout when listing tools: {error}") from error
+ except httpx.HTTPStatusError as error:
+ _LOGGER.debug("Error communicating with API: %s", error)
+ if error.response.status_code == 401 and self.token_manager is not None:
+ raise ConfigEntryAuthFailed(
+ "The MCP server requires authentication"
+ ) from error
+ raise UpdateFailed(f"Error communicating with API: {error}") from error
except httpx.HTTPError as err:
+ _LOGGER.debug("Error communicating with API: %s", err)
raise UpdateFailed(f"Error communicating with API: {err}") from err
_LOGGER.debug("Received tools: %s", result.tools)
@@ -165,7 +157,8 @@ class ModelContextProtocolCoordinator(DataUpdateCoordinator[list[llm.Tool]]):
tool.name,
tool.description,
parameters,
- self._session,
+ self.config_entry.data[CONF_URL],
+ self.token_manager,
)
)
return tools
diff --git a/homeassistant/components/mcp/manifest.json b/homeassistant/components/mcp/manifest.json
index ee4baf04802..7ff64d29aa4 100644
--- a/homeassistant/components/mcp/manifest.json
+++ b/homeassistant/components/mcp/manifest.json
@@ -3,8 +3,9 @@
"name": "Model Context Protocol",
"codeowners": ["@allenporter"],
"config_flow": true,
+ "dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/mcp",
"iot_class": "local_polling",
"quality_scale": "silver",
- "requirements": ["mcp==1.1.2"]
+ "requirements": ["mcp==1.5.0"]
}
diff --git a/homeassistant/components/mcp/quality_scale.yaml b/homeassistant/components/mcp/quality_scale.yaml
index 76afdf5860d..f22343c8d0e 100644
--- a/homeassistant/components/mcp/quality_scale.yaml
+++ b/homeassistant/components/mcp/quality_scale.yaml
@@ -44,9 +44,7 @@ rules:
parallel-updates:
status: exempt
comment: Integration does not have platforms.
- reauthentication-flow:
- status: exempt
- comment: Integration does not support authentication.
+ reauthentication-flow: done
test-coverage: done
# Gold
diff --git a/homeassistant/components/mcp/strings.json b/homeassistant/components/mcp/strings.json
index 97a75fc6f85..2b59d4ffa51 100644
--- a/homeassistant/components/mcp/strings.json
+++ b/homeassistant/components/mcp/strings.json
@@ -8,6 +8,15 @@
"data_description": {
"url": "The remote MCP server URL for the SSE endpoint, for example http://example/sse"
}
+ },
+ "pick_implementation": {
+ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]",
+ "data": {
+ "implementation": "Credentials"
+ },
+ "data_description": {
+ "implementation": "The credentials to use for the OAuth2 flow"
+ }
}
},
"error": {
@@ -17,9 +26,15 @@
"invalid_url": "Must be a valid MCP server URL e.g. https://example.com/sse"
},
"abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"missing_capabilities": "The MCP server does not support a required capability (Tools)",
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ "missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
+ "reauth_account_mismatch": "The authenticated user does not match the MCP Server user that needed re-authentication.",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
}
}
}
diff --git a/homeassistant/components/mcp_server/__init__.py b/homeassistant/components/mcp_server/__init__.py
index 941eccbe528..e523f46228f 100644
--- a/homeassistant/components/mcp_server/__init__.py
+++ b/homeassistant/components/mcp_server/__init__.py
@@ -6,7 +6,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
-from . import http, llm_api
+from . import http
from .const import DOMAIN
from .session import SessionManager
from .types import MCPServerConfigEntry
@@ -25,7 +25,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Model Context Protocol component."""
http.async_register(hass)
- llm_api.async_register_api(hass)
return True
diff --git a/homeassistant/components/mcp_server/config_flow.py b/homeassistant/components/mcp_server/config_flow.py
index 8d8d311b874..e8df68de5e2 100644
--- a/homeassistant/components/mcp_server/config_flow.py
+++ b/homeassistant/components/mcp_server/config_flow.py
@@ -16,7 +16,7 @@ from homeassistant.helpers.selector import (
SelectSelectorConfig,
)
-from .const import DOMAIN, LLM_API, LLM_API_NAME
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -33,13 +33,6 @@ class ModelContextServerProtocolConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle the initial step."""
llm_apis = {api.id: api.name for api in llm.async_get_apis(self.hass)}
- if LLM_API not in llm_apis:
- # MCP server component is not loaded yet, so make the LLM API a choice.
- llm_apis = {
- LLM_API: LLM_API_NAME,
- **llm_apis,
- }
-
if user_input is not None:
return self.async_create_entry(
title=llm_apis[user_input[CONF_LLM_HASS_API]], data=user_input
diff --git a/homeassistant/components/mcp_server/const.py b/homeassistant/components/mcp_server/const.py
index 8958ac36616..3f2e12cbb6a 100644
--- a/homeassistant/components/mcp_server/const.py
+++ b/homeassistant/components/mcp_server/const.py
@@ -2,5 +2,6 @@
DOMAIN = "mcp_server"
TITLE = "Model Context Protocol Server"
-LLM_API = "stateless_assist"
-LLM_API_NAME = "Stateless Assist"
+# The Stateless API is no longer registered explicitly, but this name may still exist in the
+# users config entry.
+STATELESS_LLM_API = "stateless_assist"
diff --git a/homeassistant/components/mcp_server/http.py b/homeassistant/components/mcp_server/http.py
index 433d978cef7..bc8fdbd56c8 100644
--- a/homeassistant/components/mcp_server/http.py
+++ b/homeassistant/components/mcp_server/http.py
@@ -25,7 +25,6 @@ from mcp import types
from homeassistant.components import conversation
from homeassistant.components.http import KEY_HASS, HomeAssistantView
-from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import llm
@@ -56,11 +55,9 @@ def async_get_config_entry(hass: HomeAssistant) -> MCPServerConfigEntry:
Will raise an HTTP error if the expected configuration is not present.
"""
- config_entries: list[MCPServerConfigEntry] = [
- config_entry
- for config_entry in hass.config_entries.async_entries(DOMAIN)
- if config_entry.state == ConfigEntryState.LOADED
- ]
+ config_entries: list[MCPServerConfigEntry] = (
+ hass.config_entries.async_loaded_entries(DOMAIN)
+ )
if not config_entries:
raise HTTPNotFound(text="Model Context Protocol server is not configured")
if len(config_entries) > 1:
diff --git a/homeassistant/components/mcp_server/llm_api.py b/homeassistant/components/mcp_server/llm_api.py
deleted file mode 100644
index 5c29b29153e..00000000000
--- a/homeassistant/components/mcp_server/llm_api.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""LLM API for MCP Server."""
-
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import llm
-from homeassistant.util import yaml as yaml_util
-
-from .const import LLM_API, LLM_API_NAME
-
-EXPOSED_ENTITY_FIELDS = {"name", "domain", "description", "areas", "names"}
-
-
-def async_register_api(hass: HomeAssistant) -> None:
- """Register the LLM API."""
- llm.async_register_api(hass, StatelessAssistAPI(hass))
-
-
-class StatelessAssistAPI(llm.AssistAPI):
- """LLM API for MCP Server that provides the Assist API without state information in the prompt.
-
- Syncing the state information is possible, but may put unnecessary load on
- the system so we are instead providing the prompt without entity state. Since
- actions don't care about the current state, there is little quality loss.
- """
-
- def __init__(self, hass: HomeAssistant) -> None:
- """Initialize the StatelessAssistAPI."""
- super().__init__(hass)
- self.id = LLM_API
- self.name = LLM_API_NAME
-
- @callback
- def _async_get_exposed_entities_prompt(
- self, llm_context: llm.LLMContext, exposed_entities: dict | None
- ) -> list[str]:
- """Return the prompt for the exposed entities."""
- prompt = []
-
- if exposed_entities and exposed_entities["entities"]:
- prompt.append(
- "An overview of the areas and the devices in this smart home:"
- )
- entities = [
- {k: v for k, v in entity_info.items() if k in EXPOSED_ENTITY_FIELDS}
- for entity_info in exposed_entities["entities"].values()
- ]
- prompt.append(yaml_util.dump(list(entities)))
-
- return prompt
diff --git a/homeassistant/components/mcp_server/manifest.json b/homeassistant/components/mcp_server/manifest.json
index 18b2e5bc417..b5fb1bdcd87 100644
--- a/homeassistant/components/mcp_server/manifest.json
+++ b/homeassistant/components/mcp_server/manifest.json
@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "local_push",
"quality_scale": "silver",
- "requirements": ["mcp==1.1.2", "aiohttp_sse==2.2.0", "anyio==4.8.0"],
+ "requirements": ["mcp==1.5.0", "aiohttp_sse==2.2.0", "anyio==4.9.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/mcp_server/server.py b/homeassistant/components/mcp_server/server.py
index ba21abd722c..affa4faecd6 100644
--- a/homeassistant/components/mcp_server/server.py
+++ b/homeassistant/components/mcp_server/server.py
@@ -21,6 +21,8 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import llm
+from .const import STATELESS_LLM_API
+
_LOGGER = logging.getLogger(__name__)
@@ -47,16 +49,23 @@ async def create_server(
A Model Context Protocol Server object is associated with a single session.
The MCP SDK handles the details of the protocol.
"""
+ if llm_api_id == STATELESS_LLM_API:
+ llm_api_id = llm.LLM_API_ASSIST
- server = Server("home-assistant")
+ server = Server[Any]("home-assistant")
+
+ async def get_api_instance() -> llm.APIInstance:
+ """Get the LLM API selected."""
+ # Backwards compatibility with old MCP Server config
+ return await llm.async_get_api(hass, llm_api_id, llm_context)
@server.list_prompts() # type: ignore[no-untyped-call, misc]
async def handle_list_prompts() -> list[types.Prompt]:
- llm_api = await llm.async_get_api(hass, llm_api_id, llm_context)
+ llm_api = await get_api_instance()
return [
types.Prompt(
name=llm_api.api.name,
- description=f"Default prompt for the Home Assistant LLM API {llm_api.api.name}",
+ description=f"Default prompt for Home Assistant {llm_api.api.name} API",
)
]
@@ -64,12 +73,12 @@ async def create_server(
async def handle_get_prompt(
name: str, arguments: dict[str, str] | None
) -> types.GetPromptResult:
- llm_api = await llm.async_get_api(hass, llm_api_id, llm_context)
+ llm_api = await get_api_instance()
if name != llm_api.api.name:
raise ValueError(f"Unknown prompt: {name}")
return types.GetPromptResult(
- description=f"Default prompt for the Home Assistant LLM API {llm_api.api.name}",
+ description=f"Default prompt for Home Assistant {llm_api.api.name} API",
messages=[
types.PromptMessage(
role="assistant",
@@ -84,13 +93,13 @@ async def create_server(
@server.list_tools() # type: ignore[no-untyped-call, misc]
async def list_tools() -> list[types.Tool]:
"""List available time tools."""
- llm_api = await llm.async_get_api(hass, llm_api_id, llm_context)
+ llm_api = await get_api_instance()
return [_format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools]
@server.call_tool() # type: ignore[no-untyped-call, misc]
async def call_tool(name: str, arguments: dict) -> Sequence[types.TextContent]:
"""Handle calling tools."""
- llm_api = await llm.async_get_api(hass, llm_api_id, llm_context)
+ llm_api = await get_api_instance()
tool_input = llm.ToolInput(tool_name=name, tool_args=arguments)
_LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args)
diff --git a/homeassistant/components/mcp_server/strings.json b/homeassistant/components/mcp_server/strings.json
index fbd14038ddc..57f1baf183c 100644
--- a/homeassistant/components/mcp_server/strings.json
+++ b/homeassistant/components/mcp_server/strings.json
@@ -7,7 +7,7 @@
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]"
},
"data_description": {
- "llm_hass_api": "The method for controling Home Assistant to expose with the Model Context Protocol."
+ "llm_hass_api": "The method for controlling Home Assistant to expose with the Model Context Protocol."
}
}
},
diff --git a/homeassistant/components/mealie/calendar.py b/homeassistant/components/mealie/calendar.py
index 729bc16c6fd..556ddede2e2 100644
--- a/homeassistant/components/mealie/calendar.py
+++ b/homeassistant/components/mealie/calendar.py
@@ -8,7 +8,7 @@ from aiomealie import Mealplan, MealplanEntryType
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MealieConfigEntry, MealieMealplanCoordinator
from .entity import MealieEntity
@@ -19,7 +19,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: MealieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the calendar platform for entity."""
coordinator = entry.runtime_data.mealplan_coordinator
diff --git a/homeassistant/components/mealie/sensor.py b/homeassistant/components/mealie/sensor.py
index e4b1655a9d1..062a2646cab 100644
--- a/homeassistant/components/mealie/sensor.py
+++ b/homeassistant/components/mealie/sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import MealieConfigEntry, MealieStatisticsCoordinator
@@ -59,7 +59,7 @@ SENSOR_TYPES: tuple[MealieStatisticsSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: MealieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Mealie sensors based on a config entry."""
coordinator = entry.runtime_data.statistics_coordinator
diff --git a/homeassistant/components/mealie/strings.json b/homeassistant/components/mealie/strings.json
index fa63252e837..186fc4c4ac0 100644
--- a/homeassistant/components/mealie/strings.json
+++ b/homeassistant/components/mealie/strings.json
@@ -146,11 +146,11 @@
"services": {
"get_mealplan": {
"name": "Get mealplan",
- "description": "Get mealplan from Mealie",
+ "description": "Gets a mealplan from Mealie",
"fields": {
"config_entry_id": {
"name": "Mealie instance",
- "description": "Select the Mealie instance to get mealplan from"
+ "description": "The Mealie instance to use for this action."
},
"start_date": {
"name": "Start date",
@@ -164,7 +164,7 @@
},
"get_recipe": {
"name": "Get recipe",
- "description": "Get recipe from Mealie",
+ "description": "Gets a recipe from Mealie",
"fields": {
"config_entry_id": {
"name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]",
@@ -178,7 +178,7 @@
},
"import_recipe": {
"name": "Import recipe",
- "description": "Import recipe from an URL",
+ "description": "Imports a recipe from an URL",
"fields": {
"config_entry_id": {
"name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]",
@@ -196,7 +196,7 @@
},
"set_random_mealplan": {
"name": "Set random mealplan",
- "description": "Set a random mealplan for a specific date",
+ "description": "Sets a random mealplan for a specific date",
"fields": {
"config_entry_id": {
"name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]",
@@ -214,7 +214,7 @@
},
"set_mealplan": {
"name": "Set a mealplan",
- "description": "Set a mealplan for a specific date",
+ "description": "Sets a mealplan for a specific date",
"fields": {
"config_entry_id": {
"name": "[%key:component::mealie::services::get_mealplan::fields::config_entry_id::name%]",
diff --git a/homeassistant/components/mealie/todo.py b/homeassistant/components/mealie/todo.py
index be04b00113e..d42c9033922 100644
--- a/homeassistant/components/mealie/todo.py
+++ b/homeassistant/components/mealie/todo.py
@@ -14,7 +14,7 @@ from homeassistant.components.todo import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MealieConfigEntry, MealieShoppingListCoordinator
@@ -46,7 +46,7 @@ def _convert_api_item(item: ShoppingItem) -> TodoItem:
async def async_setup_entry(
hass: HomeAssistant,
entry: MealieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the todo platform for entity."""
coordinator = entry.runtime_data.shoppinglist_coordinator
diff --git a/homeassistant/components/meater/config_flow.py b/homeassistant/components/meater/config_flow.py
index a7ba3ba1498..5c11b10755c 100644
--- a/homeassistant/components/meater/config_flow.py
+++ b/homeassistant/components/meater/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
+import logging
from typing import Any
from meater import AuthenticationError, MeaterApi, ServiceUnavailableError
@@ -14,6 +15,8 @@ from homeassistant.helpers import aiohttp_client
from .const import DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
REAUTH_SCHEMA = vol.Schema({vol.Required(CONF_PASSWORD): str})
USER_SCHEMA = vol.Schema(
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
@@ -84,7 +87,8 @@ class MeaterConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except ServiceUnavailableError:
errors["base"] = "service_unavailable_error"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown_auth_error"
else:
data = {"username": username, "password": password}
diff --git a/homeassistant/components/meater/sensor.py b/homeassistant/components/meater/sensor.py
index 2a26d848ac2..00fc28b8718 100644
--- a/homeassistant/components/meater/sensor.py
+++ b/homeassistant/components/meater/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -136,7 +136,9 @@ SENSOR_TYPES = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the entry."""
coordinator: DataUpdateCoordinator[dict[str, MeaterProbe]] = hass.data[DOMAIN][
diff --git a/homeassistant/components/medcom_ble/sensor.py b/homeassistant/components/medcom_ble/sensor.py
index b5cb29be845..f837620c829 100644
--- a/homeassistant/components/medcom_ble/sensor.py
+++ b/homeassistant/components/medcom_ble/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -37,7 +37,7 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Medcom BLE radiation monitor sensors."""
diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json
index f0f8ee03ad0..e049a827c75 100644
--- a/homeassistant/components/media_extractor/manifest.json
+++ b/homeassistant/components/media_extractor/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "calculated",
"loggers": ["yt_dlp"],
"quality_scale": "internal",
- "requirements": ["yt-dlp[default]==2025.01.26"],
+ "requirements": ["yt-dlp[default]==2025.03.26"],
"single_config_entry": true
}
diff --git a/homeassistant/components/media_extractor/strings.json b/homeassistant/components/media_extractor/strings.json
index 125aa08337a..11b5a884e4d 100644
--- a/homeassistant/components/media_extractor/strings.json
+++ b/homeassistant/components/media_extractor/strings.json
@@ -17,12 +17,12 @@
},
"media_content_type": {
"name": "Media content type",
- "description": "The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST MUSIC."
+ "description": "The type of the content to play."
}
}
},
"extract_media_url": {
- "name": "Get Media URL",
+ "name": "Get media URL",
"description": "Extract media URL from a service.",
"fields": {
"url": {
diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py
index e109b0418c9..0979852ecce 100644
--- a/homeassistant/components/media_player/__init__.py
+++ b/homeassistant/components/media_player/__init__.py
@@ -52,7 +52,7 @@ from homeassistant.const import ( # noqa: F401
STATE_PLAYING,
STATE_STANDBY,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.deprecation import (
@@ -68,7 +68,12 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util.hass_dict import HassKey
-from .browse_media import BrowseMedia, async_process_play_media_url # noqa: F401
+from .browse_media import ( # noqa: F401
+ BrowseMedia,
+ SearchMedia,
+ SearchMediaQuery,
+ async_process_play_media_url,
+)
from .const import ( # noqa: F401
_DEPRECATED_MEDIA_CLASS_DIRECTORY,
_DEPRECATED_SUPPORT_BROWSE_MEDIA,
@@ -107,10 +112,12 @@ from .const import ( # noqa: F401
ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_EPISODE,
ATTR_MEDIA_EXTRA,
+ ATTR_MEDIA_FILTER_CLASSES,
ATTR_MEDIA_PLAYLIST,
ATTR_MEDIA_POSITION,
ATTR_MEDIA_POSITION_UPDATED_AT,
ATTR_MEDIA_REPEAT,
+ ATTR_MEDIA_SEARCH_QUERY,
ATTR_MEDIA_SEASON,
ATTR_MEDIA_SEEK_POSITION,
ATTR_MEDIA_SERIES_TITLE,
@@ -124,9 +131,11 @@ from .const import ( # noqa: F401
CONTENT_AUTH_EXPIRY_TIME,
DOMAIN,
REPEAT_MODES,
+ SERVICE_BROWSE_MEDIA,
SERVICE_CLEAR_PLAYLIST,
SERVICE_JOIN,
SERVICE_PLAY_MEDIA,
+ SERVICE_SEARCH_MEDIA,
SERVICE_SELECT_SOUND_MODE,
SERVICE_SELECT_SOURCE,
SERVICE_UNJOIN,
@@ -136,7 +145,7 @@ from .const import ( # noqa: F401
MediaType,
RepeatMode,
)
-from .errors import BrowseError
+from .errors import BrowseError, SearchError
_LOGGER = logging.getLogger(__name__)
@@ -201,6 +210,12 @@ MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = {
vol.Optional(ATTR_MEDIA_EXTRA, default={}): dict,
}
+MEDIA_PLAYER_BROWSE_MEDIA_SCHEMA = {
+ vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string,
+ vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string,
+}
+
+
ATTR_TO_PROPERTY = [
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
@@ -284,6 +299,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
websocket_api.async_register_command(hass, websocket_browse_media)
+ websocket_api.async_register_command(hass, websocket_search_media)
hass.http.register_view(MediaPlayerImageView(component))
await component.async_setup(config)
@@ -431,6 +447,31 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"async_play_media",
[MediaPlayerEntityFeature.PLAY_MEDIA],
)
+ component.async_register_entity_service(
+ SERVICE_BROWSE_MEDIA,
+ {
+ vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string,
+ vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string,
+ },
+ "async_browse_media",
+ supports_response=SupportsResponse.ONLY,
+ )
+ component.async_register_entity_service(
+ SERVICE_SEARCH_MEDIA,
+ {
+ vol.Optional(ATTR_MEDIA_CONTENT_TYPE): cv.string,
+ vol.Optional(ATTR_MEDIA_CONTENT_ID): cv.string,
+ vol.Required(ATTR_MEDIA_SEARCH_QUERY): cv.string,
+ vol.Optional(ATTR_MEDIA_FILTER_CLASSES): vol.All(
+ cv.ensure_list,
+ [vol.In([m.value for m in MediaClass])],
+ lambda x: {MediaClass(item) for item in x},
+ ),
+ },
+ "async_internal_search_media",
+ [MediaPlayerEntityFeature.SEARCH_MEDIA],
+ SupportsResponse.ONLY,
+ )
component.async_register_entity_service(
SERVICE_SHUFFLE_SET,
{vol.Required(ATTR_MEDIA_SHUFFLE): cv.boolean},
@@ -1015,7 +1056,6 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if self.state in {
MediaPlayerState.OFF,
- MediaPlayerState.IDLE,
MediaPlayerState.STANDBY,
}:
await self.async_turn_on()
@@ -1142,6 +1182,29 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""
raise NotImplementedError
+ async def async_internal_search_media(
+ self,
+ search_query: str,
+ media_content_type: MediaType | str | None = None,
+ media_content_id: str | None = None,
+ media_filter_classes: list[MediaClass] | None = None,
+ ) -> SearchMedia:
+ return await self.async_search_media(
+ query=SearchMediaQuery(
+ search_query=search_query,
+ media_content_type=media_content_type,
+ media_content_id=media_content_id,
+ media_filter_classes=media_filter_classes,
+ )
+ )
+
+ async def async_search_media(
+ self,
+ query: SearchMediaQuery,
+ ) -> SearchMedia:
+ """Search the media player."""
+ raise NotImplementedError
+
def join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player."""
raise NotImplementedError
@@ -1345,6 +1408,75 @@ async def websocket_browse_media(
connection.send_result(msg["id"], result)
+@websocket_api.websocket_command(
+ {
+ vol.Required("type"): "media_player/search_media",
+ vol.Required("entity_id"): cv.entity_id,
+ vol.Inclusive(
+ ATTR_MEDIA_CONTENT_TYPE,
+ "media_ids",
+ "media_content_type and media_content_id must be provided together",
+ ): str,
+ vol.Inclusive(
+ ATTR_MEDIA_CONTENT_ID,
+ "media_ids",
+ "media_content_type and media_content_id must be provided together",
+ ): str,
+ vol.Required(ATTR_MEDIA_SEARCH_QUERY): str,
+ vol.Optional(ATTR_MEDIA_FILTER_CLASSES): vol.All(
+ cv.ensure_list,
+ [vol.In([m.value for m in MediaClass])],
+ lambda x: {MediaClass(item) for item in x},
+ ),
+ }
+)
+@websocket_api.async_response
+async def websocket_search_media(
+ hass: HomeAssistant,
+ connection: websocket_api.connection.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Search media available to the media_player entity.
+
+ To use, media_player integrations can implement
+ MediaPlayerEntity.async_search_media()
+ """
+ player = hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])
+
+ if player is None:
+ connection.send_error(msg["id"], "entity_not_found", "Entity not found")
+ return
+
+ if MediaPlayerEntityFeature.SEARCH_MEDIA not in player.supported_features_compat:
+ connection.send_message(
+ websocket_api.error_message(
+ msg["id"], ERR_NOT_SUPPORTED, "Player does not support searching media"
+ )
+ )
+ return
+
+ media_content_type = msg.get(ATTR_MEDIA_CONTENT_TYPE)
+ media_content_id = msg.get(ATTR_MEDIA_CONTENT_ID)
+ query = str(msg.get(ATTR_MEDIA_SEARCH_QUERY))
+ media_filter_classes = msg.get(ATTR_MEDIA_FILTER_CLASSES, [])
+
+ try:
+ payload = await player.async_internal_search_media(
+ query,
+ media_content_type,
+ media_content_id,
+ media_filter_classes,
+ )
+ except SearchError as err:
+ connection.send_message(
+ websocket_api.error_message(msg["id"], ERR_UNKNOWN_ERROR, str(err))
+ )
+ return
+
+ result = payload.as_dict()
+ connection.send_result(msg["id"], result)
+
+
_FETCH_TIMEOUT = aiohttp.ClientTimeout(total=10)
diff --git a/homeassistant/components/media_player/browse_media.py b/homeassistant/components/media_player/browse_media.py
index c917164a2ee..ec9d70476a3 100644
--- a/homeassistant/components/media_player/browse_media.py
+++ b/homeassistant/components/media_player/browse_media.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Sequence
+from dataclasses import dataclass, field
from datetime import timedelta
import logging
from typing import Any
@@ -23,7 +24,11 @@ from homeassistant.helpers.network import (
from .const import CONTENT_AUTH_EXPIRY_TIME, MediaClass, MediaType
# Paths that we don't need to sign
-PATHS_WITHOUT_AUTH = ("/api/tts_proxy/", "/api/esphome/ffmpeg_proxy/")
+PATHS_WITHOUT_AUTH = (
+ "/api/tts_proxy/",
+ "/api/esphome/ffmpeg_proxy/",
+ "/api/assist_satellite/static/",
+)
@callback
@@ -105,6 +110,7 @@ class BrowseMedia:
children_media_class: MediaClass | str | None = None,
thumbnail: str | None = None,
not_shown: int = 0,
+ can_search: bool = False,
) -> None:
"""Initialize browse media item."""
self.media_class = media_class
@@ -117,6 +123,7 @@ class BrowseMedia:
self.children_media_class = children_media_class
self.thumbnail = thumbnail
self.not_shown = not_shown
+ self.can_search = can_search
def as_dict(self, *, parent: bool = True) -> dict[str, Any]:
"""Convert Media class to browse media dictionary."""
@@ -131,6 +138,7 @@ class BrowseMedia:
"children_media_class": self.children_media_class,
"can_play": self.can_play,
"can_expand": self.can_expand,
+ "can_search": self.can_search,
"thumbnail": self.thumbnail,
}
@@ -159,3 +167,27 @@ class BrowseMedia:
def __repr__(self) -> str:
"""Return representation of browse media."""
return f""
+
+
+@dataclass(kw_only=True, frozen=True)
+class SearchMedia:
+ """Represent search results."""
+
+ version: int = field(default=1)
+ result: list[BrowseMedia]
+
+ def as_dict(self, *, parent: bool = True) -> dict[str, Any]:
+ """Convert SearchMedia class to browse media dictionary."""
+ return {
+ "result": [item.as_dict(parent=parent) for item in self.result],
+ }
+
+
+@dataclass(kw_only=True, frozen=True)
+class SearchMediaQuery:
+ """Represent a search media file."""
+
+ search_query: str
+ media_content_type: MediaType | str | None = field(default=None)
+ media_content_id: str | None = None
+ media_filter_classes: list[MediaClass] | None = field(default=None)
diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py
index ca2f3307846..8d85d7cd106 100644
--- a/homeassistant/components/media_player/const.py
+++ b/homeassistant/components/media_player/const.py
@@ -26,6 +26,8 @@ ATTR_MEDIA_ARTIST = "media_artist"
ATTR_MEDIA_CHANNEL = "media_channel"
ATTR_MEDIA_CONTENT_ID = "media_content_id"
ATTR_MEDIA_CONTENT_TYPE = "media_content_type"
+ATTR_MEDIA_SEARCH_QUERY = "search_query"
+ATTR_MEDIA_FILTER_CLASSES = "media_filter_classes"
ATTR_MEDIA_DURATION = "media_duration"
ATTR_MEDIA_ENQUEUE = "enqueue"
ATTR_MEDIA_EXTRA = "extra"
@@ -173,6 +175,8 @@ _DEPRECATED_MEDIA_TYPE_VIDEO = DeprecatedConstantEnum(MediaType.VIDEO, "2025.10"
SERVICE_CLEAR_PLAYLIST = "clear_playlist"
SERVICE_JOIN = "join"
SERVICE_PLAY_MEDIA = "play_media"
+SERVICE_BROWSE_MEDIA = "browse_media"
+SERVICE_SEARCH_MEDIA = "search_media"
SERVICE_SELECT_SOUND_MODE = "select_sound_mode"
SERVICE_SELECT_SOURCE = "select_source"
SERVICE_UNJOIN = "unjoin"
@@ -219,6 +223,7 @@ class MediaPlayerEntityFeature(IntFlag):
GROUPING = 524288
MEDIA_ANNOUNCE = 1048576
MEDIA_ENQUEUE = 2097152
+ SEARCH_MEDIA = 4194304
# These SUPPORT_* constants are deprecated as of Home Assistant 2022.5.
diff --git a/homeassistant/components/media_player/errors.py b/homeassistant/components/media_player/errors.py
index 5888ba6b5b0..23db94a330e 100644
--- a/homeassistant/components/media_player/errors.py
+++ b/homeassistant/components/media_player/errors.py
@@ -9,3 +9,7 @@ class MediaPlayerException(HomeAssistantError):
class BrowseError(MediaPlayerException):
"""Error while browsing."""
+
+
+class SearchError(MediaPlayerException):
+ """Error while searching."""
diff --git a/homeassistant/components/media_player/icons.json b/homeassistant/components/media_player/icons.json
index c11211c38ec..fb45a821062 100644
--- a/homeassistant/components/media_player/icons.json
+++ b/homeassistant/components/media_player/icons.json
@@ -32,6 +32,9 @@
}
},
"services": {
+ "browse_media": {
+ "service": "mdi:folder-search"
+ },
"clear_playlist": {
"service": "mdi:playlist-remove"
},
@@ -65,6 +68,9 @@
"repeat_set": {
"service": "mdi:repeat"
},
+ "search_media": {
+ "service": "mdi:text-search"
+ },
"select_sound_mode": {
"service": "mdi:surround-sound"
},
diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py
index edfab2a668f..af37c0d68bb 100644
--- a/homeassistant/components/media_player/intent.py
+++ b/homeassistant/components/media_player/intent.py
@@ -96,11 +96,16 @@ async def async_setup_intents(hass: HomeAssistant) -> None:
required_states={MediaPlayerState.PLAYING},
required_features=MediaPlayerEntityFeature.VOLUME_SET,
required_slots={
- ATTR_MEDIA_VOLUME_LEVEL: vol.All(
- vol.Coerce(int), vol.Range(min=0, max=100), lambda val: val / 100
- )
+ ATTR_MEDIA_VOLUME_LEVEL: intent.IntentSlotInfo(
+ description="The volume percentage of the media player",
+ value_schema=vol.All(
+ vol.Coerce(int),
+ vol.Range(min=0, max=100),
+ lambda val: val / 100,
+ ),
+ ),
},
- description="Sets the volume of a media player",
+ description="Sets the volume percentage of a media player",
platforms={DOMAIN},
device_classes={MediaPlayerDeviceClass},
),
diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml
index 7338747b545..ac359de1a5b 100644
--- a/homeassistant/components/media_player/services.yaml
+++ b/homeassistant/components/media_player/services.yaml
@@ -165,6 +165,53 @@ play_media:
selector:
boolean:
+browse_media:
+ target:
+ entity:
+ domain: media_player
+ supported_features:
+ - media_player.MediaPlayerEntityFeature.BROWSE_MEDIA
+ fields:
+ media_content_type:
+ required: false
+ example: "music"
+ selector:
+ text:
+ media_content_id:
+ required: false
+ example: "A:ALBUMARTIST/Beatles"
+ selector:
+ text:
+
+search_media:
+ target:
+ entity:
+ domain: media_player
+ supported_features:
+ - media_player.MediaPlayerEntityFeature.SEARCH_MEDIA
+ fields:
+ search_query:
+ required: true
+ example: "Beatles"
+ selector:
+ text:
+ media_content_type:
+ required: false
+ example: "music"
+ selector:
+ text:
+ media_content_id:
+ required: false
+ example: "A:ALBUMARTIST/Beatles"
+ selector:
+ text:
+ media_filter_classes:
+ required: false
+ example: ["album", "artist"]
+ selector:
+ text:
+ multiple: true
+
select_source:
target:
entity:
diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json
index be06ae22cdc..459b54b8af2 100644
--- a/homeassistant/components/media_player/strings.json
+++ b/homeassistant/components/media_player/strings.json
@@ -248,7 +248,7 @@
},
"media_content_type": {
"name": "Content type",
- "description": "The type of the content to play. Such as image, music, tv show, video, episode, channel, or playlist."
+ "description": "The type of the content to play, such as image, music, tv show, video, episode, channel, or playlist."
},
"enqueue": {
"name": "Enqueue",
@@ -260,6 +260,42 @@
}
}
},
+ "browse_media": {
+ "name": "Browse media",
+ "description": "Browses the available media.",
+ "fields": {
+ "media_content_id": {
+ "name": "Content ID",
+ "description": "The ID of the content to browse. Integration dependent."
+ },
+ "media_content_type": {
+ "name": "Content type",
+ "description": "The type of the content to browse, such as image, music, tv show, video, episode, channel, or playlist."
+ }
+ }
+ },
+ "search_media": {
+ "name": "Search media",
+ "description": "Searches the available media.",
+ "fields": {
+ "media_content_id": {
+ "name": "[%key:component::media_player::services::browse_media::fields::media_content_id::name%]",
+ "description": "[%key:component::media_player::services::browse_media::fields::media_content_id::description%]"
+ },
+ "media_content_type": {
+ "name": "[%key:component::media_player::services::browse_media::fields::media_content_type::name%]",
+ "description": "[%key:component::media_player::services::browse_media::fields::media_content_type::description%]"
+ },
+ "search_query": {
+ "name": "Search query",
+ "description": "The term to search for."
+ },
+ "media_filter_classes": {
+ "name": "Media filter classes",
+ "description": "List of media classes to filter the search results by."
+ }
+ }
+ },
"select_source": {
"name": "Select source",
"description": "Sends the media player the command to change input source.",
@@ -285,22 +321,22 @@
"description": "Removes all items from the playlist."
},
"shuffle_set": {
- "name": "Shuffle",
- "description": "Playback mode that selects the media in randomized order.",
+ "name": "Set shuffle",
+ "description": "Enables or disables the shuffle mode.",
"fields": {
"shuffle": {
- "name": "Shuffle",
- "description": "Whether or not shuffle mode is enabled."
+ "name": "Shuffle mode",
+ "description": "Whether the media should be played in randomized order or not."
}
}
},
"repeat_set": {
- "name": "Repeat",
- "description": "Playback mode that plays the media in a loop.",
+ "name": "Set repeat",
+ "description": "Sets the repeat mode.",
"fields": {
"repeat": {
"name": "Repeat mode",
- "description": "Repeat mode to set."
+ "description": "Whether the media (one or all) should be played in a loop or not."
}
}
},
@@ -330,7 +366,7 @@
},
"repeat": {
"options": {
- "off": "Off",
+ "off": "[%key:common::state::off%]",
"all": "Repeat all",
"one": "Repeat one"
}
diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py
index 5c6165a3477..e1e9a4feb4b 100644
--- a/homeassistant/components/media_source/__init__.py
+++ b/homeassistant/components/media_source/__init__.py
@@ -33,7 +33,7 @@ from .const import (
URI_SCHEME,
URI_SCHEME_REGEX,
)
-from .error import MediaSourceError, Unresolvable
+from .error import MediaSourceError, UnknownMediaSource, Unresolvable
from .models import BrowseMediaSource, MediaSource, MediaSourceItem, PlayMedia
__all__ = [
@@ -113,7 +113,11 @@ def _get_media_item(
return MediaSourceItem(hass, domain, "", target_media_player)
if item.domain is not None and item.domain not in hass.data[DOMAIN]:
- raise ValueError("Unknown media source")
+ raise UnknownMediaSource(
+ translation_domain=DOMAIN,
+ translation_key="unknown_media_source",
+ translation_placeholders={"domain": item.domain},
+ )
return item
@@ -132,7 +136,14 @@ async def async_browse_media(
try:
item = await _get_media_item(hass, media_content_id, None).async_browse()
except ValueError as err:
- raise BrowseError(str(err)) from err
+ raise BrowseError(
+ translation_domain=DOMAIN,
+ translation_key="browse_media_failed",
+ translation_placeholders={
+ "media_content_id": str(media_content_id),
+ "error": str(err),
+ },
+ ) from err
if content_filter is None or item.children is None:
return item
@@ -165,7 +176,14 @@ async def async_resolve_media(
try:
item = _get_media_item(hass, media_content_id, target_media_player)
except ValueError as err:
- raise Unresolvable(str(err)) from err
+ raise Unresolvable(
+ translation_domain=DOMAIN,
+ translation_key="resolve_media_failed",
+ translation_placeholders={
+ "media_content_id": str(media_content_id),
+ "error": str(err),
+ },
+ ) from err
return await item.async_resolve()
diff --git a/homeassistant/components/media_source/error.py b/homeassistant/components/media_source/error.py
index 120e7583e23..66e8842e08a 100644
--- a/homeassistant/components/media_source/error.py
+++ b/homeassistant/components/media_source/error.py
@@ -9,3 +9,7 @@ class MediaSourceError(HomeAssistantError):
class Unresolvable(MediaSourceError):
"""When media ID is not resolvable."""
+
+
+class UnknownMediaSource(MediaSourceError, ValueError):
+ """When media source is unknown."""
diff --git a/homeassistant/components/media_source/strings.json b/homeassistant/components/media_source/strings.json
new file mode 100644
index 00000000000..40204fc32db
--- /dev/null
+++ b/homeassistant/components/media_source/strings.json
@@ -0,0 +1,13 @@
+{
+ "exceptions": {
+ "browse_media_failed": {
+ "message": "Failed to browse media with content id {media_content_id}: {error}"
+ },
+ "resolve_media_failed": {
+ "message": "Failed to resolve media with content id {media_content_id}: {error}"
+ },
+ "unknown_media_source": {
+ "message": "Unknown media source: {domain}"
+ }
+ }
+}
diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py
index 4defd47bc39..682a28ea080 100644
--- a/homeassistant/components/melcloud/climate.py
+++ b/homeassistant/components/melcloud/climate.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import timedelta
-from typing import Any
+from typing import Any, cast
from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice
import pymelcloud.ata_device as ata
@@ -28,7 +28,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MelCloudDevice
from .const import (
@@ -76,7 +76,9 @@ ATW_ZONE_HVAC_ACTION_LOOKUP = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MelCloud device climate based on config_entry."""
mel_devices = hass.data[DOMAIN][entry.entry_id]
@@ -150,6 +152,14 @@ class AtaDeviceClimate(MelCloudClimate):
self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}"
self._attr_device_info = self.api.device_info
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+
+ # We can only check for vane_horizontal once we fetch the device data from the cloud
+ if self._device.vane_horizontal:
+ self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
+
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the optional state attributes with device specific additions."""
@@ -226,7 +236,7 @@ class AtaDeviceClimate(MelCloudClimate):
set_dict: dict[str, Any] = {}
if ATTR_HVAC_MODE in kwargs:
self._apply_set_hvac_mode(
- kwargs.get(ATTR_HVAC_MODE, self.hvac_mode), set_dict
+ cast(HVACMode, kwargs.get(ATTR_HVAC_MODE, self.hvac_mode)), set_dict
)
if ATTR_TEMPERATURE in kwargs:
@@ -272,15 +282,29 @@ class AtaDeviceClimate(MelCloudClimate):
"""Return vertical vane position or mode."""
return self._device.vane_vertical
+ @property
+ def swing_horizontal_mode(self) -> str | None:
+ """Return horizontal vane position or mode."""
+ return self._device.vane_horizontal
+
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set vertical vane position or mode."""
await self.async_set_vane_vertical(swing_mode)
+ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set horizontal vane position or mode."""
+ await self.async_set_vane_horizontal(swing_horizontal_mode)
+
@property
def swing_modes(self) -> list[str] | None:
"""Return a list of available vertical vane positions and modes."""
return self._device.vane_vertical_positions
+ @property
+ def swing_horizontal_modes(self) -> list[str] | None:
+ """Return a list of available horizontal vane positions and modes."""
+ return self._device.vane_horizontal_positions
+
async def async_turn_on(self) -> None:
"""Turn the entity on."""
await self._device.set({"power": True})
diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py
index 84585c556ca..51a026e717a 100644
--- a/homeassistant/components/melcloud/sensor.py
+++ b/homeassistant/components/melcloud/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MelCloudDevice
from .const import DOMAIN
@@ -104,7 +104,9 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud device sensors based on config_entry."""
mel_devices = hass.data[DOMAIN].get(entry.entry_id)
diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json
index 19ef0b76aad..8c168295e88 100644
--- a/homeassistant/components/melcloud/strings.json
+++ b/homeassistant/components/melcloud/strings.json
@@ -11,20 +11,20 @@
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
- "description": "The Melcloud integration needs to re-authenticate your connection details",
+ "description": "The MELCloud integration needs to re-authenticate your connection details",
"data": {
"username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
}
},
"reconfigure": {
- "title": "Reconfigure your MelCloud",
+ "title": "Reconfigure your MELCloud",
"description": "Reconfigure the entry to obtain a new token, for your account: `{username}`.",
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "password": "Enter the (new) password for MelCloud."
+ "password": "Enter the (new) password for MELCloud."
}
}
},
@@ -70,7 +70,7 @@
},
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The MELCloud YAML configuration import failed",
- "description": "Configuring MELCloud using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to MELCloud works and restart Home Assistant to try again or remove the MELCloud YAML configuration from your configuration.yaml file and continue to [set up the integration](/config/integrations/dashboard/add?domain=melcoud) manually."
+ "description": "Configuring MELCloud using YAML is being removed but there was a connection error importing your YAML configuration.\n\nEnsure connection to MELCloud works and restart Home Assistant to try again or remove the MELCloud YAML configuration from your configuration.yaml file and continue to [set up the integration](/config/integrations/dashboard/add?domain=melcoud) manually."
}
},
"entity": {
diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py
index 8de1ac53311..76fbad41575 100644
--- a/homeassistant/components/melcloud/water_heater.py
+++ b/homeassistant/components/melcloud/water_heater.py
@@ -20,14 +20,16 @@ from homeassistant.components.water_heater import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, MelCloudDevice
from .const import ATTR_STATUS
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MelCloud device climate based on config_entry."""
mel_devices = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py
index 15c47008346..42c22ae5a43 100644
--- a/homeassistant/components/melnor/number.py
+++ b/homeassistant/components/melnor/number.py
@@ -16,7 +16,7 @@ from homeassistant.components.number import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MelnorDataUpdateCoordinator
@@ -68,7 +68,7 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the number platform."""
diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py
index bbb3416dcc9..525a29dc6cf 100644
--- a/homeassistant/components/melnor/sensor.py
+++ b/homeassistant/components/melnor/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
EntityCategory,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
@@ -105,7 +105,7 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneSensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py
index d7fb96739b3..cc5abe8f6f3 100644
--- a/homeassistant/components/melnor/switch.py
+++ b/homeassistant/components/melnor/switch.py
@@ -15,7 +15,7 @@ from homeassistant.components.switch import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MelnorDataUpdateCoordinator
@@ -52,7 +52,7 @@ ZONE_ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switch platform."""
diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py
index 08de7e054de..277eb6e36eb 100644
--- a/homeassistant/components/melnor/time.py
+++ b/homeassistant/components/melnor/time.py
@@ -13,7 +13,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MelnorDataUpdateCoordinator
@@ -42,7 +42,7 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneTimeEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the number platform."""
diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py
index d1f0e8bc834..c4f9c8e6885 100644
--- a/homeassistant/components/met/weather.py
+++ b/homeassistant/components/met/weather.py
@@ -34,7 +34,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er, sun
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_system import METRIC_SYSTEM
from .const import (
@@ -54,7 +54,7 @@ DEFAULT_NAME = "Met.no"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MetWeatherConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py
index 404ef5d8393..72706ccb70f 100644
--- a/homeassistant/components/met_eireann/weather.py
+++ b/homeassistant/components/met_eireann/weather.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
@@ -47,7 +47,7 @@ def format_condition(condition: str | None) -> str | None:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py
index 5c4ada6b5f1..5f1d5269538 100644
--- a/homeassistant/components/meteo_france/__init__.py
+++ b/homeassistant/components/meteo_france/__init__.py
@@ -57,7 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Fetch data from API endpoint."""
assert isinstance(department, str)
return await hass.async_add_executor_job(
- client.get_warning_current_phenomenoms, department, 0, True
+ client.get_warning_current_phenomenons, department, 0, True
)
coordinator_forecast = DataUpdateCoordinator(
diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py
index 2230f43b754..e64a55651d3 100644
--- a/homeassistant/components/meteo_france/const.py
+++ b/homeassistant/components/meteo_france/const.py
@@ -74,6 +74,7 @@ CONDITION_CLASSES: dict[str, list[str]] = {
"Pluie modérée",
"Pluie / Averses",
"Averses",
+ "Averses faibles",
"Pluie",
],
ATTR_CONDITION_SNOWY: [
@@ -81,10 +82,11 @@ CONDITION_CLASSES: dict[str, list[str]] = {
"Neige",
"Averses de neige",
"Neige forte",
+ "Neige faible",
"Quelques flocons",
],
ATTR_CONDITION_SNOWY_RAINY: ["Pluie et neige", "Pluie verglaçante"],
- ATTR_CONDITION_SUNNY: ["Ensoleillé"],
+ ATTR_CONDITION_SUNNY: ["Ensoleillé", "Ciel clair"],
ATTR_CONDITION_WINDY: [],
ATTR_CONDITION_WINDY_VARIANT: [],
ATTR_CONDITION_EXCEPTIONAL: [],
diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json
index 567788ec479..d82d0c3f91b 100644
--- a/homeassistant/components/meteo_france/manifest.json
+++ b/homeassistant/components/meteo_france/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/meteo_france",
"iot_class": "cloud_polling",
"loggers": ["meteofrance_api"],
- "requirements": ["meteofrance-api==1.3.0"]
+ "requirements": ["meteofrance-api==1.4.0"]
}
diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py
index 826716f1679..7333f7b0c19 100644
--- a/homeassistant/components/meteo_france/sensor.py
+++ b/homeassistant/components/meteo_france/sensor.py
@@ -7,7 +7,7 @@ from typing import Any
from meteofrance_api.helpers import (
get_warning_text_status_from_indice_color,
- readeable_phenomenoms_dict,
+ readable_phenomenons_dict,
)
from meteofrance_api.model.forecast import Forecast
from meteofrance_api.model.rain import Rain
@@ -30,7 +30,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -182,7 +182,9 @@ SENSOR_TYPES_PROBABILITY: tuple[MeteoFranceSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteo-France sensor platform."""
data = hass.data[DOMAIN][entry.entry_id]
@@ -334,7 +336,7 @@ class MeteoFranceAlertSensor(MeteoFranceSensor[CurrentPhenomenons]):
def extra_state_attributes(self):
"""Return the state attributes."""
return {
- **readeable_phenomenoms_dict(self.coordinator.data.phenomenons_max_colors),
+ **readable_phenomenons_dict(self.coordinator.data.phenomenons_max_colors),
}
diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py
index 8305547afd3..e2df35f21f3 100644
--- a/homeassistant/components/meteo_france/weather.py
+++ b/homeassistant/components/meteo_france/weather.py
@@ -28,7 +28,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -55,7 +55,9 @@ def format_condition(condition: str):
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteo-France weather platform."""
coordinator: DataUpdateCoordinator[MeteoFranceForecast] = hass.data[DOMAIN][
@@ -159,6 +161,11 @@ class MeteoFranceWeather(
"""Return the wind speed."""
return self.coordinator.data.current_forecast["wind"]["speed"]
+ @property
+ def native_wind_gust_speed(self):
+ """Return the wind gust speed."""
+ return self.coordinator.data.current_forecast["wind"].get("gust")
+
@property
def wind_bearing(self):
"""Return the wind bearing."""
diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py
index 2194f82e43e..6e508bd63d8 100644
--- a/homeassistant/components/meteoclimatic/sensor.py
+++ b/homeassistant/components/meteoclimatic/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -101,6 +101,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
name="Wind Bearing",
native_unit_of_measurement=DEGREE,
icon="mdi:weather-windy",
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
SensorEntityDescription(
key="rain",
@@ -112,7 +114,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteoclimatic sensor platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py
index 75a93689efa..fa3b3c92288 100644
--- a/homeassistant/components/meteoclimatic/weather.py
+++ b/homeassistant/components/meteoclimatic/weather.py
@@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -26,7 +26,9 @@ def format_condition(condition):
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Meteoclimatic weather platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py
index 61f825abdc3..5a256144d11 100644
--- a/homeassistant/components/metoffice/sensor.py
+++ b/homeassistant/components/metoffice/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
@@ -142,7 +142,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Met Office weather sensor platform."""
hass_data = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py
index 5eeddee8dd4..d3f1320c47e 100644
--- a/homeassistant/components/metoffice/weather.py
+++ b/homeassistant/components/metoffice/weather.py
@@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
from . import get_device_info
@@ -39,7 +39,9 @@ from .data import MetOfficeData
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Met Office weather sensor platform."""
entity_registry = er.async_get(hass)
diff --git a/homeassistant/components/microbees/binary_sensor.py b/homeassistant/components/microbees/binary_sensor.py
index 551f68ba354..1dc2a8d9702 100644
--- a/homeassistant/components/microbees/binary_sensor.py
+++ b/homeassistant/components/microbees/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MicroBeesUpdateCoordinator
@@ -36,7 +36,9 @@ BINARYSENSOR_TYPES = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the microBees binary sensor platform."""
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/microbees/button.py b/homeassistant/components/microbees/button.py
index f449fa9afee..ca3a76753a7 100644
--- a/homeassistant/components/microbees/button.py
+++ b/homeassistant/components/microbees/button.py
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MicroBeesUpdateCoordinator
@@ -15,7 +15,9 @@ BUTTON_TRANSLATIONS = {51: "button_gate", 91: "button_panic"}
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the microBees button platform."""
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/microbees/climate.py b/homeassistant/components/microbees/climate.py
index 077048ee352..554ca3b32cc 100644
--- a/homeassistant/components/microbees/climate.py
+++ b/homeassistant/components/microbees/climate.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MicroBeesUpdateCoordinator
@@ -26,7 +26,9 @@ THERMOVALVE_SENSOR_ID = 782
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the microBees climate platform."""
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/microbees/cover.py b/homeassistant/components/microbees/cover.py
index b6d5d366d89..fe87fcddd62 100644
--- a/homeassistant/components/microbees/cover.py
+++ b/homeassistant/components/microbees/cover.py
@@ -12,7 +12,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from .const import DOMAIN
@@ -23,7 +23,9 @@ COVER_IDS = {47: "roller_shutter"}
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the microBees cover platform."""
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/microbees/light.py b/homeassistant/components/microbees/light.py
index 654cdc37182..a7ff60dc64a 100644
--- a/homeassistant/components/microbees/light.py
+++ b/homeassistant/components/microbees/light.py
@@ -6,7 +6,7 @@ from homeassistant.components.light import ATTR_RGBW_COLOR, ColorMode, LightEnti
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MicroBeesUpdateCoordinator
@@ -14,7 +14,9 @@ from .entity import MicroBeesActuatorEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Config entry."""
coordinator: MicroBeesUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/microbees/sensor.py b/homeassistant/components/microbees/sensor.py
index 360422de735..e4be463ab10 100644
--- a/homeassistant/components/microbees/sensor.py
+++ b/homeassistant/components/microbees/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MicroBeesUpdateCoordinator
@@ -63,7 +63,9 @@ SENSOR_TYPES = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id].coordinator
diff --git a/homeassistant/components/microbees/switch.py b/homeassistant/components/microbees/switch.py
index 1d668d041e1..deda2d78d09 100644
--- a/homeassistant/components/microbees/switch.py
+++ b/homeassistant/components/microbees/switch.py
@@ -6,7 +6,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MicroBeesUpdateCoordinator
@@ -17,7 +17,9 @@ SWITCH_PRODUCT_IDS = {25, 26, 27, 35, 38, 46, 63, 64, 65, 86}
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id].coordinator
diff --git a/homeassistant/components/miele/__init__.py b/homeassistant/components/miele/__init__.py
new file mode 100644
index 00000000000..13247c42034
--- /dev/null
+++ b/homeassistant/components/miele/__init__.py
@@ -0,0 +1,70 @@
+"""The Miele integration."""
+
+from __future__ import annotations
+
+from aiohttp import ClientError, ClientResponseError
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.config_entry_oauth2_flow import (
+ OAuth2Session,
+ async_get_config_entry_implementation,
+)
+
+from .api import AsyncConfigEntryAuth
+from .const import DOMAIN
+from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator
+
+PLATFORMS: list[Platform] = [
+ Platform.SENSOR,
+]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool:
+ """Set up Miele from a config entry."""
+ implementation = await async_get_config_entry_implementation(hass, entry)
+
+ session = OAuth2Session(hass, entry, implementation)
+ auth = AsyncConfigEntryAuth(async_get_clientsession(hass), session)
+ try:
+ await auth.async_get_access_token()
+ except ClientResponseError as err:
+ if 400 <= err.status < 500:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_auth_failed",
+ ) from err
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_ready",
+ ) from err
+ except ClientError as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="config_entry_not_ready",
+ ) from err
+
+ # Setup MieleAPI and coordinator for data fetch
+ coordinator = MieleDataUpdateCoordinator(hass, auth)
+ await coordinator.async_config_entry_first_refresh()
+ entry.runtime_data = coordinator
+
+ entry.async_create_background_task(
+ hass,
+ coordinator.api.listen_events(
+ data_callback=coordinator.callback_update_data,
+ actions_callback=coordinator.callback_update_actions,
+ ),
+ "pymiele event listener",
+ )
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: MieleConfigEntry) -> bool:
+ """Unload a config entry."""
+
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/miele/api.py b/homeassistant/components/miele/api.py
new file mode 100644
index 00000000000..632314f405c
--- /dev/null
+++ b/homeassistant/components/miele/api.py
@@ -0,0 +1,27 @@
+"""API for Miele bound to Home Assistant OAuth."""
+
+from typing import cast
+
+from aiohttp import ClientSession
+from pymiele import MIELE_API, AbstractAuth
+
+from homeassistant.helpers import config_entry_oauth2_flow
+
+
+class AsyncConfigEntryAuth(AbstractAuth):
+ """Provide Miele authentication tied to an OAuth2 based config entry."""
+
+ def __init__(
+ self,
+ websession: ClientSession,
+ oauth_session: config_entry_oauth2_flow.OAuth2Session,
+ ) -> None:
+ """Initialize Miele auth."""
+ super().__init__(websession, MIELE_API)
+ self._oauth_session = oauth_session
+
+ async def async_get_access_token(self) -> str:
+ """Return a valid access token."""
+
+ await self._oauth_session.async_ensure_token_valid()
+ return cast(str, self._oauth_session.token["access_token"])
diff --git a/homeassistant/components/miele/application_credentials.py b/homeassistant/components/miele/application_credentials.py
new file mode 100644
index 00000000000..d40ef765ce0
--- /dev/null
+++ b/homeassistant/components/miele/application_credentials.py
@@ -0,0 +1,21 @@
+"""Application credentials platform for the Miele integration."""
+
+from pymiele import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
+
+from homeassistant.components.application_credentials import AuthorizationServer
+from homeassistant.core import HomeAssistant
+
+
+async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
+ """Return authorization server."""
+ return AuthorizationServer(
+ authorize_url=OAUTH2_AUTHORIZE,
+ token_url=OAUTH2_TOKEN,
+ )
+
+
+async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]:
+ """Return description placeholders for the credentials dialog."""
+ return {
+ "register_url": "https://www.miele.com/f/com/en/register_api.aspx",
+ }
diff --git a/homeassistant/components/miele/config_flow.py b/homeassistant/components/miele/config_flow.py
new file mode 100644
index 00000000000..d3c7dbba12b
--- /dev/null
+++ b/homeassistant/components/miele/config_flow.py
@@ -0,0 +1,73 @@
+"""Config flow for Miele."""
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+from homeassistant.config_entries import (
+ SOURCE_REAUTH,
+ SOURCE_RECONFIGURE,
+ ConfigFlowResult,
+)
+from homeassistant.helpers import config_entry_oauth2_flow
+
+from .const import DOMAIN
+
+
+class OAuth2FlowHandler(
+ config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
+):
+ """Config flow to handle Miele OAuth2 authentication."""
+
+ DOMAIN = DOMAIN
+
+ @property
+ def logger(self) -> logging.Logger:
+ """Return logger."""
+ return logging.getLogger(__name__)
+
+ @property
+ def extra_authorize_data(self) -> dict:
+ """Extra data that needs to be appended to the authorize url."""
+ # "vg" is mandatory but the value doesn't seem to matter
+ return {
+ "vg": "sv-SE",
+ }
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform reauth upon an API authentication error."""
+
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Dialog that informs the user that reauth is required."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ )
+
+ return await self.async_step_user()
+
+ async def async_step_reconfigure(
+ self, user_input: Mapping[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """User initiated reconfiguration."""
+ return await self.async_step_user()
+
+ async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
+ """Create or update the config entry."""
+
+ if self.source == SOURCE_REAUTH:
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(), data=data
+ )
+
+ if self.source == SOURCE_RECONFIGURE:
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(), data=data
+ )
+ return await super().async_oauth_create_entry(data)
diff --git a/homeassistant/components/miele/const.py b/homeassistant/components/miele/const.py
new file mode 100644
index 00000000000..86239ee6590
--- /dev/null
+++ b/homeassistant/components/miele/const.py
@@ -0,0 +1,154 @@
+"""Constants for the Miele integration."""
+
+from enum import IntEnum
+
+DOMAIN = "miele"
+MANUFACTURER = "Miele"
+
+ACTIONS = "actions"
+POWER_ON = "powerOn"
+POWER_OFF = "powerOff"
+PROCESS_ACTION = "processAction"
+
+
+class MieleAppliance(IntEnum):
+ """Define appliance types."""
+
+ WASHING_MACHINE = 1
+ TUMBLE_DRYER = 2
+ WASHING_MACHINE_SEMI_PROFESSIONAL = 3
+ TUMBLE_DRYER_SEMI_PROFESSIONAL = 4
+ WASHING_MACHINE_PROFESSIONAL = 5
+ DRYER_PROFESSIONAL = 6
+ DISHWASHER = 7
+ DISHWASHER_SEMI_PROFESSIONAL = 8
+ DISHWASHER_PROFESSIONAL = 9
+ OVEN = 12
+ OVEN_MICROWAVE = 13
+ HOB_HIGHLIGHT = 14
+ STEAM_OVEN = 15
+ MICROWAVE = 16
+ COFFEE_SYSTEM = 17
+ HOOD = 18
+ FRIDGE = 19
+ FREEZER = 20
+ FRIDGE_FREEZER = 21
+ ROBOT_VACUUM_CLEANER = 23
+ WASHER_DRYER = 24
+ DISH_WARMER = 25
+ HOB_INDUCTION = 27
+ STEAM_OVEN_COMBI = 31
+ WINE_CABINET = 32
+ WINE_CONDITIONING_UNIT = 33
+ WINE_STORAGE_CONDITIONING_UNIT = 34
+ STEAM_OVEN_MICRO = 45
+ DIALOG_OVEN = 67
+ WINE_CABINET_FREEZER = 68
+ STEAM_OVEN_MK2 = 73
+ HOB_INDUCT_EXTR = 74
+
+
+DEVICE_TYPE_TAGS = {
+ MieleAppliance.WASHING_MACHINE: "washing_machine",
+ MieleAppliance.TUMBLE_DRYER: "tumble_dryer",
+ MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL: "washing_machine",
+ MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: "tumble_dryer",
+ MieleAppliance.WASHING_MACHINE_PROFESSIONAL: "washing_machine",
+ MieleAppliance.DRYER_PROFESSIONAL: "tumble_dryer",
+ MieleAppliance.DISHWASHER: "dishwasher",
+ MieleAppliance.DISHWASHER_SEMI_PROFESSIONAL: "dishwasher",
+ MieleAppliance.DISHWASHER_PROFESSIONAL: "dishwasher",
+ MieleAppliance.OVEN: "oven",
+ MieleAppliance.OVEN_MICROWAVE: "oven_microwave",
+ MieleAppliance.HOB_HIGHLIGHT: "hob",
+ MieleAppliance.STEAM_OVEN: "steam_oven",
+ MieleAppliance.MICROWAVE: "microwave",
+ MieleAppliance.COFFEE_SYSTEM: "coffee_system",
+ MieleAppliance.HOOD: "hood",
+ MieleAppliance.FRIDGE: "refrigerator",
+ MieleAppliance.FREEZER: "freezer",
+ MieleAppliance.FRIDGE_FREEZER: "fridge_freezer",
+ MieleAppliance.ROBOT_VACUUM_CLEANER: "robot_vacuum_cleaner",
+ MieleAppliance.WASHER_DRYER: "washer_dryer",
+ MieleAppliance.DISH_WARMER: "warming_drawer",
+ MieleAppliance.HOB_INDUCTION: "hob",
+ MieleAppliance.STEAM_OVEN_COMBI: "steam_oven_combi",
+ MieleAppliance.WINE_CABINET: "wine_cabinet",
+ MieleAppliance.WINE_CONDITIONING_UNIT: "wine_conditioning_unit",
+ MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT: "wine_unit",
+ MieleAppliance.STEAM_OVEN_MICRO: "steam_oven_micro",
+ MieleAppliance.DIALOG_OVEN: "dialog_oven",
+ MieleAppliance.WINE_CABINET_FREEZER: "wine_cabinet_freezer",
+ MieleAppliance.STEAM_OVEN_MK2: "steam_oven",
+ MieleAppliance.HOB_INDUCT_EXTR: "hob_extraction",
+}
+
+
+class StateStatus(IntEnum):
+ """Define appliance states."""
+
+ RESERVED = 0
+ OFF = 1
+ ON = 2
+ PROGRAMMED = 3
+ WAITING_TO_START = 4
+ IN_USE = 5
+ PAUSE = 6
+ PROGRAM_ENDED = 7
+ FAILURE = 8
+ PROGRAM_INTERRUPTED = 9
+ IDLE = 10
+ RINSE_HOLD = 11
+ SERVICE = 12
+ SUPERFREEZING = 13
+ SUPERCOOLING = 14
+ SUPERHEATING = 15
+ SUPERCOOLING_SUPERFREEZING = 146
+ AUTOCLEANING = 147
+ NOT_CONNECTED = 255
+
+
+STATE_STATUS_TAGS = {
+ StateStatus.OFF: "off",
+ StateStatus.ON: "on",
+ StateStatus.PROGRAMMED: "programmed",
+ StateStatus.WAITING_TO_START: "waiting_to_start",
+ StateStatus.IN_USE: "in_use",
+ StateStatus.PAUSE: "pause",
+ StateStatus.PROGRAM_ENDED: "program_ended",
+ StateStatus.FAILURE: "failure",
+ StateStatus.PROGRAM_INTERRUPTED: "program_interrupted",
+ StateStatus.IDLE: "idle",
+ StateStatus.RINSE_HOLD: "rinse_hold",
+ StateStatus.SERVICE: "service",
+ StateStatus.SUPERFREEZING: "superfreezing",
+ StateStatus.SUPERCOOLING: "supercooling",
+ StateStatus.SUPERHEATING: "superheating",
+ StateStatus.SUPERCOOLING_SUPERFREEZING: "supercooling_superfreezing",
+ StateStatus.AUTOCLEANING: "autocleaning",
+ StateStatus.NOT_CONNECTED: "not_connected",
+}
+
+
+class MieleActions(IntEnum):
+ """Define appliance actions."""
+
+ START = 1
+ STOP = 2
+ PAUSE = 3
+ START_SUPERFREEZE = 4
+ STOP_SUPERFREEZE = 5
+ START_SUPERCOOL = 6
+ STOP_SUPERCOOL = 7
+
+
+# Possible actions
+PROCESS_ACTIONS = {
+ "start": MieleActions.START,
+ "stop": MieleActions.STOP,
+ "pause": MieleActions.PAUSE,
+ "start_superfreezing": MieleActions.START_SUPERFREEZE,
+ "stop_superfreezing": MieleActions.STOP_SUPERFREEZE,
+ "start_supercooling": MieleActions.START_SUPERCOOL,
+ "stop_supercooling": MieleActions.STOP_SUPERCOOL,
+}
diff --git a/homeassistant/components/miele/coordinator.py b/homeassistant/components/miele/coordinator.py
new file mode 100644
index 00000000000..8902f0f173a
--- /dev/null
+++ b/homeassistant/components/miele/coordinator.py
@@ -0,0 +1,87 @@
+"""Coordinator module for Miele integration."""
+
+from __future__ import annotations
+
+import asyncio.timeouts
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+
+from pymiele import MieleAction, MieleDevice
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+from .api import AsyncConfigEntryAuth
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+type MieleConfigEntry = ConfigEntry[MieleDataUpdateCoordinator]
+
+
+@dataclass
+class MieleCoordinatorData:
+ """Data class for storing coordinator data."""
+
+ devices: dict[str, MieleDevice]
+ actions: dict[str, MieleAction]
+
+
+class MieleDataUpdateCoordinator(DataUpdateCoordinator[MieleCoordinatorData]):
+ """Coordinator for Miele data."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ api: AsyncConfigEntryAuth,
+ ) -> None:
+ """Initialize the Miele data coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=timedelta(seconds=120),
+ )
+ self.api = api
+
+ async def _async_update_data(self) -> MieleCoordinatorData:
+ """Fetch data from the Miele API."""
+ async with asyncio.timeout(10):
+ # Get devices
+ devices_json = await self.api.get_devices()
+ devices = {
+ device_id: MieleDevice(device)
+ for device_id, device in devices_json.items()
+ }
+ actions = {}
+ for device_id in devices:
+ actions_json = await self.api.get_actions(device_id)
+ actions[device_id] = MieleAction(actions_json)
+ return MieleCoordinatorData(devices=devices, actions=actions)
+
+ async def callback_update_data(self, devices_json: dict[str, dict]) -> None:
+ """Handle data update from the API."""
+ devices = {
+ device_id: MieleDevice(device) for device_id, device in devices_json.items()
+ }
+ self.async_set_updated_data(
+ MieleCoordinatorData(
+ devices=devices,
+ actions=self.data.actions,
+ )
+ )
+
+ async def callback_update_actions(self, actions_json: dict[str, dict]) -> None:
+ """Handle data update from the API."""
+ actions = {
+ device_id: MieleAction(action) for device_id, action in actions_json.items()
+ }
+ self.async_set_updated_data(
+ MieleCoordinatorData(
+ devices=self.data.devices,
+ actions=actions,
+ )
+ )
diff --git a/homeassistant/components/miele/diagnostics.py b/homeassistant/components/miele/diagnostics.py
new file mode 100644
index 00000000000..2dbb88fbca6
--- /dev/null
+++ b/homeassistant/components/miele/diagnostics.py
@@ -0,0 +1,80 @@
+"""Diagnostics support for Miele."""
+
+from __future__ import annotations
+
+import hashlib
+from typing import Any, cast
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntry
+
+from .coordinator import MieleConfigEntry
+
+TO_REDACT = {"access_token", "refresh_token", "fabNumber"}
+
+
+def hash_identifier(key: str) -> str:
+ """Hash the identifier string."""
+ return f"**REDACTED_{hashlib.sha256(key.encode()).hexdigest()[:16]}"
+
+
+def redact_identifiers(in_data: dict[str, Any]) -> dict[str, Any]:
+ """Redact identifiers from the data."""
+ for key in in_data:
+ in_data[hash_identifier(key)] = in_data.pop(key)
+ return in_data
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: MieleConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ miele_data = {
+ "devices": redact_identifiers(
+ {
+ device_id: device_data.raw
+ for device_id, device_data in config_entry.runtime_data.data.devices.items()
+ }
+ ),
+ "actions": redact_identifiers(
+ {
+ device_id: action_data.raw
+ for device_id, action_data in config_entry.runtime_data.data.actions.items()
+ }
+ ),
+ }
+
+ return {
+ "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT),
+ "miele_data": async_redact_data(miele_data, TO_REDACT),
+ }
+
+
+async def async_get_device_diagnostics(
+ hass: HomeAssistant, config_entry: MieleConfigEntry, device: DeviceEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a device."""
+ info = {
+ "manufacturer": device.manufacturer,
+ "model": device.model,
+ }
+
+ coordinator = config_entry.runtime_data
+
+ device_id = cast(str, device.serial_number)
+ miele_data = {
+ "devices": {
+ hash_identifier(device_id): coordinator.data.devices[device_id].raw
+ },
+ "actions": {
+ hash_identifier(device_id): coordinator.data.actions[device_id].raw
+ },
+ "programs": "Not implemented",
+ }
+ return {
+ "info": async_redact_data(info, TO_REDACT),
+ "data": async_redact_data(config_entry.data, TO_REDACT),
+ "miele_data": async_redact_data(miele_data, TO_REDACT),
+ }
diff --git a/homeassistant/components/miele/entity.py b/homeassistant/components/miele/entity.py
new file mode 100644
index 00000000000..337f583cbff
--- /dev/null
+++ b/homeassistant/components/miele/entity.py
@@ -0,0 +1,56 @@
+"""Entity base class for the Miele integration."""
+
+from pymiele import MieleDevice
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DEVICE_TYPE_TAGS, DOMAIN, MANUFACTURER, MieleAppliance, StateStatus
+from .coordinator import MieleDataUpdateCoordinator
+
+
+class MieleEntity(CoordinatorEntity[MieleDataUpdateCoordinator]):
+ """Base class for Miele entities."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: MieleDataUpdateCoordinator,
+ device_id: str,
+ description: EntityDescription,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+ self._device_id = device_id
+ self.entity_description = description
+ self._attr_unique_id = f"{device_id}-{description.key}"
+
+ device = self.device
+ appliance_type = DEVICE_TYPE_TAGS.get(MieleAppliance(device.device_type))
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device_id)},
+ serial_number=device_id,
+ name=appliance_type or device.tech_type,
+ translation_key=appliance_type,
+ manufacturer=MANUFACTURER,
+ model=device.tech_type,
+ hw_version=device.xkm_tech_type,
+ sw_version=device.xkm_release_version,
+ )
+
+ @property
+ def device(self) -> MieleDevice:
+ """Return the device object."""
+ return self.coordinator.data.devices[self._device_id]
+
+ @property
+ def available(self) -> bool:
+ """Return the availability of the entity."""
+
+ return (
+ super().available
+ and self._device_id in self.coordinator.data.devices
+ and (self.device.state_status is not StateStatus.NOT_CONNECTED)
+ )
diff --git a/homeassistant/components/miele/manifest.json b/homeassistant/components/miele/manifest.json
new file mode 100644
index 00000000000..414db320718
--- /dev/null
+++ b/homeassistant/components/miele/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "miele",
+ "name": "Miele",
+ "codeowners": ["@astrandb"],
+ "config_flow": true,
+ "dependencies": ["application_credentials"],
+ "documentation": "https://www.home-assistant.io/integrations/miele",
+ "iot_class": "cloud_push",
+ "loggers": ["pymiele"],
+ "quality_scale": "bronze",
+ "requirements": ["pymiele==0.3.4"],
+ "single_config_entry": true
+}
diff --git a/homeassistant/components/miele/quality_scale.yaml b/homeassistant/components/miele/quality_scale.yaml
new file mode 100644
index 00000000000..e9d229c6a1b
--- /dev/null
+++ b/homeassistant/components/miele/quality_scale.yaml
@@ -0,0 +1,76 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ No custom actions are defined.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ No explicit event subscriptions.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry:
+ status: done
+ comment: |
+ Handled by a setting in manifest.json as there is no account information in API
+
+ # Silver
+ action-exceptions: todo
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No configuration parameters
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates:
+ status: exempt
+ comment: Handled by coordinator
+ reauthentication-flow: done
+ test-coverage: todo
+
+ # Gold
+ devices: todo
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ 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: done
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: todo
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: done
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/miele/sensor.py b/homeassistant/components/miele/sensor.py
new file mode 100644
index 00000000000..c281ba51151
--- /dev/null
+++ b/homeassistant/components/miele/sensor.py
@@ -0,0 +1,211 @@
+"""Sensor platform for Miele integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import logging
+from typing import Final, cast
+
+from pymiele import MieleDevice
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+
+from .const import STATE_STATUS_TAGS, MieleAppliance, StateStatus
+from .coordinator import MieleConfigEntry, MieleDataUpdateCoordinator
+from .entity import MieleEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass(frozen=True, kw_only=True)
+class MieleSensorDescription(SensorEntityDescription):
+ """Class describing Miele sensor entities."""
+
+ value_fn: Callable[[MieleDevice], StateType]
+ zone: int | None = None
+
+
+@dataclass
+class MieleSensorDefinition:
+ """Class for defining sensor entities."""
+
+ types: tuple[MieleAppliance, ...]
+ description: MieleSensorDescription
+
+
+SENSOR_TYPES: Final[tuple[MieleSensorDefinition, ...]] = (
+ MieleSensorDefinition(
+ types=(
+ MieleAppliance.WASHING_MACHINE,
+ MieleAppliance.WASHING_MACHINE_SEMI_PROFESSIONAL,
+ MieleAppliance.TUMBLE_DRYER,
+ MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL,
+ MieleAppliance.DISHWASHER,
+ MieleAppliance.OVEN,
+ MieleAppliance.OVEN_MICROWAVE,
+ MieleAppliance.HOB_HIGHLIGHT,
+ MieleAppliance.STEAM_OVEN,
+ MieleAppliance.MICROWAVE,
+ MieleAppliance.COFFEE_SYSTEM,
+ MieleAppliance.HOOD,
+ MieleAppliance.FRIDGE,
+ MieleAppliance.FREEZER,
+ MieleAppliance.FRIDGE_FREEZER,
+ MieleAppliance.ROBOT_VACUUM_CLEANER,
+ MieleAppliance.WASHER_DRYER,
+ MieleAppliance.DISH_WARMER,
+ MieleAppliance.HOB_INDUCTION,
+ MieleAppliance.STEAM_OVEN_COMBI,
+ MieleAppliance.WINE_CABINET,
+ MieleAppliance.WINE_CONDITIONING_UNIT,
+ MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT,
+ MieleAppliance.STEAM_OVEN_MICRO,
+ MieleAppliance.DIALOG_OVEN,
+ MieleAppliance.WINE_CABINET_FREEZER,
+ MieleAppliance.STEAM_OVEN_MK2,
+ MieleAppliance.HOB_INDUCT_EXTR,
+ ),
+ description=MieleSensorDescription(
+ key="state_status",
+ translation_key="status",
+ value_fn=lambda value: value.state_status,
+ device_class=SensorDeviceClass.ENUM,
+ options=list(STATE_STATUS_TAGS.values()),
+ ),
+ ),
+ MieleSensorDefinition(
+ types=(
+ MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL,
+ MieleAppliance.OVEN,
+ MieleAppliance.OVEN_MICROWAVE,
+ MieleAppliance.DISH_WARMER,
+ MieleAppliance.STEAM_OVEN,
+ MieleAppliance.MICROWAVE,
+ MieleAppliance.FRIDGE,
+ MieleAppliance.FREEZER,
+ MieleAppliance.FRIDGE_FREEZER,
+ MieleAppliance.STEAM_OVEN_COMBI,
+ MieleAppliance.WINE_CABINET,
+ MieleAppliance.WINE_CONDITIONING_UNIT,
+ MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT,
+ MieleAppliance.STEAM_OVEN_MICRO,
+ MieleAppliance.DIALOG_OVEN,
+ MieleAppliance.WINE_CABINET_FREEZER,
+ MieleAppliance.STEAM_OVEN_MK2,
+ ),
+ description=MieleSensorDescription(
+ key="state_temperature_1",
+ zone=1,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda value: cast(int, value.state_temperatures[0].temperature)
+ / 100.0,
+ ),
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: MieleConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the sensor platform."""
+ coordinator = config_entry.runtime_data
+
+ entities: list = []
+ entity_class: type[MieleSensor]
+ for device_id, device in coordinator.data.devices.items():
+ for definition in SENSOR_TYPES:
+ if device.device_type in definition.types:
+ match definition.description.key:
+ case "state_status":
+ entity_class = MieleStatusSensor
+ case _:
+ entity_class = MieleSensor
+ entities.append(
+ entity_class(coordinator, device_id, definition.description)
+ )
+
+ async_add_entities(entities)
+
+
+APPLIANCE_ICONS = {
+ MieleAppliance.WASHING_MACHINE: "mdi:washing-machine",
+ MieleAppliance.TUMBLE_DRYER: "mdi:tumble-dryer",
+ MieleAppliance.TUMBLE_DRYER_SEMI_PROFESSIONAL: "mdi:tumble-dryer",
+ MieleAppliance.DISHWASHER: "mdi:dishwasher",
+ MieleAppliance.OVEN: "mdi:chef-hat",
+ MieleAppliance.OVEN_MICROWAVE: "mdi:chef-hat",
+ MieleAppliance.HOB_HIGHLIGHT: "mdi:pot-steam-outline",
+ MieleAppliance.STEAM_OVEN: "mdi:chef-hat",
+ MieleAppliance.MICROWAVE: "mdi:microwave",
+ MieleAppliance.COFFEE_SYSTEM: "mdi:coffee-maker",
+ MieleAppliance.HOOD: "mdi:turbine",
+ MieleAppliance.FRIDGE: "mdi:fridge-industrial-outline",
+ MieleAppliance.FREEZER: "mdi:fridge-industrial-outline",
+ MieleAppliance.FRIDGE_FREEZER: "mdi:fridge-outline",
+ MieleAppliance.ROBOT_VACUUM_CLEANER: "mdi:robot-vacuum",
+ MieleAppliance.WASHER_DRYER: "mdi:washing-machine",
+ MieleAppliance.DISH_WARMER: "mdi:heat-wave",
+ MieleAppliance.HOB_INDUCTION: "mdi:pot-steam-outline",
+ MieleAppliance.STEAM_OVEN_COMBI: "mdi:chef-hat",
+ MieleAppliance.WINE_CABINET: "mdi:glass-wine",
+ MieleAppliance.WINE_CONDITIONING_UNIT: "mdi:glass-wine",
+ MieleAppliance.WINE_STORAGE_CONDITIONING_UNIT: "mdi:glass-wine",
+ MieleAppliance.STEAM_OVEN_MICRO: "mdi:chef-hat",
+ MieleAppliance.DIALOG_OVEN: "mdi:chef-hat",
+ MieleAppliance.WINE_CABINET_FREEZER: "mdi:glass-wine",
+ MieleAppliance.HOB_INDUCT_EXTR: "mdi:pot-steam-outline",
+}
+
+
+class MieleSensor(MieleEntity, SensorEntity):
+ """Representation of a Sensor."""
+
+ entity_description: MieleSensorDescription
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.device)
+
+
+class MieleStatusSensor(MieleSensor):
+ """Representation of the status sensor."""
+
+ def __init__(
+ self,
+ coordinator: MieleDataUpdateCoordinator,
+ device_id: str,
+ description: MieleSensorDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator, device_id, description)
+ self._attr_name = None
+ self._attr_icon = APPLIANCE_ICONS.get(
+ MieleAppliance(self.device.device_type),
+ "mdi:state-machine",
+ )
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ return STATE_STATUS_TAGS.get(StateStatus(self.device.state_status))
+
+ @property
+ def available(self) -> bool:
+ """Return the availability of the entity."""
+ # This sensor should always be available
+ return True
diff --git a/homeassistant/components/miele/strings.json b/homeassistant/components/miele/strings.json
new file mode 100644
index 00000000000..a25d0613a81
--- /dev/null
+++ b/homeassistant/components/miele/strings.json
@@ -0,0 +1,154 @@
+{
+ "application_credentials": {
+ "description": "Navigate to [\"Get involved\" at Miele developer site]({register_url}) to request credentials then enter them below."
+ },
+ "config": {
+ "step": {
+ "confirm": {
+ "description": "[%key:common::config_flow::description::confirm_setup%]"
+ },
+ "pick_implementation": {
+ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The Miele integration needs to re-authenticate your account"
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
+ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
+ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "account_mismatch": "The used account does not match the original account",
+ "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]",
+ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
+ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
+ },
+ "create_entry": {
+ "default": "[%key:common::config_flow::create_entry::authenticated%]"
+ }
+ },
+ "device": {
+ "coffee_system": {
+ "name": "Coffee system"
+ },
+ "dishwasher": {
+ "name": "Dishwasher"
+ },
+ "tumble_dryer": {
+ "name": "Tumble dryer"
+ },
+ "fridge_freezer": {
+ "name": "Fridge freezer"
+ },
+ "induction_hob": {
+ "name": "Induction hob"
+ },
+ "oven": {
+ "name": "Oven"
+ },
+ "oven_microwave": {
+ "name": "Oven microwave"
+ },
+ "hob_highlight": {
+ "name": "Hob highlight"
+ },
+ "steam_oven": {
+ "name": "Steam oven"
+ },
+ "microwave": {
+ "name": "Microwave"
+ },
+ "hood": {
+ "name": "Hood"
+ },
+ "warming_drawer": {
+ "name": "Warming drawer"
+ },
+ "steam_oven_combi": {
+ "name": "Steam oven combi"
+ },
+ "wine_cabinet": {
+ "name": "Wine cabinet"
+ },
+ "wine_conditioning_unit": {
+ "name": "Wine conditioning unit"
+ },
+ "wine_unit": {
+ "name": "Wine unit"
+ },
+ "refrigerator": {
+ "name": "Refrigerator"
+ },
+ "freezer": {
+ "name": "Freezer"
+ },
+ "robot_vacuum_cleander": {
+ "name": "Robot vacuum cleaner"
+ },
+ "steam_oven_microwave": {
+ "name": "Steam oven micro"
+ },
+ "dialog_oven": {
+ "name": "Dialog oven"
+ },
+ "wine_cabinet_freezer": {
+ "name": "Wine cabinet freezer"
+ },
+ "hob_extraction": {
+ "name": "Hob with extraction"
+ },
+ "washer_dryer": {
+ "name": "Washer dryer"
+ },
+ "washing_machine": {
+ "name": "Washing machine"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "status": {
+ "name": "Status",
+ "state": {
+ "autocleaning": "Automatic cleaning",
+ "failure": "Failure",
+ "idle": "[%key:common::state::idle%]",
+ "not_connected": "Not connected",
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]",
+ "pause": "Pause",
+ "program_ended": "Program ended",
+ "program_interrupted": "Program interrupted",
+ "programmed": "Programmed",
+ "rinse_hold": "Rinse hold",
+ "in_use": "In use",
+ "service": "Service",
+ "supercooling": "Supercooling",
+ "supercooling_superfreezing": "Supercooling/superfreezing",
+ "superfreezing": "Superfreezing",
+ "superheating": "Superheating",
+ "waiting_to_start": "Waiting to start"
+ }
+ }
+ }
+ },
+ "exceptions": {
+ "config_entry_auth_failed": {
+ "message": "Authentication failed. Please log in again."
+ },
+ "config_entry_not_ready": {
+ "message": "Error while loading the integration."
+ },
+ "set_switch_error": {
+ "message": "Failed to set state for {entity}."
+ }
+ }
+}
diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py
index db4727ec1ec..f7bc10e31d4 100644
--- a/homeassistant/components/mikrotik/device_tracker.py
+++ b/homeassistant/components/mikrotik/device_tracker.py
@@ -10,7 +10,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -20,7 +20,7 @@ from .coordinator import Device, MikrotikConfigEntry, MikrotikDataUpdateCoordina
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MikrotikConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for Mikrotik component."""
coordinator = config_entry.runtime_data
@@ -54,7 +54,7 @@ async def async_setup_entry(
@callback
def update_items(
coordinator: MikrotikDataUpdateCoordinator,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
tracked: dict[str, MikrotikDataUpdateCoordinatorTracker],
) -> None:
"""Update tracked device state from the hub."""
diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py
index 3cd9247c63a..ba496923a30 100644
--- a/homeassistant/components/mill/climate.py
+++ b/homeassistant/components/mill/climate.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -54,7 +54,9 @@ SET_ROOM_TEMP_SCHEMA = vol.Schema(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill climate."""
if entry.data.get(CONNECTION_TYPE) == LOCAL:
diff --git a/homeassistant/components/mill/number.py b/homeassistant/components/mill/number.py
index b4ef7bdd2c2..8433a9853c6 100644
--- a/homeassistant/components/mill/number.py
+++ b/homeassistant/components/mill/number.py
@@ -8,7 +8,7 @@ from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME, UnitOfPower
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CLOUD, CONNECTION_TYPE, DOMAIN
from .coordinator import MillDataUpdateCoordinator
@@ -16,7 +16,9 @@ from .entity import MillBaseEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill Number."""
if entry.data.get(CONNECTION_TYPE) == CLOUD:
diff --git a/homeassistant/components/mill/sensor.py b/homeassistant/components/mill/sensor.py
index 57eead9be18..3a47cb427d2 100644
--- a/homeassistant/components/mill/sensor.py
+++ b/homeassistant/components/mill/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -146,7 +146,9 @@ SOCKET_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mill sensor."""
if entry.data.get(CONNECTION_TYPE) == LOCAL:
diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py
index 89252a58864..9039c3e9e24 100644
--- a/homeassistant/components/min_max/sensor.py
+++ b/homeassistant/components/min_max/sensor.py
@@ -25,7 +25,10 @@ from homeassistant.const import (
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
@@ -75,7 +78,7 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize min/max/mean config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py
index 55bf96a7b89..d8f60380a6c 100644
--- a/homeassistant/components/minecraft_server/__init__.py
+++ b/homeassistant/components/minecraft_server/__init__.py
@@ -9,15 +9,13 @@ import dns.rdata
import dns.rdataclass
import dns.rdatatype
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, CONF_TYPE, Platform
+from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType
from .const import DOMAIN, KEY_LATENCY, KEY_MOTD
-from .coordinator import MinecraftServerCoordinator
+from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -31,32 +29,18 @@ def load_dnspython_rdata_classes() -> None:
dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, entry: MinecraftServerConfigEntry
+) -> bool:
"""Set up Minecraft Server from a config entry."""
# Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083)
await hass.async_add_executor_job(load_dnspython_rdata_classes)
- # Create API instance.
- api = MinecraftServer(
- hass,
- entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION),
- entry.data[CONF_ADDRESS],
- )
-
- # Initialize API instance.
- try:
- await api.async_initialize()
- except MinecraftServerAddressError as error:
- raise ConfigEntryNotReady(f"Initialization failed: {error}") from error
-
- # Create coordinator instance.
- coordinator = MinecraftServerCoordinator(hass, entry, api)
+ # Create coordinator instance and store it.
+ coordinator = MinecraftServerCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
-
- # Store coordinator instance.
- domain_data = hass.data.setdefault(DOMAIN, {})
- domain_data[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
# Set up platforms.
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -64,21 +48,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
+) -> bool:
"""Unload Minecraft Server config entry."""
-
- # Unload platforms.
- unload_ok = await hass.config_entries.async_unload_platforms(
- config_entry, PLATFORMS
- )
-
- # Clean up.
- hass.data[DOMAIN].pop(config_entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
-async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
+) -> bool:
"""Migrate old config entry to a new format."""
# 1 --> 2: Use config entry ID as base for unique IDs.
@@ -152,7 +131,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
async def _async_migrate_device_identifiers(
- hass: HomeAssistant, config_entry: ConfigEntry, old_unique_id: str | None
+ hass: HomeAssistant,
+ config_entry: MinecraftServerConfigEntry,
+ old_unique_id: str | None,
) -> None:
"""Migrate the device identifiers to the new format."""
device_registry = dr.async_get(hass)
diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py
index 60f2e00da0e..a7279040a6d 100644
--- a/homeassistant/components/minecraft_server/binary_sensor.py
+++ b/homeassistant/components/minecraft_server/binary_sensor.py
@@ -5,12 +5,10 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import MinecraftServerCoordinator
+from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
from .entity import MinecraftServerEntity
KEY_STATUS = "status"
@@ -24,14 +22,17 @@ BINARY_SENSOR_DESCRIPTIONS = [
),
]
+# Coordinator is used to centralize the data updates.
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: MinecraftServerConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Minecraft Server binary sensor platform."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
# Add binary sensor entities.
async_add_entities(
@@ -49,7 +50,7 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit
self,
coordinator: MinecraftServerCoordinator,
description: BinarySensorEntityDescription,
- config_entry: ConfigEntry,
+ config_entry: MinecraftServerConfigEntry,
) -> None:
"""Initialize binary sensor base entity."""
super().__init__(coordinator, config_entry)
diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py
index 3ffdc33f3b2..d0f7cf5a8fb 100644
--- a/homeassistant/components/minecraft_server/config_flow.py
+++ b/homeassistant/components/minecraft_server/config_flow.py
@@ -8,10 +8,10 @@ from typing import Any
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE
+from homeassistant.const import CONF_ADDRESS, CONF_TYPE
from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType
-from .const import DEFAULT_NAME, DOMAIN
+from .const import DOMAIN
DEFAULT_ADDRESS = "localhost:25565"
@@ -37,7 +37,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
# Prepare config entry data.
config_data = {
- CONF_NAME: user_input[CONF_NAME],
CONF_ADDRESS: address,
}
@@ -78,9 +77,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user",
data_schema=vol.Schema(
{
- vol.Required(
- CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)
- ): str,
vol.Required(
CONF_ADDRESS,
default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS),
diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py
index e7a58741696..35a1c0dd5a5 100644
--- a/homeassistant/components/minecraft_server/const.py
+++ b/homeassistant/components/minecraft_server/const.py
@@ -1,7 +1,5 @@
"""Constants for the Minecraft Server integration."""
-DEFAULT_NAME = "Minecraft Server"
-
DOMAIN = "minecraft_server"
KEY_LATENCY = "latency"
diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py
index f66e4acf214..457b0700535 100644
--- a/homeassistant/components/minecraft_server/coordinator.py
+++ b/homeassistant/components/minecraft_server/coordinator.py
@@ -6,17 +6,22 @@ from datetime import timedelta
import logging
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_NAME
+from homeassistant.const import CONF_ADDRESS, CONF_TYPE
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import (
MinecraftServer,
+ MinecraftServerAddressError,
MinecraftServerConnectionError,
MinecraftServerData,
MinecraftServerNotInitializedError,
+ MinecraftServerType,
)
+type MinecraftServerConfigEntry = ConfigEntry[MinecraftServerCoordinator]
+
SCAN_INTERVAL = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
@@ -25,25 +30,40 @@ _LOGGER = logging.getLogger(__name__)
class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]):
"""Minecraft Server data update coordinator."""
- config_entry: ConfigEntry
+ config_entry: MinecraftServerConfigEntry
+ _api: MinecraftServer
def __init__(
self,
hass: HomeAssistant,
- config_entry: ConfigEntry,
- api: MinecraftServer,
+ config_entry: MinecraftServerConfigEntry,
) -> None:
"""Initialize coordinator instance."""
- self._api = api
super().__init__(
hass=hass,
- name=config_entry.data[CONF_NAME],
+ name=config_entry.title,
config_entry=config_entry,
logger=_LOGGER,
update_interval=SCAN_INTERVAL,
)
+ async def _async_setup(self) -> None:
+ """Set up the Minecraft Server data coordinator."""
+
+ # Create API instance.
+ self._api = MinecraftServer(
+ self.hass,
+ self.config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION),
+ self.config_entry.data[CONF_ADDRESS],
+ )
+
+ # Initialize API instance.
+ try:
+ await self._api.async_initialize()
+ except MinecraftServerAddressError as error:
+ raise ConfigEntryNotReady(f"Initialization failed: {error}") from error
+
async def _async_update_data(self) -> MinecraftServerData:
"""Get updated data from the server."""
try:
diff --git a/homeassistant/components/minecraft_server/diagnostics.py b/homeassistant/components/minecraft_server/diagnostics.py
index 0bcffe1434a..dd94411b969 100644
--- a/homeassistant/components/minecraft_server/diagnostics.py
+++ b/homeassistant/components/minecraft_server/diagnostics.py
@@ -5,20 +5,19 @@ from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_ADDRESS, CONF_NAME
+from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
+from .coordinator import MinecraftServerConfigEntry
-TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"}
+TO_REDACT: Iterable[Any] = {CONF_ADDRESS, "players_list"}
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, config_entry: ConfigEntry
+ hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
return {
"config_entry": {
diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json
index d6ade4853c9..be399a3c8dc 100644
--- a/homeassistant/components/minecraft_server/manifest.json
+++ b/homeassistant/components/minecraft_server/manifest.json
@@ -6,5 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/minecraft_server",
"iot_class": "local_polling",
"loggers": ["dnspython", "mcstatus"],
+ "quality_scale": "silver",
"requirements": ["mcstatus==11.1.1"]
}
diff --git a/homeassistant/components/minecraft_server/quality_scale.yaml b/homeassistant/components/minecraft_server/quality_scale.yaml
index fc3db3b3075..288e58fad39 100644
--- a/homeassistant/components/minecraft_server/quality_scale.yaml
+++ b/homeassistant/components/minecraft_server/quality_scale.yaml
@@ -6,22 +6,15 @@ rules:
appropriate-polling: done
brands: done
common-modules: done
- config-flow:
- status: todo
- comment: Check removal and replacement of name in config flow with the title (server address).
- config-flow-test-coverage:
- status: todo
- comment: |
- Merge test_show_config_form with full flow test.
- Move full flow test to the top of all tests.
- All test cases should end in either CREATE_ENTRY or ABORT.
+ config-flow: done
+ config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: Integration doesn't provide any service actions.
docs-high-level-description: done
docs-installation-instructions: done
- docs-removal-instructions: todo
+ docs-removal-instructions: done
entity-event-setup:
status: done
comment: Handled by coordinator.
@@ -29,7 +22,7 @@ rules:
status: done
comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information.
has-entity-name: done
- runtime-data: todo
+ runtime-data: done
test-before-configure: done
test-before-setup:
status: done
@@ -58,11 +51,7 @@ rules:
log-when-unavailable:
status: done
comment: Handled by coordinator.
- parallel-updates:
- status: todo
- comment: |
- Although this is handled by the coordinator and no service actions are provided,
- PARALLEL_UPDATES should still be set to 0 in binary_sensor and sensor according to the rule.
+ parallel-updates: done
reauthentication-flow:
status: exempt
comment: No authentication is required for the integration.
diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py
index fae004a015e..cfc16c7724d 100644
--- a/homeassistant/components/minecraft_server/sensor.py
+++ b/homeassistant/components/minecraft_server/sensor.py
@@ -7,15 +7,14 @@ from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TYPE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .api import MinecraftServerData, MinecraftServerType
-from .const import DOMAIN, KEY_LATENCY, KEY_MOTD
-from .coordinator import MinecraftServerCoordinator
+from .const import KEY_LATENCY, KEY_MOTD
+from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
from .entity import MinecraftServerEntity
ATTR_PLAYERS_LIST = "players_list"
@@ -31,6 +30,9 @@ KEY_VERSION = "version"
UNIT_PLAYERS_MAX = "players"
UNIT_PLAYERS_ONLINE = "players"
+# Coordinator is used to centralize the data updates.
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class MinecraftServerSensorEntityDescription(SensorEntityDescription):
@@ -158,11 +160,11 @@ SENSOR_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: MinecraftServerConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Minecraft Server sensor platform."""
- coordinator = hass.data[DOMAIN][config_entry.entry_id]
+ coordinator = config_entry.runtime_data
# Add sensor entities.
async_add_entities(
@@ -184,7 +186,7 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity):
self,
coordinator: MinecraftServerCoordinator,
description: MinecraftServerSensorEntityDescription,
- config_entry: ConfigEntry,
+ config_entry: MinecraftServerConfigEntry,
) -> None:
"""Initialize sensor base entity."""
super().__init__(coordinator, config_entry)
diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json
index c084c9e6df0..cb4670dcac4 100644
--- a/homeassistant/components/minecraft_server/strings.json
+++ b/homeassistant/components/minecraft_server/strings.json
@@ -2,12 +2,14 @@
"config": {
"step": {
"user": {
- "title": "Link your Minecraft Server",
- "description": "Set up your Minecraft Server instance to allow monitoring.",
"data": {
- "name": "[%key:common::config_flow::data::name%]",
"address": "Server address"
- }
+ },
+ "data_description": {
+ "address": "The hostname, IP address or SRV record of your Minecraft server, optionally including the port."
+ },
+ "title": "Link your Minecraft Server",
+ "description": "Set up your Minecraft Server instance to allow monitoring."
}
},
"abort": {
diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py
index dcb2eff2fd6..c60f1c4d760 100644
--- a/homeassistant/components/mjpeg/camera.py
+++ b/homeassistant/components/mjpeg/camera.py
@@ -27,7 +27,7 @@ from homeassistant.helpers.aiohttp_client import (
async_get_clientsession,
)
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER
@@ -39,7 +39,7 @@ BUFFER_SIZE = 102400
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a MJPEG IP Camera based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/moat/sensor.py b/homeassistant/components/moat/sensor.py
index 66edfbe91f2..e968577d789 100644
--- a/homeassistant/components/moat/sensor.py
+++ b/homeassistant/components/moat/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
@@ -105,7 +105,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Moat BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py
index e19e00b1277..8f8b8d97295 100644
--- a/homeassistant/components/mobile_app/binary_sensor.py
+++ b/homeassistant/components/mobile_app/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.const import CONF_WEBHOOK_ID, STATE_ON
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_SENSOR_ATTRIBUTES,
@@ -28,7 +28,7 @@ from .entity import MobileAppEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up mobile app binary sensor from a config entry."""
entities = []
diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py
index 7e84930e2e9..7e5a0a291b6 100644
--- a/homeassistant/components/mobile_app/device_tracker.py
+++ b/homeassistant/components/mobile_app/device_tracker.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
@@ -33,7 +33,9 @@ ATTR_KEYS = (ATTR_ALTITUDE, ATTR_COURSE, ATTR_SPEED, ATTR_VERTICAL_ACCURACY)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Mobile app based off an entry."""
entity = MobileAppEntity(entry)
diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py
index 06ab924aba2..8200ad1fccd 100644
--- a/homeassistant/components/mobile_app/sensor.py
+++ b/homeassistant/components/mobile_app/sensor.py
@@ -11,7 +11,7 @@ from homeassistant.const import CONF_WEBHOOK_ID, STATE_UNKNOWN, UnitOfTemperatur
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
@@ -36,7 +36,7 @@ from .webhook import _extract_sensor_unique_id
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up mobile app sensor from a config entry."""
entities = []
diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py
index 61df7206402..52642cc32e3 100644
--- a/homeassistant/components/modbus/__init__.py
+++ b/homeassistant/components/modbus/__init__.py
@@ -79,6 +79,16 @@ from .const import (
CONF_FAN_MODE_TOP,
CONF_FAN_MODE_VALUES,
CONF_FANS,
+ CONF_HVAC_ACTION_COOLING,
+ CONF_HVAC_ACTION_DEFROSTING,
+ CONF_HVAC_ACTION_DRYING,
+ CONF_HVAC_ACTION_FAN,
+ CONF_HVAC_ACTION_HEATING,
+ CONF_HVAC_ACTION_IDLE,
+ CONF_HVAC_ACTION_OFF,
+ CONF_HVAC_ACTION_PREHEATING,
+ CONF_HVAC_ACTION_REGISTER,
+ CONF_HVAC_ACTION_VALUES,
CONF_HVAC_MODE_AUTO,
CONF_HVAC_MODE_COOL,
CONF_HVAC_MODE_DRY,
@@ -297,6 +307,45 @@ CLIMATE_SCHEMA = vol.All(
vol.Optional(CONF_WRITE_REGISTERS, default=False): cv.boolean,
}
),
+ vol.Optional(CONF_HVAC_ACTION_REGISTER): vol.Maybe(
+ {
+ CONF_ADDRESS: cv.positive_int,
+ CONF_HVAC_ACTION_VALUES: {
+ vol.Optional(CONF_HVAC_ACTION_COOLING): vol.Any(
+ cv.positive_int, [cv.positive_int]
+ ),
+ vol.Optional(CONF_HVAC_ACTION_DEFROSTING): vol.Any(
+ cv.positive_int, [cv.positive_int]
+ ),
+ vol.Optional(CONF_HVAC_ACTION_DRYING): vol.Any(
+ cv.positive_int, [cv.positive_int]
+ ),
+ vol.Optional(CONF_HVAC_ACTION_FAN): vol.Any(
+ cv.positive_int, [cv.positive_int]
+ ),
+ vol.Optional(CONF_HVAC_ACTION_HEATING): vol.Any(
+ cv.positive_int, [cv.positive_int]
+ ),
+ vol.Optional(CONF_HVAC_ACTION_IDLE): vol.Any(
+ cv.positive_int, [cv.positive_int]
+ ),
+ vol.Optional(CONF_HVAC_ACTION_OFF): vol.Any(
+ cv.positive_int, [cv.positive_int]
+ ),
+ vol.Optional(CONF_HVAC_ACTION_PREHEATING): vol.Any(
+ cv.positive_int, [cv.positive_int]
+ ),
+ },
+ vol.Optional(
+ CONF_INPUT_TYPE, default=CALL_TYPE_REGISTER_HOLDING
+ ): vol.In(
+ [
+ CALL_TYPE_REGISTER_HOLDING,
+ CALL_TYPE_REGISTER_INPUT,
+ ]
+ ),
+ }
+ ),
vol.Optional(CONF_FAN_MODE_REGISTER): vol.Maybe(
vol.All(
{
diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py
index fca1b94611a..be10a9495c6 100644
--- a/homeassistant/components/modbus/climate.py
+++ b/homeassistant/components/modbus/climate.py
@@ -24,6 +24,7 @@ from homeassistant.components.climate import (
SWING_VERTICAL,
ClimateEntity,
ClimateEntityFeature,
+ HVACAction,
HVACMode,
)
from homeassistant.const import (
@@ -61,6 +62,16 @@ from .const import (
CONF_FAN_MODE_REGISTER,
CONF_FAN_MODE_TOP,
CONF_FAN_MODE_VALUES,
+ CONF_HVAC_ACTION_COOLING,
+ CONF_HVAC_ACTION_DEFROSTING,
+ CONF_HVAC_ACTION_DRYING,
+ CONF_HVAC_ACTION_FAN,
+ CONF_HVAC_ACTION_HEATING,
+ CONF_HVAC_ACTION_IDLE,
+ CONF_HVAC_ACTION_OFF,
+ CONF_HVAC_ACTION_PREHEATING,
+ CONF_HVAC_ACTION_REGISTER,
+ CONF_HVAC_ACTION_VALUES,
CONF_HVAC_MODE_AUTO,
CONF_HVAC_MODE_COOL,
CONF_HVAC_MODE_DRY,
@@ -74,6 +85,7 @@ from .const import (
CONF_HVAC_ON_VALUE,
CONF_HVAC_ONOFF_COIL,
CONF_HVAC_ONOFF_REGISTER,
+ CONF_INPUT_TYPE,
CONF_MAX_TEMP,
CONF_MIN_TEMP,
CONF_STEP,
@@ -188,6 +200,34 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
self._attr_hvac_mode = HVACMode.AUTO
self._attr_hvac_modes = [HVACMode.AUTO]
+ if CONF_HVAC_ACTION_REGISTER in config:
+ action_config = config[CONF_HVAC_ACTION_REGISTER]
+ self._hvac_action_register = action_config[CONF_ADDRESS]
+ self._hvac_action_type = action_config[CONF_INPUT_TYPE]
+
+ self._attr_hvac_action = None
+ self._hvac_action_mapping: list[tuple[int, HVACAction]] = []
+ action_value_config = action_config[CONF_HVAC_ACTION_VALUES]
+
+ for hvac_action_kw, hvac_action in (
+ (CONF_HVAC_ACTION_COOLING, HVACAction.COOLING),
+ (CONF_HVAC_ACTION_DEFROSTING, HVACAction.DEFROSTING),
+ (CONF_HVAC_ACTION_DRYING, HVACAction.DRYING),
+ (CONF_HVAC_ACTION_FAN, HVACAction.FAN),
+ (CONF_HVAC_ACTION_HEATING, HVACAction.HEATING),
+ (CONF_HVAC_ACTION_IDLE, HVACAction.IDLE),
+ (CONF_HVAC_ACTION_OFF, HVACAction.OFF),
+ (CONF_HVAC_ACTION_PREHEATING, HVACAction.PREHEATING),
+ ):
+ if hvac_action_kw in action_value_config:
+ values = action_value_config[hvac_action_kw]
+ if not isinstance(values, list):
+ values = [values]
+ for value in values:
+ self._hvac_action_mapping.append((value, hvac_action))
+ else:
+ self._hvac_action_register = None
+
if CONF_FAN_MODE_REGISTER in config:
self._attr_supported_features = (
self._attr_supported_features | ClimateEntityFeature.FAN_MODE
@@ -216,7 +256,6 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
self._fan_mode_mapping_from_modbus[value] = fan_mode
self._fan_mode_mapping_to_modbus[fan_mode] = value
self._attr_fan_modes.append(fan_mode)
-
else:
# No FAN modes defined
self._fan_mode_register = None
@@ -457,6 +496,20 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
self._attr_hvac_mode = mode
break
+ # Read the HVAC action register if defined
+ if self._hvac_action_register is not None:
+ hvac_action = await self._async_read_register(
+ self._hvac_action_type, self._hvac_action_register, raw=True
+ )
+
+ # Translate the value received
+ if hvac_action is not None:
+ self._attr_hvac_action = None
+ for value, action in self._hvac_action_mapping:
+ if hvac_action == value:
+ self._attr_hvac_action = action
+ break
+
# Read the Fan mode register if defined
if self._fan_mode_register is not None:
fan_mode = await self._async_read_register(
diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py
index 5926569040d..634637a6b08 100644
--- a/homeassistant/components/modbus/const.py
+++ b/homeassistant/components/modbus/const.py
@@ -63,6 +63,16 @@ CONF_HVAC_ONOFF_REGISTER = "hvac_onoff_register"
CONF_HVAC_ON_VALUE = "hvac_on_value"
CONF_HVAC_OFF_VALUE = "hvac_off_value"
CONF_HVAC_ONOFF_COIL = "hvac_onoff_coil"
+CONF_HVAC_ACTION_REGISTER = "hvac_action_register"
+CONF_HVAC_ACTION_COOLING = "action_cooling"
+CONF_HVAC_ACTION_DEFROSTING = "action_defrosting"
+CONF_HVAC_ACTION_DRYING = "action_drying"
+CONF_HVAC_ACTION_FAN = "action_fan"
+CONF_HVAC_ACTION_HEATING = "action_heating"
+CONF_HVAC_ACTION_IDLE = "action_idle"
+CONF_HVAC_ACTION_OFF = "action_off"
+CONF_HVAC_ACTION_PREHEATING = "action_preheating"
+CONF_HVAC_ACTION_VALUES = "values"
CONF_HVAC_MODE_OFF = "state_off"
CONF_HVAC_MODE_HEAT = "state_heat"
CONF_HVAC_MODE_COOL = "state_cool"
diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py
index 81cfc3127d1..006ef504590 100644
--- a/homeassistant/components/modbus/modbus.py
+++ b/homeassistant/components/modbus/modbus.py
@@ -384,6 +384,11 @@ class ModbusHub:
{ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1}
)
entry = self._pb_request[use_call]
+
+ if use_call in {"write_registers", "write_coils"}:
+ if not isinstance(value, list):
+ value = [value]
+
kwargs[entry.value_attr_name] = value
try:
result: ModbusPDU = await entry.func(address, **kwargs)
diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json
index 7b55022645e..7d1578558b0 100644
--- a/homeassistant/components/modbus/strings.json
+++ b/homeassistant/components/modbus/strings.json
@@ -2,11 +2,11 @@
"services": {
"reload": {
"name": "[%key:common::action::reload%]",
- "description": "Reloads all modbus entities."
+ "description": "Reloads all Modbus entities."
},
"write_coil": {
"name": "Write coil",
- "description": "Writes to a modbus coil.",
+ "description": "Writes to a Modbus coil.",
"fields": {
"address": {
"name": "Address",
@@ -17,8 +17,8 @@
"description": "State to write."
},
"slave": {
- "name": "Slave",
- "description": "Address of the modbus unit/slave."
+ "name": "Server",
+ "description": "Address of the Modbus unit/server."
},
"hub": {
"name": "Hub",
@@ -28,7 +28,7 @@
},
"write_register": {
"name": "Write register",
- "description": "Writes to a modbus holding register.",
+ "description": "Writes to a Modbus holding register.",
"fields": {
"address": {
"name": "[%key:component::modbus::services::write_coil::fields::address::name%]",
@@ -88,11 +88,11 @@
},
"duplicate_entity_entry": {
"title": "Modbus {sub_1} address {sub_2} is duplicate, second entry not loaded.",
- "description": "An address can only be associated with one entity, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
+ "description": "An address can only be associated with one entity. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
},
"duplicate_entity_name": {
"title": "Modbus {sub_1} is duplicate, second entry not loaded.",
- "description": "A entity name must be unique, Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
+ "description": "An entity name must be unique. Please correct the entry in your configuration.yaml file and restart Home Assistant to fix this issue."
},
"no_entities": {
"title": "Modbus {sub_1} contain no entities, entry not loaded.",
diff --git a/homeassistant/components/modem_callerid/button.py b/homeassistant/components/modem_callerid/button.py
index 3cad9062be9..954a638818d 100644
--- a/homeassistant/components/modem_callerid/button.py
+++ b/homeassistant/components/modem_callerid/button.py
@@ -9,13 +9,15 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_KEY_API, DOMAIN
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Modem Caller ID sensor."""
api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API]
diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py
index 00c821f3511..de8e4b2f73c 100644
--- a/homeassistant/components/modem_callerid/sensor.py
+++ b/homeassistant/components/modem_callerid/sensor.py
@@ -9,13 +9,15 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CID, DATA_KEY_API, DOMAIN
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Modem Caller ID sensor."""
api = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API]
diff --git a/homeassistant/components/modern_forms/binary_sensor.py b/homeassistant/components/modern_forms/binary_sensor.py
index ea903c580a4..2bba85f54d7 100644
--- a/homeassistant/components/modern_forms/binary_sensor.py
+++ b/homeassistant/components/modern_forms/binary_sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import CLEAR_TIMER, DOMAIN
@@ -16,7 +16,7 @@ from .entity import ModernFormsDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Modern Forms binary sensors."""
coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py
index 988edcb60e5..26c69b28a5c 100644
--- a/homeassistant/components/modern_forms/fan.py
+++ b/homeassistant/components/modern_forms/fan.py
@@ -11,7 +11,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -35,7 +35,7 @@ from .entity import ModernFormsDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Modern Forms platform from config entry."""
diff --git a/homeassistant/components/modern_forms/light.py b/homeassistant/components/modern_forms/light.py
index 2b53a414cea..6216efe3ff4 100644
--- a/homeassistant/components/modern_forms/light.py
+++ b/homeassistant/components/modern_forms/light.py
@@ -11,7 +11,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -36,7 +36,7 @@ BRIGHTNESS_RANGE = (1, 255)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Modern Forms platform from config entry."""
diff --git a/homeassistant/components/modern_forms/sensor.py b/homeassistant/components/modern_forms/sensor.py
index 0f1e90cbe52..aa7d163cfdc 100644
--- a/homeassistant/components/modern_forms/sensor.py
+++ b/homeassistant/components/modern_forms/sensor.py
@@ -7,7 +7,7 @@ from datetime import datetime
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
@@ -19,7 +19,7 @@ from .entity import ModernFormsDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Modern Forms sensor based on a config entry."""
coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/modern_forms/switch.py b/homeassistant/components/modern_forms/switch.py
index f2e8b1b705c..89a5b779d74 100644
--- a/homeassistant/components/modern_forms/switch.py
+++ b/homeassistant/components/modern_forms/switch.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import modernforms_exception_handler
from .const import DOMAIN
@@ -18,7 +18,7 @@ from .entity import ModernFormsDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Modern Forms switch based on a config entry."""
coordinator: ModernFormsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py
index 1e7018ff1c7..a7479aef5e8 100644
--- a/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py
+++ b/homeassistant/components/moehlenhoff_alpha2/binary_sensor.py
@@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -17,7 +17,7 @@ from .coordinator import Alpha2BaseCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Alpha2 sensor entities from a config_entry."""
diff --git a/homeassistant/components/moehlenhoff_alpha2/button.py b/homeassistant/components/moehlenhoff_alpha2/button.py
index c7ac574724a..57f9d0e31a2 100644
--- a/homeassistant/components/moehlenhoff_alpha2/button.py
+++ b/homeassistant/components/moehlenhoff_alpha2/button.py
@@ -4,7 +4,7 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -15,7 +15,7 @@ from .coordinator import Alpha2BaseCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Alpha2 button entities."""
diff --git a/homeassistant/components/moehlenhoff_alpha2/climate.py b/homeassistant/components/moehlenhoff_alpha2/climate.py
index 7c24dad4469..85d5939049e 100644
--- a/homeassistant/components/moehlenhoff_alpha2/climate.py
+++ b/homeassistant/components/moehlenhoff_alpha2/climate.py
@@ -12,7 +12,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, PRESET_AUTO, PRESET_DAY, PRESET_NIGHT
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Alpha2Climate entities from a config_entry."""
diff --git a/homeassistant/components/moehlenhoff_alpha2/manifest.json b/homeassistant/components/moehlenhoff_alpha2/manifest.json
index 14f40991a84..45b7f8c9565 100644
--- a/homeassistant/components/moehlenhoff_alpha2/manifest.json
+++ b/homeassistant/components/moehlenhoff_alpha2/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/moehlenhoff_alpha2",
"iot_class": "local_push",
- "requirements": ["moehlenhoff-alpha2==1.3.1"]
+ "requirements": ["moehlenhoff-alpha2==1.4.0"]
}
diff --git a/homeassistant/components/moehlenhoff_alpha2/sensor.py b/homeassistant/components/moehlenhoff_alpha2/sensor.py
index 5286257ff61..306e80e54d3 100644
--- a/homeassistant/components/moehlenhoff_alpha2/sensor.py
+++ b/homeassistant/components/moehlenhoff_alpha2/sensor.py
@@ -4,7 +4,7 @@ from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -14,7 +14,7 @@ from .coordinator import Alpha2BaseCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Alpha2 sensor entities from a config_entry."""
diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py
index 750ddce8513..451cc65fb55 100644
--- a/homeassistant/components/mold_indicator/sensor.py
+++ b/homeassistant/components/mold_indicator/sensor.py
@@ -36,7 +36,10 @@ from homeassistant.core import (
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_device_info_to_link_from_entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -105,7 +108,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mold indicator sensor entry."""
name: str = entry.options[CONF_NAME]
diff --git a/homeassistant/components/monarch_money/sensor.py b/homeassistant/components/monarch_money/sensor.py
index e0dff7d565c..1597d9820a1 100644
--- a/homeassistant/components/monarch_money/sensor.py
+++ b/homeassistant/components/monarch_money/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CURRENCY_DOLLAR, PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import MonarchMoneyConfigEntry
@@ -110,7 +110,7 @@ MONARCH_CASHFLOW_SENSORS: tuple[MonarchMoneyCashflowSensorEntityDescription, ...
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MonarchMoneyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Monarch Money sensors for config entries."""
mm_coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py
index 2dde0832440..9d678c16874 100644
--- a/homeassistant/components/monoprice/media_player.py
+++ b/homeassistant/components/monoprice/media_player.py
@@ -16,7 +16,7 @@ from homeassistant.const import CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform, service
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_SOURCES,
@@ -58,7 +58,7 @@ def _get_sources(config_entry):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Monoprice 6-zone amplifier platform."""
port = config_entry.data[CONF_PORT]
diff --git a/homeassistant/components/monzo/sensor.py b/homeassistant/components/monzo/sensor.py
index 41b97d90452..0b6ab2b70a5 100644
--- a/homeassistant/components/monzo/sensor.py
+++ b/homeassistant/components/monzo/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import MonzoCoordinator
@@ -65,7 +65,7 @@ MODEL_POT = "Pot"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
coordinator: MonzoCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py
index 09048579859..12d0ff3ed41 100644
--- a/homeassistant/components/moon/sensor.py
+++ b/homeassistant/components/moon/sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -26,7 +26,7 @@ STATE_WAXING_GIBBOUS = "waxing_gibbous"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform from config_entry."""
async_add_entities([MoonSensorEntity(entry)], True)
diff --git a/homeassistant/components/mopeka/sensor.py b/homeassistant/components/mopeka/sensor.py
index 0f67efaea1e..53c93f771f2 100644
--- a/homeassistant/components/mopeka/sensor.py
+++ b/homeassistant/components/mopeka/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from . import MopekaConfigEntry
@@ -115,7 +115,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: MopekaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Mopeka BLE sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/mopeka/strings.json b/homeassistant/components/mopeka/strings.json
index 2455eea2f76..23feb554772 100644
--- a/homeassistant/components/mopeka/strings.json
+++ b/homeassistant/components/mopeka/strings.json
@@ -6,7 +6,7 @@
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]",
- "medium_type": "Medium Type"
+ "medium_type": "Medium type"
}
},
"bluetooth_confirm": {
diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py
index fa1664353e1..2abcc273e23 100644
--- a/homeassistant/components/motion_blinds/__init__.py
+++ b/homeassistant/components/motion_blinds/__init__.py
@@ -6,12 +6,13 @@ from typing import TYPE_CHECKING
from motionblinds import AsyncMotionMulticast
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import (
+ CONF_BLIND_TYPE_LIST,
CONF_INTERFACE,
CONF_WAIT_FOR_PUSH,
DEFAULT_INTERFACE,
@@ -39,6 +40,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
key = entry.data[CONF_API_KEY]
multicast_interface = entry.data.get(CONF_INTERFACE, DEFAULT_INTERFACE)
wait_for_push = entry.options.get(CONF_WAIT_FOR_PUSH, DEFAULT_WAIT_FOR_PUSH)
+ blind_type_list = entry.data.get(CONF_BLIND_TYPE_LIST)
# Create multicast Listener
async with setup_lock:
@@ -81,7 +83,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# Connect to motion gateway
multicast = hass.data[DOMAIN][KEY_MULTICAST_LISTENER]
connect_gateway_class = ConnectMotionGateway(hass, multicast)
- if not await connect_gateway_class.async_connect_gateway(host, key):
+ if not await connect_gateway_class.async_connect_gateway(
+ host, key, blind_type_list
+ ):
raise ConfigEntryNotReady
motion_gateway = connect_gateway_class.gateway_device
api_lock = asyncio.Lock()
@@ -95,6 +99,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass, entry, _LOGGER, coordinator_info
)
+ # store blind type list for next time
+ if entry.data.get(CONF_BLIND_TYPE_LIST) != motion_gateway.blind_type_list:
+ data = {
+ **entry.data,
+ CONF_BLIND_TYPE_LIST: motion_gateway.blind_type_list,
+ }
+ hass.config_entries.async_update_entry(entry, data=data)
+
# Fetch initial data so we have data when entities subscribe
await coordinator.async_config_entry_first_refresh()
@@ -124,12 +136,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
multicast.Unregister_motion_gateway(config_entry.data[CONF_HOST])
hass.data[DOMAIN].pop(config_entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# No motion gateways left, stop Motion multicast
unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP)
unsub_stop()
diff --git a/homeassistant/components/motion_blinds/button.py b/homeassistant/components/motion_blinds/button.py
index 89841bf8fd4..09f29e09c70 100644
--- a/homeassistant/components/motion_blinds/button.py
+++ b/homeassistant/components/motion_blinds/button.py
@@ -8,7 +8,7 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
from .coordinator import DataUpdateCoordinatorMotionBlinds
@@ -18,7 +18,7 @@ from .entity import MotionCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Motionblinds."""
entities: list[ButtonEntity] = []
diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py
index d8d1e7c21f1..954f9e25c21 100644
--- a/homeassistant/components/motion_blinds/config_flow.py
+++ b/homeassistant/components/motion_blinds/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import logging
from typing import Any
from motionblinds import MotionDiscovery, MotionGateway
@@ -28,6 +29,8 @@ from .const import (
)
from .gateway import ConnectMotionGateway
+_LOGGER = logging.getLogger(__name__)
+
CONFIG_SCHEMA = vol.Schema(
{
vol.Optional(CONF_HOST): str,
@@ -93,7 +96,8 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN):
try:
# key not needed for GetDeviceList request
await self.hass.async_add_executor_job(gateway.GetDeviceList)
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Failed to connect to Motion Gateway")
return self.async_abort(reason="not_motionblinds")
if not gateway.available:
@@ -156,6 +160,7 @@ class MotionBlindsFlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
key = user_input[CONF_API_KEY]
+ assert self._host
connect_gateway_class = ConnectMotionGateway(self.hass)
if not await connect_gateway_class.async_connect_gateway(self._host, key):
diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py
index 96067d7ceb0..950fa3ab4c7 100644
--- a/homeassistant/components/motion_blinds/const.py
+++ b/homeassistant/components/motion_blinds/const.py
@@ -8,6 +8,7 @@ DEFAULT_GATEWAY_NAME = "Motionblinds Gateway"
PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SENSOR]
+CONF_BLIND_TYPE_LIST = "blind_type_list"
CONF_WAIT_FOR_PUSH = "wait_for_push"
CONF_INTERFACE = "interface"
DEFAULT_WAIT_FOR_PUSH = False
diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py
index 1ea3a6ed9d6..dbf43e3d30f 100644
--- a/homeassistant/components/motion_blinds/cover.py
+++ b/homeassistant/components/motion_blinds/cover.py
@@ -18,7 +18,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .const import (
@@ -83,7 +83,7 @@ SET_ABSOLUTE_POSITION_SCHEMA: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Motion Blind from a config entry."""
entities: list[MotionBaseDevice] = []
diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py
index 44f7caa74b2..9826557919c 100644
--- a/homeassistant/components/motion_blinds/gateway.py
+++ b/homeassistant/components/motion_blinds/gateway.py
@@ -42,11 +42,16 @@ class ConnectMotionGateway:
for blind in self.gateway_device.device_list.values():
blind.Update_from_cache()
- async def async_connect_gateway(self, host, key):
+ async def async_connect_gateway(
+ self,
+ host: str,
+ key: str,
+ blind_type_list: dict[str, int] | None = None,
+ ) -> bool:
"""Connect to the Motion Gateway."""
_LOGGER.debug("Initializing with host %s (key %s)", host, key[:3])
self._gateway_device = MotionGateway(
- ip=host, key=key, multicast=self._multicast
+ ip=host, key=key, multicast=self._multicast, blind_type_list=blind_type_list
)
try:
# update device info and get the connected sub devices
diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json
index b327c146300..1654d5b5937 100644
--- a/homeassistant/components/motion_blinds/manifest.json
+++ b/homeassistant/components/motion_blinds/manifest.json
@@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/motion_blinds",
"iot_class": "local_push",
"loggers": ["motionblinds"],
- "requirements": ["motionblinds==0.6.25"]
+ "requirements": ["motionblinds==0.6.26"]
}
diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py
index 6418cebda0c..60d283aa0b6 100644
--- a/homeassistant/components/motion_blinds/sensor.py
+++ b/homeassistant/components/motion_blinds/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.const import (
EntityCategory,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY
from .entity import MotionCoordinatorEntity
@@ -26,7 +26,7 @@ ATTR_BATTERY_VOLTAGE = "battery_voltage"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Motionblinds."""
entities: list[SensorEntity] = []
diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json
index ddbf928462a..12060cd69f0 100644
--- a/homeassistant/components/motion_blinds/strings.json
+++ b/homeassistant/components/motion_blinds/strings.json
@@ -3,20 +3,20 @@
"flow_title": "{short_mac} ({ip_address})",
"step": {
"user": {
- "description": "Connect to your Motion Gateway, if the IP address is not set, auto-discovery is used",
+ "description": "Connect to your Motionblinds gateway. If the IP address is not set, auto-discovery is used",
"data": {
"host": "[%key:common::config_flow::data::ip%]"
}
},
"connect": {
- "description": "You will need the 16 character API Key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-key for instructions",
+ "description": "You will need the 16 character API key, see https://www.home-assistant.io/integrations/motion_blinds/#retrieving-the-api-key for instructions",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
},
"select": {
- "title": "Select the Motion Gateway that you wish to connect",
- "description": "Run the setup again if you want to connect additional Motion Gateways",
+ "title": "Select the Motionblinds gateway that you wish to connect",
+ "description": "Run the setup again if you want to connect additional Motionblinds gateways",
"data": {
"select_ip": "[%key:common::config_flow::data::ip%]"
}
@@ -29,7 +29,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"connection_error": "[%key:common::config_flow::error::cannot_connect%]",
- "not_motionblinds": "Discovered device is not a Motion gateway"
+ "not_motionblinds": "Discovered device is not a Motionblinds gateway"
}
},
"options": {
diff --git a/homeassistant/components/motionblinds_ble/button.py b/homeassistant/components/motionblinds_ble/button.py
index a099276cd85..12fb6c7a513 100644
--- a/homeassistant/components/motionblinds_ble/button.py
+++ b/homeassistant/components/motionblinds_ble/button.py
@@ -13,7 +13,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_CONNECT, ATTR_DISCONNECT, ATTR_FAVORITE, CONF_MAC_CODE, DOMAIN
from .entity import MotionblindsBLEEntity
@@ -53,7 +53,9 @@ BUTTON_TYPES: list[MotionblindsBLEButtonEntityDescription] = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button entities based on a config entry."""
diff --git a/homeassistant/components/motionblinds_ble/cover.py b/homeassistant/components/motionblinds_ble/cover.py
index afeeb5b0d70..beaee8598b5 100644
--- a/homeassistant/components/motionblinds_ble/cover.py
+++ b/homeassistant/components/motionblinds_ble/cover.py
@@ -19,7 +19,7 @@ from homeassistant.components.cover import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_BLIND_TYPE, CONF_MAC_CODE, DOMAIN, ICON_VERTICAL_BLIND
from .entity import MotionblindsBLEEntity
@@ -61,7 +61,9 @@ BLIND_TYPE_TO_ENTITY_DESCRIPTION: dict[str, MotionblindsBLECoverEntityDescriptio
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up cover entity based on a config entry."""
diff --git a/homeassistant/components/motionblinds_ble/select.py b/homeassistant/components/motionblinds_ble/select.py
index c297c887910..976f51a0a0f 100644
--- a/homeassistant/components/motionblinds_ble/select.py
+++ b/homeassistant/components/motionblinds_ble/select.py
@@ -11,7 +11,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_SPEED, CONF_MAC_CODE, DOMAIN
from .entity import MotionblindsBLEEntity
@@ -32,7 +32,9 @@ SELECT_TYPES: dict[str, SelectEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up select entities based on a config entry."""
diff --git a/homeassistant/components/motionblinds_ble/sensor.py b/homeassistant/components/motionblinds_ble/sensor.py
index 740a0509a9e..8993a3b1cd5 100644
--- a/homeassistant/components/motionblinds_ble/sensor.py
+++ b/homeassistant/components/motionblinds_ble/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
EntityCategory,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
@@ -92,7 +92,9 @@ SENSORS: tuple[MotionblindsBLESensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor entities based on a config entry."""
diff --git a/homeassistant/components/motionblinds_ble/strings.json b/homeassistant/components/motionblinds_ble/strings.json
index d6532f12386..4589c2d873b 100644
--- a/homeassistant/components/motionblinds_ble/strings.json
+++ b/homeassistant/components/motionblinds_ble/strings.json
@@ -1,8 +1,8 @@
{
"config": {
"abort": {
- "no_bluetooth_adapter": "No bluetooth adapter found",
- "no_devices_found": "Could not find any bluetooth devices"
+ "no_bluetooth_adapter": "No Bluetooth adapter found",
+ "no_devices_found": "Could not find any Bluetooth devices"
},
"error": {
"could_not_find_motor": "Could not find a motor with that MAC code",
@@ -62,9 +62,9 @@
"speed": {
"name": "Speed",
"state": {
- "1": "Low",
- "2": "Medium",
- "3": "High"
+ "1": "[%key:common::state::low%]",
+ "2": "[%key:common::state::medium%]",
+ "3": "[%key:common::state::high%]"
}
}
},
@@ -72,8 +72,8 @@
"connection": {
"name": "Connection status",
"state": {
- "connected": "Connected",
- "disconnected": "Disconnected",
+ "connected": "[%key:common::state::connected%]",
+ "disconnected": "[%key:common::state::disconnected%]",
"connecting": "Connecting",
"disconnecting": "Disconnecting"
}
diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py
index df4c321037e..159956277a8 100644
--- a/homeassistant/components/motioneye/camera.py
+++ b/homeassistant/components/motioneye/camera.py
@@ -42,7 +42,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import get_camera_from_cameras, is_acceptable_camera, listen_for_new_cameras
@@ -93,7 +93,9 @@ SCHEMA_SERVICE_SET_TEXT = vol.Schema(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/motioneye/sensor.py b/homeassistant/components/motioneye/sensor.py
index e0113544848..c160b77c16a 100644
--- a/homeassistant/components/motioneye/sensor.py
+++ b/homeassistant/components/motioneye/sensor.py
@@ -12,7 +12,7 @@ from motioneye_client.const import KEY_ACTIONS
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -24,7 +24,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/motioneye/switch.py b/homeassistant/components/motioneye/switch.py
index 9d704f17740..89d3b8a8727 100644
--- a/homeassistant/components/motioneye/switch.py
+++ b/homeassistant/components/motioneye/switch.py
@@ -19,7 +19,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import get_camera_from_cameras, listen_for_new_cameras
@@ -67,7 +67,9 @@ MOTIONEYE_SWITCHES = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up motionEye from a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/motionmount/binary_sensor.py b/homeassistant/components/motionmount/binary_sensor.py
index f19af67e198..4bb880311f9 100644
--- a/homeassistant/components/motionmount/binary_sensor.py
+++ b/homeassistant/components/motionmount/binary_sensor.py
@@ -6,17 +6,20 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MotionMountConfigEntry
from .entity import MotionMountEntity
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
entry: MotionMountConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Vogel's MotionMount from a config entry."""
mm = entry.runtime_data
@@ -29,6 +32,8 @@ class MotionMountMovingSensor(MotionMountEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.MOVING
_attr_translation_key = "motionmount_is_moving"
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_entity_registry_enabled_default = False
def __init__(
self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry
diff --git a/homeassistant/components/motionmount/icons.json b/homeassistant/components/motionmount/icons.json
new file mode 100644
index 00000000000..8d6d867f4d0
--- /dev/null
+++ b/homeassistant/components/motionmount/icons.json
@@ -0,0 +1,12 @@
+{
+ "entity": {
+ "sensor": {
+ "motionmount_error_status": {
+ "default": "mdi:alert-circle-outline",
+ "state": {
+ "none": "mdi:check-circle-outline"
+ }
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/motionmount/manifest.json b/homeassistant/components/motionmount/manifest.json
index 2665836ffd4..337ce776b33 100644
--- a/homeassistant/components/motionmount/manifest.json
+++ b/homeassistant/components/motionmount/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "motionmount",
"name": "Vogel's MotionMount",
- "codeowners": ["@RJPoelstra"],
+ "codeowners": ["@laiho-vogels"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/motionmount",
"integration_type": "device",
diff --git a/homeassistant/components/motionmount/number.py b/homeassistant/components/motionmount/number.py
index 6305820174f..3e2c1b067aa 100644
--- a/homeassistant/components/motionmount/number.py
+++ b/homeassistant/components/motionmount/number.py
@@ -8,17 +8,19 @@ from homeassistant.components.number import NumberEntity
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MotionMountConfigEntry
from .const import DOMAIN
from .entity import MotionMountEntity
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
entry: MotionMountConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Vogel's MotionMount from a config entry."""
mm = entry.runtime_data
diff --git a/homeassistant/components/motionmount/quality_scale.yaml b/homeassistant/components/motionmount/quality_scale.yaml
index e4a6a04ceeb..8b210931eaf 100644
--- a/homeassistant/components/motionmount/quality_scale.yaml
+++ b/homeassistant/components/motionmount/quality_scale.yaml
@@ -37,7 +37,7 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
- parallel-updates: todo
+ parallel-updates: done
reauthentication-flow: done
test-coverage: todo
@@ -56,12 +56,12 @@ rules:
dynamic-devices:
status: exempt
comment: Single device per config entry
- entity-category: todo
- entity-device-class: todo
- entity-disabled-by-default: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
entity-translations: done
exception-translations: done
- icon-translations: todo
+ icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
diff --git a/homeassistant/components/motionmount/select.py b/homeassistant/components/motionmount/select.py
index 31c5056b91f..861faa319cd 100644
--- a/homeassistant/components/motionmount/select.py
+++ b/homeassistant/components/motionmount/select.py
@@ -9,7 +9,7 @@ import motionmount
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MotionMountConfigEntry
from .const import DOMAIN, WALL_PRESET_NAME
@@ -17,12 +17,13 @@ from .entity import MotionMountEntity
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=60)
+PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: MotionMountConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Vogel's MotionMount from a config entry."""
mm = entry.runtime_data
@@ -45,6 +46,7 @@ class MotionMountPresets(MotionMountEntity, SelectEntity):
super().__init__(mm, config_entry)
self._attr_unique_id = f"{self._base_unique_id}-preset"
self._presets: list[motionmount.Preset] = []
+ self._attr_current_option = None
def _update_options(self, presets: list[motionmount.Preset]) -> None:
"""Convert presets to select options."""
diff --git a/homeassistant/components/motionmount/sensor.py b/homeassistant/components/motionmount/sensor.py
index 685c3ebf932..28fe921d9ac 100644
--- a/homeassistant/components/motionmount/sensor.py
+++ b/homeassistant/components/motionmount/sensor.py
@@ -6,12 +6,15 @@ import motionmount
from motionmount import MotionMountSystemError
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
+from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import MotionMountConfigEntry
from .entity import MotionMountEntity
+PARALLEL_UPDATES = 0
+
ERROR_MESSAGES: Final = {
MotionMountSystemError.MotorError: "motor",
MotionMountSystemError.ObstructionDetected: "obstruction",
@@ -24,7 +27,7 @@ ERROR_MESSAGES: Final = {
async def async_setup_entry(
hass: HomeAssistant,
entry: MotionMountConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Vogel's MotionMount from a config entry."""
mm = entry.runtime_data
@@ -45,6 +48,8 @@ class MotionMountErrorStatusSensor(MotionMountEntity, SensorEntity):
"internal",
]
_attr_translation_key = "motionmount_error_status"
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_entity_registry_enabled_default = False
def __init__(
self, mm: motionmount.MotionMount, config_entry: MotionMountConfigEntry
diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py
index db3901016f7..14b69e941b7 100644
--- a/homeassistant/components/mpd/media_player.py
+++ b/homeassistant/components/mpd/media_player.py
@@ -31,7 +31,7 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN, LOGGER
@@ -68,7 +68,9 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up media player from config_entry."""
diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py
index 6656afe2c8a..ae010bf18c9 100644
--- a/homeassistant/components/mqtt/__init__.py
+++ b/homeassistant/components/mqtt/__init__.py
@@ -13,7 +13,7 @@ import voluptuous as vol
from homeassistant import config as conf_util
from homeassistant.components import websocket_api
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_DISCOVERY, SERVICE_RELOAD
+from homeassistant.const import CONF_DISCOVERY, CONF_PLATFORM, SERVICE_RELOAD
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import (
ConfigValidationError,
@@ -81,6 +81,7 @@ from .const import (
ENTRY_OPTION_FIELDS,
MQTT_CONNECTION_STATE,
TEMPLATE_ERRORS,
+ Platform,
)
from .models import (
DATA_MQTT,
@@ -293,6 +294,21 @@ async def async_check_config_schema(
) from exc
+def _platforms_in_use(hass: HomeAssistant, entry: ConfigEntry) -> set[str | Platform]:
+ """Return a set of platforms in use."""
+ domains: set[str | Platform] = {
+ entry.domain
+ for entry in er.async_entries_for_config_entry(
+ er.async_get(hass), entry.entry_id
+ )
+ }
+ # Update with domains from subentries
+ for subentry in entry.subentries.values():
+ components = subentry.data["components"].values()
+ domains.update(component[CONF_PLATFORM] for component in components)
+ return domains
+
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the actions and websocket API for the MQTT component."""
@@ -434,12 +450,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
mqtt_data, conf = await _setup_client()
platforms_used = platforms_from_config(mqtt_data.config)
- platforms_used.update(
- entry.domain
- for entry in er.async_entries_for_config_entry(
- er.async_get(hass), entry.entry_id
- )
- )
+ platforms_used.update(_platforms_in_use(hass, entry))
integration = async_get_loaded_integration(hass, DOMAIN)
# Preload platforms we know we are going to use so
# discovery can setup each platform synchronously
diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py
index 584b238b3a8..f0d000f79db 100644
--- a/homeassistant/components/mqtt/abbreviations.py
+++ b/homeassistant/components/mqtt/abbreviations.py
@@ -56,6 +56,7 @@ ABBREVIATIONS = {
"ent_pic": "entity_picture",
"evt_typ": "event_types",
"fanspd_lst": "fan_speed_list",
+ "flsh": "flash",
"flsh_tlng": "flash_time_long",
"flsh_tsht": "flash_time_short",
"fx_cmd_tpl": "effect_command_template",
@@ -150,6 +151,7 @@ ABBREVIATIONS = {
"pl_rst_pct": "payload_reset_percentage",
"pl_rst_pr_mode": "payload_reset_preset_mode",
"pl_stop": "payload_stop",
+ "pl_stop_tilt": "payload_stop_tilt",
"pl_strt": "payload_start",
"pl_ret": "payload_return_to_base",
"pl_toff": "payload_turn_off",
@@ -218,10 +220,16 @@ ABBREVIATIONS = {
"sup_vol": "support_volume_set",
"sup_feat": "supported_features",
"sup_clrm": "supported_color_modes",
+ "swing_h_mode_cmd_tpl": "swing_horizontal_mode_command_template",
+ "swing_h_mode_cmd_t": "swing_horizontal_mode_command_topic",
+ "swing_h_mode_stat_tpl": "swing_horizontal_mode_state_template",
+ "swing_h_mode_stat_t": "swing_horizontal_mode_state_topic",
+ "swing_h_modes": "swing_horizontal_modes",
"swing_mode_cmd_tpl": "swing_mode_command_template",
"swing_mode_cmd_t": "swing_mode_command_topic",
"swing_mode_stat_tpl": "swing_mode_state_template",
"swing_mode_stat_t": "swing_mode_state_topic",
+ "swing_modes": "swing_modes",
"temp_cmd_tpl": "temperature_command_template",
"temp_cmd_t": "temperature_command_topic",
"temp_hi_cmd_tpl": "temperature_high_command_template",
@@ -246,6 +254,7 @@ ABBREVIATIONS = {
"tilt_status_tpl": "tilt_status_template",
"tit": "title",
"t": "topic",
+ "trns": "transition",
"uniq_id": "unique_id",
"unit_of_meas": "unit_of_measurement",
"url_t": "url_topic",
diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py
index 7bdc13d0522..64b1a6b05fa 100644
--- a/homeassistant/components/mqtt/alarm_control_panel.py
+++ b/homeassistant/components/mqtt/alarm_control_panel.py
@@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_CODE, CONF_NAME, CONF_VALUE_TEMPLATE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from . import subscription
@@ -115,7 +115,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT alarm control panel through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/async_client.py b/homeassistant/components/mqtt/async_client.py
index 5f90136df44..0467eb3a289 100644
--- a/homeassistant/components/mqtt/async_client.py
+++ b/homeassistant/components/mqtt/async_client.py
@@ -6,7 +6,14 @@ from functools import lru_cache
from types import TracebackType
from typing import Self
-from paho.mqtt.client import Client as MQTTClient
+from paho.mqtt.client import (
+ CallbackOnConnect_v2,
+ CallbackOnDisconnect_v2,
+ CallbackOnPublish_v2,
+ CallbackOnSubscribe_v2,
+ CallbackOnUnsubscribe_v2,
+ Client as MQTTClient,
+)
_MQTT_LOCK_COUNT = 7
@@ -44,6 +51,12 @@ class AsyncMQTTClient(MQTTClient):
that is not needed since we are running in an async event loop.
"""
+ on_connect: CallbackOnConnect_v2
+ on_disconnect: CallbackOnDisconnect_v2
+ on_publish: CallbackOnPublish_v2
+ on_subscribe: CallbackOnSubscribe_v2
+ on_unsubscribe: CallbackOnUnsubscribe_v2
+
def setup(self) -> None:
"""Set up the client.
diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py
index d736123eae8..a1e146d4e36 100644
--- a/homeassistant/components/mqtt/binary_sensor.py
+++ b/homeassistant/components/mqtt/binary_sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, event as evt
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
@@ -69,7 +69,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT binary sensor through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py
index b6056c2efd9..5b2bcc8920f 100644
--- a/homeassistant/components/mqtt/button.py
+++ b/homeassistant/components/mqtt/button.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA
@@ -43,7 +43,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT button through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py
index 88fabad0446..d3615edcbba 100644
--- a/homeassistant/components/mqtt/camera.py
+++ b/homeassistant/components/mqtt/camera.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import subscription
@@ -60,7 +60,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT camera through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py
index 3aca566dbfc..f6f53599363 100644
--- a/homeassistant/components/mqtt/client.py
+++ b/homeassistant/components/mqtt/client.py
@@ -15,6 +15,7 @@ import socket
import ssl
import time
from typing import TYPE_CHECKING, Any
+from uuid import uuid4
import certifi
@@ -292,27 +293,44 @@ class MqttClientSetup:
"""
# We don't import on the top because some integrations
# should be able to optionally rely on MQTT.
- import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
+ from paho.mqtt import client as mqtt # pylint: disable=import-outside-toplevel
# pylint: disable-next=import-outside-toplevel
from .async_client import AsyncMQTTClient
config = self._config
+ clean_session: bool | None = None
if (protocol := config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)) == PROTOCOL_31:
proto = mqtt.MQTTv31
+ clean_session = True
elif protocol == PROTOCOL_5:
proto = mqtt.MQTTv5
else:
proto = mqtt.MQTTv311
+ clean_session = True
if (client_id := config.get(CONF_CLIENT_ID)) is None:
- # PAHO MQTT relies on the MQTT server to generate random client IDs.
- # However, that feature is not mandatory so we generate our own.
- client_id = None
+ # PAHO MQTT relies on the MQTT server to generate random client ID
+ # for protocol version 3.1, however, that feature is not mandatory
+ # so we generate our own.
+ client_id = mqtt._base62(uuid4().int, padding=22) # noqa: SLF001
transport: str = config.get(CONF_TRANSPORT, DEFAULT_TRANSPORT)
self._client = AsyncMQTTClient(
- mqtt.CallbackAPIVersion.VERSION1,
- client_id,
+ callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
+ client_id=client_id,
+ # See: https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html
+ # clean_session (bool defaults to None)
+ # a boolean that determines the client type.
+ # If True, the broker will remove all information about this client when it
+ # disconnects. If False, the client is a persistent client and subscription
+ # information and queued messages will be retained when the client
+ # disconnects. Note that a client will never discard its own outgoing
+ # messages on disconnect. Calling connect() or reconnect() will cause the
+ # messages to be resent. Use reinitialise() to reset a client to its
+ # original state. The clean_session argument only applies to MQTT versions
+ # v3.1.1 and v3.1. It is not accepted if the MQTT version is v5.0 - use the
+ # clean_start argument on connect() instead.
+ clean_session=clean_session,
protocol=proto,
transport=transport, # type: ignore[arg-type]
reconnect_on_failure=False,
@@ -371,6 +389,7 @@ class MQTT:
self.loop = hass.loop
self.config_entry = config_entry
self.conf = conf
+ self.is_mqttv5 = conf.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) == PROTOCOL_5
self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict(
set
@@ -476,9 +495,9 @@ class MQTT:
mqttc.on_connect = self._async_mqtt_on_connect
mqttc.on_disconnect = self._async_mqtt_on_disconnect
mqttc.on_message = self._async_mqtt_on_message
- mqttc.on_publish = self._async_mqtt_on_callback
- mqttc.on_subscribe = self._async_mqtt_on_callback
- mqttc.on_unsubscribe = self._async_mqtt_on_callback
+ mqttc.on_publish = self._async_mqtt_on_publish
+ mqttc.on_subscribe = self._async_mqtt_on_subscribe_unsubscribe
+ mqttc.on_unsubscribe = self._async_mqtt_on_subscribe_unsubscribe
# suppress exceptions at callback
mqttc.suppress_exceptions = True
@@ -498,7 +517,7 @@ class MQTT:
def _async_reader_callback(self, client: mqtt.Client) -> None:
"""Handle reading data from the socket."""
if (status := client.loop_read(MAX_PACKETS_TO_READ)) != 0:
- self._async_on_disconnect(status)
+ self._async_handle_callback_exception(status)
@callback
def _async_start_misc_periodic(self) -> None:
@@ -593,7 +612,7 @@ class MQTT:
def _async_writer_callback(self, client: mqtt.Client) -> None:
"""Handle writing data to the socket."""
if (status := client.loop_write()) != 0:
- self._async_on_disconnect(status)
+ self._async_handle_callback_exception(status)
def _on_socket_register_write(
self, client: mqtt.Client, userdata: Any, sock: SocketType
@@ -652,14 +671,25 @@ class MQTT:
result: int | None = None
self._available_future = client_available
self._should_reconnect = True
+ connect_partial = partial(
+ self._mqttc.connect,
+ host=self.conf[CONF_BROKER],
+ port=self.conf.get(CONF_PORT, DEFAULT_PORT),
+ keepalive=self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE),
+ # See:
+ # https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html
+ # `clean_start` (bool) – (MQTT v5.0 only) `True`, `False` or
+ # `MQTT_CLEAN_START_FIRST_ONLY`. Sets the MQTT v5.0 clean_start flag
+ # always, never or on the first successful connect only,
+ # respectively. MQTT session data (such as outstanding messages and
+ # subscriptions) is cleared on successful connect when the
+ # clean_start flag is set. For MQTT v3.1.1, the clean_session
+ # argument of Client should be used for similar result.
+ clean_start=True if self.is_mqttv5 else mqtt.MQTT_CLEAN_START_FIRST_ONLY,
+ )
try:
async with self._connection_lock, self._async_connect_in_executor():
- result = await self.hass.async_add_executor_job(
- self._mqttc.connect,
- self.conf[CONF_BROKER],
- self.conf.get(CONF_PORT, DEFAULT_PORT),
- self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE),
- )
+ result = await self.hass.async_add_executor_job(connect_partial)
except (OSError, mqtt.WebsocketConnectionError) as err:
_LOGGER.error("Failed to connect to MQTT server due to exception: %s", err)
self._async_connection_result(False)
@@ -983,29 +1013,28 @@ class MQTT:
self,
_mqttc: mqtt.Client,
_userdata: None,
- _flags: dict[str, int],
- result_code: int,
- properties: mqtt.Properties | None = None,
+ _connect_flags: mqtt.ConnectFlags,
+ reason_code: mqtt.ReasonCode,
+ _properties: mqtt.Properties | None = None,
) -> None:
"""On connect callback.
Resubscribe to all topics we were subscribed to and publish birth
message.
"""
- # pylint: disable-next=import-outside-toplevel
- import paho.mqtt.client as mqtt
-
- if result_code != mqtt.CONNACK_ACCEPTED:
- if result_code in (
- mqtt.CONNACK_REFUSED_BAD_USERNAME_PASSWORD,
- mqtt.CONNACK_REFUSED_NOT_AUTHORIZED,
- ):
+ if reason_code.is_failure:
+ # 24: Continue authentication
+ # 25: Re-authenticate
+ # 134: Bad user name or password
+ # 135: Not authorized
+ # 140: Bad authentication method
+ if reason_code.value in (24, 25, 134, 135, 140):
self._should_reconnect = False
self.hass.async_create_task(self.async_disconnect())
self.config_entry.async_start_reauth(self.hass)
_LOGGER.error(
"Unable to connect to the MQTT broker: %s",
- mqtt.connack_string(result_code),
+ reason_code.getName(), # type: ignore[no-untyped-call]
)
self._async_connection_result(False)
return
@@ -1016,7 +1045,7 @@ class MQTT:
"Connected to MQTT server %s:%s (%s)",
self.conf[CONF_BROKER],
self.conf.get(CONF_PORT, DEFAULT_PORT),
- result_code,
+ reason_code,
)
birth: dict[str, Any]
@@ -1153,18 +1182,32 @@ class MQTT:
self._mqtt_data.state_write_requests.process_write_state_requests(msg)
@callback
- def _async_mqtt_on_callback(
+ def _async_mqtt_on_publish(
self,
_mqttc: mqtt.Client,
_userdata: None,
mid: int,
- _granted_qos_reason: tuple[int, ...] | mqtt.ReasonCodes | None = None,
- _properties_reason: mqtt.ReasonCodes | None = None,
+ _reason_code: mqtt.ReasonCode,
+ _properties: mqtt.Properties | None,
) -> None:
+ """Publish callback."""
+ self._async_mqtt_on_callback(mid)
+
+ @callback
+ def _async_mqtt_on_subscribe_unsubscribe(
+ self,
+ _mqttc: mqtt.Client,
+ _userdata: None,
+ mid: int,
+ _reason_code: list[mqtt.ReasonCode],
+ _properties: mqtt.Properties | None,
+ ) -> None:
+ """Subscribe / Unsubscribe callback."""
+ self._async_mqtt_on_callback(mid)
+
+ @callback
+ def _async_mqtt_on_callback(self, mid: int) -> None:
"""Publish / Subscribe / Unsubscribe callback."""
- # The callback signature for on_unsubscribe is different from on_subscribe
- # see https://github.com/eclipse/paho.mqtt.python/issues/687
- # properties and reason codes are not used in Home Assistant
future = self._async_get_mid_future(mid)
if future.done() and (future.cancelled() or future.exception()):
# Timed out or cancelled
@@ -1180,19 +1223,28 @@ class MQTT:
self._pending_operations[mid] = future
return future
+ @callback
+ def _async_handle_callback_exception(self, status: mqtt.MQTTErrorCode) -> None:
+ """Handle a callback exception."""
+ # We don't import on the top because some integrations
+ # should be able to optionally rely on MQTT.
+ import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
+
+ _LOGGER.warning(
+ "Error returned from MQTT server: %s",
+ mqtt.error_string(status),
+ )
+
@callback
def _async_mqtt_on_disconnect(
self,
_mqttc: mqtt.Client,
_userdata: None,
- result_code: int,
+ _disconnect_flags: mqtt.DisconnectFlags,
+ reason_code: mqtt.ReasonCode,
properties: mqtt.Properties | None = None,
) -> None:
"""Disconnected callback."""
- self._async_on_disconnect(result_code)
-
- @callback
- def _async_on_disconnect(self, result_code: int) -> None:
if not self.connected:
# This function is re-entrant and may be called multiple times
# when there is a broken pipe error.
@@ -1203,11 +1255,11 @@ class MQTT:
self.connected = False
async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, False)
_LOGGER.log(
- logging.INFO if result_code == 0 else logging.DEBUG,
+ logging.INFO if reason_code == 0 else logging.DEBUG,
"Disconnected from MQTT server %s:%s (%s)",
self.conf[CONF_BROKER],
self.conf.get(CONF_PORT, DEFAULT_PORT),
- result_code,
+ reason_code,
)
@callback
diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py
index 12619609f64..931a57a71cc 100644
--- a/homeassistant/components/mqtt/climate.py
+++ b/homeassistant/components/mqtt/climate.py
@@ -45,7 +45,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, VolSchemaType
@@ -113,11 +113,19 @@ CONF_PRESET_MODE_COMMAND_TOPIC = "preset_mode_command_topic"
CONF_PRESET_MODE_VALUE_TEMPLATE = "preset_mode_value_template"
CONF_PRESET_MODE_COMMAND_TEMPLATE = "preset_mode_command_template"
CONF_PRESET_MODES_LIST = "preset_modes"
+
+CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE = "swing_horizontal_mode_command_template"
+CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC = "swing_horizontal_mode_command_topic"
+CONF_SWING_HORIZONTAL_MODE_LIST = "swing_horizontal_modes"
+CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE = "swing_horizontal_mode_state_template"
+CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC = "swing_horizontal_mode_state_topic"
+
CONF_SWING_MODE_COMMAND_TEMPLATE = "swing_mode_command_template"
CONF_SWING_MODE_COMMAND_TOPIC = "swing_mode_command_topic"
CONF_SWING_MODE_LIST = "swing_modes"
CONF_SWING_MODE_STATE_TEMPLATE = "swing_mode_state_template"
CONF_SWING_MODE_STATE_TOPIC = "swing_mode_state_topic"
+
CONF_TEMP_HIGH_COMMAND_TEMPLATE = "temperature_high_command_template"
CONF_TEMP_HIGH_COMMAND_TOPIC = "temperature_high_command_topic"
CONF_TEMP_HIGH_STATE_TEMPLATE = "temperature_high_state_template"
@@ -145,6 +153,8 @@ MQTT_CLIMATE_ATTRIBUTES_BLOCKED = frozenset(
climate.ATTR_MIN_TEMP,
climate.ATTR_PRESET_MODE,
climate.ATTR_PRESET_MODES,
+ climate.ATTR_SWING_HORIZONTAL_MODE,
+ climate.ATTR_SWING_HORIZONTAL_MODES,
climate.ATTR_SWING_MODE,
climate.ATTR_SWING_MODES,
climate.ATTR_TARGET_TEMP_HIGH,
@@ -162,6 +172,7 @@ VALUE_TEMPLATE_KEYS = (
CONF_MODE_STATE_TEMPLATE,
CONF_ACTION_TEMPLATE,
CONF_PRESET_MODE_VALUE_TEMPLATE,
+ CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE,
CONF_SWING_MODE_STATE_TEMPLATE,
CONF_TEMP_HIGH_STATE_TEMPLATE,
CONF_TEMP_LOW_STATE_TEMPLATE,
@@ -174,6 +185,7 @@ COMMAND_TEMPLATE_KEYS = {
CONF_MODE_COMMAND_TEMPLATE,
CONF_POWER_COMMAND_TEMPLATE,
CONF_PRESET_MODE_COMMAND_TEMPLATE,
+ CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE,
CONF_SWING_MODE_COMMAND_TEMPLATE,
CONF_TEMP_COMMAND_TEMPLATE,
CONF_TEMP_HIGH_COMMAND_TEMPLATE,
@@ -194,6 +206,8 @@ TOPIC_KEYS = (
CONF_POWER_COMMAND_TOPIC,
CONF_PRESET_MODE_COMMAND_TOPIC,
CONF_PRESET_MODE_STATE_TOPIC,
+ CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC,
+ CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC,
CONF_SWING_MODE_COMMAND_TOPIC,
CONF_SWING_MODE_STATE_TOPIC,
CONF_TEMP_COMMAND_TOPIC,
@@ -302,6 +316,13 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE): cv.template,
+ vol.Optional(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC): valid_publish_topic,
+ vol.Optional(
+ CONF_SWING_HORIZONTAL_MODE_LIST, default=[SWING_ON, SWING_OFF]
+ ): cv.ensure_list,
+ vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE): cv.template,
+ vol.Optional(CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC): valid_subscribe_topic,
vol.Optional(CONF_SWING_MODE_COMMAND_TEMPLATE): cv.template,
vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): valid_publish_topic,
vol.Optional(
@@ -350,7 +371,7 @@ DISCOVERY_SCHEMA = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT climate through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
@@ -515,6 +536,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
_attr_fan_mode: str | None = None
_attr_hvac_mode: HVACMode | None = None
+ _attr_swing_horizontal_mode: str | None = None
_attr_swing_mode: str | None = None
_default_name = DEFAULT_NAME
_entity_id_format = climate.ENTITY_ID_FORMAT
@@ -543,6 +565,7 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
if (precision := config.get(CONF_PRECISION)) is not None:
self._attr_precision = precision
self._attr_fan_modes = config[CONF_FAN_MODE_LIST]
+ self._attr_swing_horizontal_modes = config[CONF_SWING_HORIZONTAL_MODE_LIST]
self._attr_swing_modes = config[CONF_SWING_MODE_LIST]
self._attr_target_temperature_step = config[CONF_TEMP_STEP]
@@ -568,6 +591,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None or self._optimistic:
self._attr_fan_mode = FAN_LOW
+ if (
+ self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None
+ or self._optimistic
+ ):
+ self._attr_swing_horizontal_mode = SWING_OFF
if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None or self._optimistic:
self._attr_swing_mode = SWING_OFF
if self._topic[CONF_MODE_STATE_TOPIC] is None or self._optimistic:
@@ -629,6 +657,11 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
):
support |= ClimateEntityFeature.FAN_MODE
+ if (self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is not None) or (
+ self._topic[CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC] is not None
+ ):
+ support |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
+
if (self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None) or (
self._topic[CONF_SWING_MODE_COMMAND_TOPIC] is not None
):
@@ -744,6 +777,16 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
),
{"_attr_fan_mode"},
)
+ self.add_subscription(
+ CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC,
+ partial(
+ self._handle_mode_received,
+ CONF_SWING_HORIZONTAL_MODE_STATE_TEMPLATE,
+ "_attr_swing_horizontal_mode",
+ CONF_SWING_HORIZONTAL_MODE_LIST,
+ ),
+ {"_attr_swing_horizontal_mode"},
+ )
self.add_subscription(
CONF_SWING_MODE_STATE_TOPIC,
partial(
@@ -782,6 +825,20 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity):
self.async_write_ha_state()
+ async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
+ """Set new swing horizontal mode."""
+ payload = self._command_templates[CONF_SWING_HORIZONTAL_MODE_COMMAND_TEMPLATE](
+ swing_horizontal_mode
+ )
+ await self._publish(CONF_SWING_HORIZONTAL_MODE_COMMAND_TOPIC, payload)
+
+ if (
+ self._optimistic
+ or self._topic[CONF_SWING_HORIZONTAL_MODE_STATE_TOPIC] is None
+ ):
+ self._attr_swing_horizontal_mode = swing_horizontal_mode
+ self.async_write_ha_state()
+
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new swing mode."""
payload = self._command_templates[CONF_SWING_MODE_COMMAND_TEMPLATE](swing_mode)
diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py
index a9d417fc783..ecb7d9cfeb1 100644
--- a/homeassistant/components/mqtt/config_flow.py
+++ b/homeassistant/components/mqtt/config_flow.py
@@ -5,38 +5,70 @@ from __future__ import annotations
import asyncio
from collections import OrderedDict
from collections.abc import Callable, Mapping
+from copy import deepcopy
+from dataclasses import dataclass
+from enum import IntEnum
import logging
import queue
from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError
from types import MappingProxyType
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, cast
+from uuid import uuid4
-from cryptography.hazmat.primitives.serialization import load_pem_private_key
-from cryptography.x509 import load_pem_x509_certificate
+from cryptography.hazmat.primitives.serialization import (
+ Encoding,
+ NoEncryption,
+ PrivateFormat,
+ load_der_private_key,
+ load_pem_private_key,
+)
+from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
import voluptuous as vol
from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
+from homeassistant.components.sensor import (
+ CONF_STATE_CLASS,
+ DEVICE_CLASS_UNITS,
+ SensorDeviceClass,
+ SensorStateClass,
+)
+from homeassistant.components.switch import SwitchDeviceClass
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
+ ConfigSubentryFlow,
OptionsFlow,
+ SubentryFlowResult,
)
from homeassistant.const import (
+ ATTR_CONFIGURATION_URL,
+ ATTR_HW_VERSION,
+ ATTR_MODEL,
+ ATTR_MODEL_ID,
+ ATTR_NAME,
+ ATTR_SW_VERSION,
CONF_CLIENT_ID,
+ CONF_DEVICE,
+ CONF_DEVICE_CLASS,
CONF_DISCOVERY,
CONF_HOST,
+ CONF_NAME,
+ CONF_OPTIMISTIC,
CONF_PASSWORD,
CONF_PAYLOAD,
+ CONF_PLATFORM,
CONF_PORT,
CONF_PROTOCOL,
+ CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME,
+ CONF_VALUE_TEMPLATE,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.data_entry_flow import AbortFlow
-from homeassistant.helpers import config_validation as cv
+from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section
+from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.selector import (
@@ -47,9 +79,12 @@ from homeassistant.helpers.selector import (
NumberSelectorConfig,
NumberSelectorMode,
SelectOptionDict,
+ Selector,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
+ TemplateSelector,
+ TemplateSelectorConfig,
TextSelector,
TextSelectorConfig,
TextSelectorType,
@@ -64,13 +99,27 @@ from .const import (
ATTR_QOS,
ATTR_RETAIN,
ATTR_TOPIC,
+ CONF_AVAILABILITY_TEMPLATE,
+ CONF_AVAILABILITY_TOPIC,
CONF_BIRTH_MESSAGE,
CONF_BROKER,
CONF_CERTIFICATE,
CONF_CLIENT_CERT,
CONF_CLIENT_KEY,
+ CONF_COMMAND_TEMPLATE,
+ CONF_COMMAND_TOPIC,
CONF_DISCOVERY_PREFIX,
+ CONF_ENTITY_PICTURE,
+ CONF_EXPIRE_AFTER,
CONF_KEEPALIVE,
+ CONF_LAST_RESET_VALUE_TEMPLATE,
+ CONF_OPTIONS,
+ CONF_PAYLOAD_AVAILABLE,
+ CONF_PAYLOAD_NOT_AVAILABLE,
+ CONF_QOS,
+ CONF_RETAIN,
+ CONF_STATE_TOPIC,
+ CONF_SUGGESTED_DISPLAY_PRECISION,
CONF_TLS_INSECURE,
CONF_TRANSPORT,
CONF_WILL_MESSAGE,
@@ -82,9 +131,12 @@ from .const import (
DEFAULT_DISCOVERY,
DEFAULT_ENCODING,
DEFAULT_KEEPALIVE,
+ DEFAULT_PAYLOAD_AVAILABLE,
+ DEFAULT_PAYLOAD_NOT_AVAILABLE,
DEFAULT_PORT,
DEFAULT_PREFIX,
DEFAULT_PROTOCOL,
+ DEFAULT_QOS,
DEFAULT_TRANSPORT,
DEFAULT_WILL,
DEFAULT_WS_PATH,
@@ -92,12 +144,17 @@ from .const import (
SUPPORTED_PROTOCOLS,
TRANSPORT_TCP,
TRANSPORT_WEBSOCKETS,
+ Platform,
)
+from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData
from .util import (
async_create_certificate_temp_files,
get_file_path,
+ learn_more_url,
valid_birth_will,
valid_publish_topic,
+ valid_subscribe_topic,
+ valid_subscribe_topic_template,
)
_LOGGER = logging.getLogger(__name__)
@@ -105,6 +162,8 @@ _LOGGER = logging.getLogger(__name__)
ADDON_SETUP_TIMEOUT = 5
ADDON_SETUP_TIMEOUT_ROUNDS = 5
+CONF_CLIENT_KEY_PASSWORD = "client_key_password"
+
MQTT_TIMEOUT = 5
ADVANCED_OPTIONS = "advanced_options"
@@ -119,9 +178,8 @@ PORT_SELECTOR = vol.All(
vol.Coerce(int),
)
PASSWORD_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.PASSWORD))
-QOS_SELECTOR = vol.All(
- NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2)),
- vol.Coerce(int),
+QOS_SELECTOR = NumberSelector(
+ NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=2)
)
KEEPALIVE_SELECTOR = vol.All(
NumberSelector(
@@ -165,12 +223,313 @@ BROKER_VERIFICATION_SELECTOR = SelectSelector(
# mime configuration from https://pki-tutorial.readthedocs.io/en/latest/mime.html
CA_CERT_UPLOAD_SELECTOR = FileSelector(
- FileSelectorConfig(accept=".crt,application/x-x509-ca-cert")
+ FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-ca-cert")
)
CERT_UPLOAD_SELECTOR = FileSelector(
- FileSelectorConfig(accept=".crt,application/x-x509-user-cert")
+ FileSelectorConfig(accept=".pem,.crt,.cer,.der,application/x-x509-user-cert")
)
-KEY_UPLOAD_SELECTOR = FileSelector(FileSelectorConfig(accept=".key,application/pkcs8"))
+KEY_UPLOAD_SELECTOR = FileSelector(
+ FileSelectorConfig(accept=".pem,.key,.der,.pk8,application/pkcs8")
+)
+
+# Subentry selectors
+SUBENTRY_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH]
+SUBENTRY_PLATFORM_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[platform.value for platform in SUBENTRY_PLATFORMS],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=CONF_PLATFORM,
+ )
+)
+TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig())
+
+SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_AVAILABILITY_TOPIC): TEXT_SELECTOR,
+ vol.Optional(CONF_AVAILABILITY_TEMPLATE): TEMPLATE_SELECTOR,
+ vol.Optional(
+ CONF_PAYLOAD_AVAILABLE, default=DEFAULT_PAYLOAD_AVAILABLE
+ ): TEXT_SELECTOR,
+ vol.Optional(
+ CONF_PAYLOAD_NOT_AVAILABLE, default=DEFAULT_PAYLOAD_NOT_AVAILABLE
+ ): TEXT_SELECTOR,
+ }
+)
+
+# Sensor specific selectors
+SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[device_class.value for device_class in SensorDeviceClass],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="device_class_sensor",
+ sort=True,
+ )
+)
+SENSOR_STATE_CLASS_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[device_class.value for device_class in SensorStateClass],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key=CONF_STATE_CLASS,
+ )
+)
+OPTIONS_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[],
+ custom_value=True,
+ multiple=True,
+ )
+)
+SUGGESTED_DISPLAY_PRECISION_SELECTOR = NumberSelector(
+ NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=9)
+)
+EXPIRE_AFTER_SELECTOR = NumberSelector(
+ NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0)
+)
+
+# Switch specific selectors
+SWITCH_DEVICE_CLASS_SELECTOR = SelectSelector(
+ SelectSelectorConfig(
+ options=[device_class.value for device_class in SwitchDeviceClass],
+ mode=SelectSelectorMode.DROPDOWN,
+ translation_key="device_class_switch",
+ )
+)
+
+
+@callback
+def validate_sensor_platform_config(
+ config: dict[str, Any],
+) -> dict[str, str]:
+ """Validate the sensor options, state and device class config."""
+ errors: dict[str, str] = {}
+ # Only allow `options` to be set for `enum` sensors
+ # to limit the possible sensor values
+ if config.get(CONF_OPTIONS) is not None:
+ if config.get(CONF_STATE_CLASS) or config.get(CONF_UNIT_OF_MEASUREMENT):
+ errors[CONF_OPTIONS] = "options_not_allowed_with_state_class_or_uom"
+
+ if (device_class := config.get(CONF_DEVICE_CLASS)) != SensorDeviceClass.ENUM:
+ errors[CONF_DEVICE_CLASS] = "options_device_class_enum"
+
+ if (
+ (device_class := config.get(CONF_DEVICE_CLASS)) == SensorDeviceClass.ENUM
+ and errors is not None
+ and CONF_OPTIONS not in config
+ ):
+ errors[CONF_OPTIONS] = "options_with_enum_device_class"
+
+ if (
+ device_class in DEVICE_CLASS_UNITS
+ and (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is None
+ and errors is not None
+ ):
+ # Do not allow an empty unit of measurement in a subentry data flow
+ errors[CONF_UNIT_OF_MEASUREMENT] = "uom_required_for_device_class"
+ return errors
+
+ if (
+ device_class is not None
+ and device_class in DEVICE_CLASS_UNITS
+ and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class]
+ ):
+ errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom"
+
+ return errors
+
+
+@dataclass(frozen=True, kw_only=True)
+class PlatformField:
+ """Stores a platform config field schema, required flag and validator."""
+
+ selector: Selector[Any] | Callable[..., Selector[Any]]
+ required: bool
+ validator: Callable[..., Any]
+ error: str | None = None
+ default: str | int | vol.Undefined = vol.UNDEFINED
+ exclude_from_reconfig: bool = False
+ conditions: tuple[dict[str, Any], ...] | None = None
+ custom_filtering: bool = False
+ section: str | None = None
+
+
+@callback
+def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector:
+ """Return a context based unit of measurement selector."""
+ if (
+ user_data is None
+ or (device_class := user_data.get(CONF_DEVICE_CLASS)) is None
+ or device_class not in DEVICE_CLASS_UNITS
+ ):
+ return TEXT_SELECTOR
+ return SelectSelector(
+ SelectSelectorConfig(
+ options=[str(uom) for uom in DEVICE_CLASS_UNITS[device_class]],
+ sort=True,
+ custom_value=True,
+ )
+ )
+
+
+COMMON_ENTITY_FIELDS = {
+ CONF_PLATFORM: PlatformField(
+ selector=SUBENTRY_PLATFORM_SELECTOR,
+ required=True,
+ validator=str,
+ exclude_from_reconfig=True,
+ ),
+ CONF_NAME: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ validator=str,
+ exclude_from_reconfig=True,
+ ),
+ CONF_ENTITY_PICTURE: PlatformField(
+ selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url"
+ ),
+}
+
+PLATFORM_ENTITY_FIELDS = {
+ Platform.NOTIFY.value: {},
+ Platform.SENSOR.value: {
+ CONF_DEVICE_CLASS: PlatformField(
+ selector=SENSOR_DEVICE_CLASS_SELECTOR, required=False, validator=str
+ ),
+ CONF_STATE_CLASS: PlatformField(
+ selector=SENSOR_STATE_CLASS_SELECTOR, required=False, validator=str
+ ),
+ CONF_UNIT_OF_MEASUREMENT: PlatformField(
+ selector=unit_of_measurement_selector,
+ required=False,
+ validator=str,
+ custom_filtering=True,
+ ),
+ CONF_SUGGESTED_DISPLAY_PRECISION: PlatformField(
+ selector=SUGGESTED_DISPLAY_PRECISION_SELECTOR,
+ required=False,
+ validator=cv.positive_int,
+ section="advanced_settings",
+ ),
+ CONF_OPTIONS: PlatformField(
+ selector=OPTIONS_SELECTOR,
+ required=False,
+ validator=cv.ensure_list,
+ conditions=({"device_class": "enum"},),
+ ),
+ },
+ Platform.SWITCH.value: {
+ CONF_DEVICE_CLASS: PlatformField(
+ selector=SWITCH_DEVICE_CLASS_SELECTOR, required=False, validator=str
+ ),
+ },
+}
+PLATFORM_MQTT_FIELDS = {
+ Platform.NOTIFY.value: {
+ CONF_COMMAND_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=True,
+ validator=valid_publish_topic,
+ error="invalid_publish_topic",
+ ),
+ CONF_COMMAND_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=cv.template,
+ error="invalid_template",
+ ),
+ CONF_RETAIN: PlatformField(
+ selector=BOOLEAN_SELECTOR, required=False, validator=bool
+ ),
+ },
+ Platform.SENSOR.value: {
+ CONF_STATE_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=True,
+ validator=valid_subscribe_topic,
+ error="invalid_subscribe_topic",
+ ),
+ CONF_VALUE_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=cv.template,
+ error="invalid_template",
+ ),
+ CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=cv.template,
+ error="invalid_template",
+ conditions=({CONF_STATE_CLASS: "total"},),
+ ),
+ CONF_EXPIRE_AFTER: PlatformField(
+ selector=EXPIRE_AFTER_SELECTOR,
+ required=False,
+ validator=cv.positive_int,
+ section="advanced_settings",
+ ),
+ },
+ Platform.SWITCH.value: {
+ CONF_COMMAND_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=True,
+ validator=valid_publish_topic,
+ error="invalid_publish_topic",
+ ),
+ CONF_COMMAND_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=cv.template,
+ error="invalid_template",
+ ),
+ CONF_STATE_TOPIC: PlatformField(
+ selector=TEXT_SELECTOR,
+ required=False,
+ validator=valid_subscribe_topic,
+ error="invalid_subscribe_topic",
+ ),
+ CONF_VALUE_TEMPLATE: PlatformField(
+ selector=TEMPLATE_SELECTOR,
+ required=False,
+ validator=cv.template,
+ error="invalid_template",
+ ),
+ CONF_RETAIN: PlatformField(
+ selector=BOOLEAN_SELECTOR, required=False, validator=bool
+ ),
+ CONF_OPTIMISTIC: PlatformField(
+ selector=BOOLEAN_SELECTOR, required=False, validator=bool
+ ),
+ },
+}
+ENTITY_CONFIG_VALIDATOR: dict[
+ str,
+ Callable[[dict[str, Any]], dict[str, str]] | None,
+] = {
+ Platform.NOTIFY.value: None,
+ Platform.SENSOR.value: validate_sensor_platform_config,
+ Platform.SWITCH.value: None,
+}
+
+MQTT_DEVICE_PLATFORM_FIELDS = {
+ ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str),
+ ATTR_SW_VERSION: PlatformField(
+ selector=TEXT_SELECTOR, required=False, validator=str
+ ),
+ ATTR_HW_VERSION: PlatformField(
+ selector=TEXT_SELECTOR, required=False, validator=str
+ ),
+ ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str),
+ ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False, validator=str),
+ ATTR_CONFIGURATION_URL: PlatformField(
+ selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url"
+ ),
+ CONF_QOS: PlatformField(
+ selector=QOS_SELECTOR,
+ required=False,
+ validator=int,
+ default=DEFAULT_QOS,
+ section="mqtt_settings",
+ ),
+}
REAUTH_SCHEMA = vol.Schema(
{
@@ -204,6 +563,170 @@ def update_password_from_user_input(
return substituted_used_data
+@callback
+def validate_field(
+ field: str,
+ validator: Callable[..., Any],
+ user_input: dict[str, Any] | None,
+ errors: dict[str, str],
+ error: str,
+) -> None:
+ """Validate a single field."""
+ if user_input is None or field not in user_input:
+ return
+ try:
+ validator(user_input[field])
+ except (ValueError, vol.Invalid):
+ errors[field] = error
+
+
+@callback
+def _check_conditions(
+ platform_field: PlatformField, component_data: dict[str, Any] | None = None
+) -> bool:
+ """Only include field if one of conditions match, or no conditions are set."""
+ if platform_field.conditions is None or component_data is None:
+ return True
+ return any(
+ all(component_data.get(key) == value for key, value in condition.items())
+ for condition in platform_field.conditions
+ )
+
+
+@callback
+def calculate_merged_config(
+ merged_user_input: dict[str, Any],
+ data_schema_fields: dict[str, PlatformField],
+ component_data: dict[str, Any],
+) -> dict[str, Any]:
+ """Calculate merged config."""
+ base_schema_fields = {
+ key
+ for key, platform_field in data_schema_fields.items()
+ if _check_conditions(platform_field, component_data)
+ } - set(merged_user_input)
+ return {
+ key: value
+ for key, value in component_data.items()
+ if key not in base_schema_fields
+ } | merged_user_input
+
+
+@callback
+def validate_user_input(
+ user_input: dict[str, Any],
+ data_schema_fields: dict[str, PlatformField],
+ *,
+ component_data: dict[str, Any] | None = None,
+ config_validator: Callable[[dict[str, Any]], dict[str, str]] | None = None,
+) -> tuple[dict[str, Any], dict[str, str]]:
+ """Validate user input."""
+ errors: dict[str, str] = {}
+ # Merge sections
+ merged_user_input: dict[str, Any] = {}
+ for key, value in user_input.items():
+ if isinstance(value, dict):
+ merged_user_input.update(value)
+ else:
+ merged_user_input[key] = value
+
+ for field, value in merged_user_input.items():
+ validator = data_schema_fields[field].validator
+ try:
+ validator(value)
+ except (ValueError, vol.Invalid):
+ errors[field] = data_schema_fields[field].error or "invalid_input"
+
+ if config_validator is not None:
+ if TYPE_CHECKING:
+ assert component_data is not None
+
+ errors |= config_validator(
+ calculate_merged_config(
+ merged_user_input, data_schema_fields, component_data
+ ),
+ )
+
+ return merged_user_input, errors
+
+
+@callback
+def data_schema_from_fields(
+ data_schema_fields: dict[str, PlatformField],
+ reconfig: bool,
+ component_data: dict[str, Any] | None = None,
+ user_input: dict[str, Any] | None = None,
+ device_data: MqttDeviceData | None = None,
+) -> vol.Schema:
+ """Generate custom data schema from platform fields or device data."""
+ if device_data is not None:
+ component_data_with_user_input: dict[str, Any] | None = dict(device_data)
+ if TYPE_CHECKING:
+ assert component_data_with_user_input is not None
+ component_data_with_user_input.update(
+ component_data_with_user_input.pop("mqtt_settings", {})
+ )
+ else:
+ component_data_with_user_input = deepcopy(component_data)
+ if component_data_with_user_input is not None and user_input is not None:
+ component_data_with_user_input |= user_input
+
+ sections: dict[str | None, None] = {
+ field_details.section: None for field_details in data_schema_fields.values()
+ }
+ data_schema: dict[Any, Any] = {}
+ all_data_element_options: set[Any] = set()
+ no_reconfig_options: set[Any] = set()
+ for schema_section in sections:
+ data_schema_element = {
+ vol.Required(field_name, default=field_details.default)
+ if field_details.required
+ else vol.Optional(
+ field_name, default=field_details.default
+ ): field_details.selector(component_data_with_user_input) # type: ignore[operator]
+ if field_details.custom_filtering
+ else field_details.selector
+ for field_name, field_details in data_schema_fields.items()
+ if field_details.section == schema_section
+ and (not field_details.exclude_from_reconfig or not reconfig)
+ and _check_conditions(field_details, component_data_with_user_input)
+ }
+ data_element_options = set(data_schema_element)
+ all_data_element_options |= data_element_options
+ no_reconfig_options |= {
+ field_name
+ for field_name, field_details in data_schema_fields.items()
+ if field_details.section == schema_section
+ and field_details.exclude_from_reconfig
+ }
+ if schema_section is None:
+ data_schema.update(data_schema_element)
+ continue
+ collapsed = (
+ not any(
+ (default := data_schema_fields[str(option)].default) is vol.UNDEFINED
+ or component_data_with_user_input[str(option)] != default
+ for option in data_element_options
+ if option in component_data_with_user_input
+ )
+ if component_data_with_user_input is not None
+ else True
+ )
+ data_schema[vol.Optional(schema_section)] = section(
+ vol.Schema(data_schema_element), SectionConfig({"collapsed": collapsed})
+ )
+
+ # Reset all fields from the component_data not in the schema
+ if component_data:
+ filtered_fields = (
+ set(data_schema_fields) - all_data_element_options - no_reconfig_options
+ )
+ for field in filtered_fields:
+ if field in component_data:
+ del component_data[field]
+ return vol.Schema(data_schema)
+
+
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
@@ -219,6 +742,14 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
self.install_task: asyncio.Task | None = None
self.start_task: asyncio.Task | None = None
+ @classmethod
+ @callback
+ def async_get_supported_subentry_types(
+ cls, config_entry: ConfigEntry
+ ) -> dict[str, type[ConfigSubentryFlow]]:
+ """Return subentries supported by this handler."""
+ return {CONF_DEVICE: MQTTSubentryFlowHandler}
+
@staticmethod
@callback
def async_get_options_flow(
@@ -710,17 +1241,515 @@ class MQTTOptionsFlowHandler(OptionsFlow):
)
-async def _get_uploaded_file(hass: HomeAssistant, id: str) -> str:
- """Get file content from uploaded file."""
+class MQTTSubentryFlowHandler(ConfigSubentryFlow):
+ """Handle MQTT subentry flow."""
- def _proces_uploaded_file() -> str:
+ _subentry_data: MqttSubentryData
+ _component_id: str | None = None
+
+ @callback
+ def update_component_fields(
+ self,
+ data_schema_fields: dict[str, PlatformField],
+ merged_user_input: dict[str, Any],
+ ) -> None:
+ """Update the componment fields."""
+ if TYPE_CHECKING:
+ assert self._component_id is not None
+ component_data = self._subentry_data["components"][self._component_id]
+ # Remove the fields from the component data
+ # if they are not in the schema and not in the user input
+ config = calculate_merged_config(
+ merged_user_input, data_schema_fields, component_data
+ )
+ for field in (
+ field
+ for field, platform_field in data_schema_fields.items()
+ if field in (set(component_data) - set(config))
+ and not platform_field.exclude_from_reconfig
+ ):
+ component_data.pop(field)
+ component_data.update(merged_user_input)
+
+ @callback
+ def generate_names(self) -> tuple[str, str]:
+ """Generate the device and full entity name."""
+ if TYPE_CHECKING:
+ assert self._component_id is not None
+ device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
+ if entity_name := self._subentry_data["components"][self._component_id].get(
+ CONF_NAME
+ ):
+ full_entity_name: str = f"{device_name} {entity_name}"
+ else:
+ full_entity_name = device_name
+ return device_name, full_entity_name
+
+ @callback
+ def get_suggested_values_from_component(
+ self, data_schema: vol.Schema
+ ) -> dict[str, Any]:
+ """Get suggestions from component data based on the data schema."""
+ if TYPE_CHECKING:
+ assert self._component_id is not None
+ component_data = self._subentry_data["components"][self._component_id]
+ return {
+ field_key: self.get_suggested_values_from_component(value.schema)
+ if isinstance(value, section)
+ else component_data.get(field_key)
+ for field_key, value in data_schema.schema.items()
+ }
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Add a subentry."""
+ self._subentry_data = MqttSubentryData(device=MqttDeviceData(), components={})
+ return await self.async_step_device()
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Reconfigure a subentry."""
+ reconfigure_subentry = self._get_reconfigure_subentry()
+ self._subentry_data = cast(
+ MqttSubentryData, deepcopy(dict(reconfigure_subentry.data))
+ )
+ return await self.async_step_summary_menu()
+
+ async def async_step_device(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Add a new MQTT device."""
+ errors: dict[str, Any] = {}
+ device_data = self._subentry_data[CONF_DEVICE]
+ data_schema = data_schema_from_fields(
+ MQTT_DEVICE_PLATFORM_FIELDS,
+ device_data=device_data,
+ reconfig=True,
+ )
+ if user_input is not None:
+ _, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS)
+ if not errors:
+ self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input)
+ if self.source == SOURCE_RECONFIGURE:
+ return await self.async_step_summary_menu()
+ return await self.async_step_entity()
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema, device_data if user_input is None else user_input
+ )
+ return self.async_show_form(
+ step_id=CONF_DEVICE,
+ data_schema=data_schema,
+ errors=errors,
+ last_step=False,
+ )
+
+ async def async_step_entity(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Add or edit an mqtt entity."""
+ errors: dict[str, str] = {}
+ data_schema_fields = COMMON_ENTITY_FIELDS
+ entity_name_label: str = ""
+ platform_label: str = ""
+ component_data: dict[str, Any] | None = None
+ if reconfig := (self._component_id is not None):
+ component_data = self._subentry_data["components"][self._component_id]
+ name: str | None = component_data.get(CONF_NAME)
+ platform_label = f"{self._subentry_data['components'][self._component_id][CONF_PLATFORM]} "
+ entity_name_label = f" ({name})" if name is not None else ""
+ data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig)
+ if user_input is not None:
+ merged_user_input, errors = validate_user_input(
+ user_input, data_schema_fields, component_data=component_data
+ )
+ if not errors:
+ if self._component_id is None:
+ self._component_id = uuid4().hex
+ self._subentry_data["components"].setdefault(self._component_id, {})
+ self.update_component_fields(data_schema_fields, merged_user_input)
+ return await self.async_step_entity_platform_config()
+ data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
+ elif self.source == SOURCE_RECONFIGURE and self._component_id is not None:
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema,
+ self.get_suggested_values_from_component(data_schema),
+ )
+ device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
+ return self.async_show_form(
+ step_id="entity",
+ data_schema=data_schema,
+ description_placeholders={
+ "mqtt_device": device_name,
+ "entity_name_label": entity_name_label,
+ "platform_label": platform_label,
+ },
+ errors=errors,
+ last_step=False,
+ )
+
+ def _show_update_or_delete_form(self, step_id: str) -> SubentryFlowResult:
+ """Help selecting an entity to update or delete."""
+ device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
+ entities = [
+ SelectOptionDict(
+ value=key,
+ label=f"{device_name} {component_data.get(CONF_NAME, '-')}"
+ f" ({component_data[CONF_PLATFORM]})",
+ )
+ for key, component_data in self._subentry_data["components"].items()
+ ]
+ data_schema = vol.Schema(
+ {
+ vol.Required("component"): SelectSelector(
+ SelectSelectorConfig(
+ options=entities,
+ mode=SelectSelectorMode.LIST,
+ )
+ )
+ }
+ )
+ return self.async_show_form(
+ step_id=step_id, data_schema=data_schema, last_step=False
+ )
+
+ async def async_step_update_entity(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Select the entity to update."""
+ if user_input:
+ self._component_id = user_input["component"]
+ return await self.async_step_entity()
+ if len(self._subentry_data["components"]) == 1:
+ # Return first key
+ self._component_id = next(iter(self._subentry_data["components"]))
+ return await self.async_step_entity()
+ return self._show_update_or_delete_form("update_entity")
+
+ async def async_step_delete_entity(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Select the entity to delete."""
+ if user_input:
+ del self._subentry_data["components"][user_input["component"]]
+ return await self.async_step_summary_menu()
+ return self._show_update_or_delete_form("delete_entity")
+
+ async def async_step_entity_platform_config(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Configure platform entity details."""
+ if TYPE_CHECKING:
+ assert self._component_id is not None
+ component_data = self._subentry_data["components"][self._component_id]
+ platform = component_data[CONF_PLATFORM]
+ data_schema_fields = PLATFORM_ENTITY_FIELDS[platform]
+ errors: dict[str, str] = {}
+
+ data_schema = data_schema_from_fields(
+ data_schema_fields,
+ reconfig=bool(
+ {field for field in data_schema_fields if field in component_data}
+ ),
+ component_data=component_data,
+ user_input=user_input,
+ )
+ if not data_schema.schema:
+ return await self.async_step_mqtt_platform_config()
+ if user_input is not None:
+ # Test entity fields against the validator
+ merged_user_input, errors = validate_user_input(
+ user_input,
+ data_schema_fields,
+ component_data=component_data,
+ config_validator=ENTITY_CONFIG_VALIDATOR[platform],
+ )
+ if not errors:
+ self.update_component_fields(data_schema_fields, merged_user_input)
+ return await self.async_step_mqtt_platform_config()
+
+ data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
+ else:
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema,
+ self.get_suggested_values_from_component(data_schema),
+ )
+
+ device_name, full_entity_name = self.generate_names()
+ return self.async_show_form(
+ step_id="entity_platform_config",
+ data_schema=data_schema,
+ description_placeholders={
+ "mqtt_device": device_name,
+ CONF_PLATFORM: platform,
+ "entity": full_entity_name,
+ "url": learn_more_url(platform),
+ }
+ | (user_input or {}),
+ errors=errors,
+ last_step=False,
+ )
+
+ async def async_step_mqtt_platform_config(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Configure entity platform MQTT details."""
+ errors: dict[str, str] = {}
+ if TYPE_CHECKING:
+ assert self._component_id is not None
+ component_data = self._subentry_data["components"][self._component_id]
+ platform = component_data[CONF_PLATFORM]
+ data_schema_fields = PLATFORM_MQTT_FIELDS[platform]
+ data_schema = data_schema_from_fields(
+ data_schema_fields,
+ reconfig=bool(
+ {field for field in data_schema_fields if field in component_data}
+ ),
+ component_data=component_data,
+ )
+ if user_input is not None:
+ # Test entity fields against the validator
+ merged_user_input, errors = validate_user_input(
+ user_input,
+ data_schema_fields,
+ component_data=component_data,
+ config_validator=ENTITY_CONFIG_VALIDATOR[platform],
+ )
+ if not errors:
+ self.update_component_fields(data_schema_fields, merged_user_input)
+ self._component_id = None
+ if self.source == SOURCE_RECONFIGURE:
+ return await self.async_step_summary_menu()
+ return self._async_create_subentry()
+
+ data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
+ else:
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema,
+ self.get_suggested_values_from_component(data_schema),
+ )
+ device_name, full_entity_name = self.generate_names()
+ return self.async_show_form(
+ step_id="mqtt_platform_config",
+ data_schema=data_schema,
+ description_placeholders={
+ "mqtt_device": device_name,
+ CONF_PLATFORM: platform,
+ "entity": full_entity_name,
+ "url": learn_more_url(platform),
+ },
+ errors=errors,
+ last_step=False,
+ )
+
+ @callback
+ def _async_create_subentry(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Create a subentry for a new MQTT device."""
+ device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
+ component_data: dict[str, Any] = next(
+ iter(self._subentry_data["components"].values())
+ )
+ platform = component_data[CONF_PLATFORM]
+ entity_name: str | None
+ if entity_name := component_data.get(CONF_NAME):
+ full_entity_name: str = f"{device_name} {entity_name}"
+ else:
+ full_entity_name = device_name
+
+ return self.async_create_entry(
+ data=self._subentry_data,
+ title=self._subentry_data[CONF_DEVICE][CONF_NAME],
+ description_placeholders={
+ "entity": full_entity_name,
+ CONF_PLATFORM: platform,
+ },
+ )
+
+ async def async_step_availability(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Configure availability options."""
+ errors: dict[str, str] = {}
+ validate_field(
+ "availability_topic",
+ valid_subscribe_topic,
+ user_input,
+ errors,
+ "invalid_subscribe_topic",
+ )
+ validate_field(
+ "availability_template",
+ valid_subscribe_topic_template,
+ user_input,
+ errors,
+ "invalid_template",
+ )
+ if not errors and user_input is not None:
+ self._subentry_data.setdefault("availability", MqttAvailabilityData())
+ self._subentry_data["availability"] = cast(MqttAvailabilityData, user_input)
+ return await self.async_step_summary_menu()
+
+ data_schema = SUBENTRY_AVAILABILITY_SCHEMA
+ data_schema = self.add_suggested_values_to_schema(
+ data_schema,
+ dict(self._subentry_data.setdefault("availability", {}))
+ if self.source == SOURCE_RECONFIGURE
+ else user_input,
+ )
+ return self.async_show_form(
+ step_id="availability",
+ data_schema=data_schema,
+ errors=errors,
+ last_step=False,
+ )
+
+ async def async_step_summary_menu(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Show summary menu and decide to add more entities or to finish the flow."""
+ self._component_id = None
+ mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME]
+ mqtt_items = ", ".join(
+ f"{mqtt_device} {component_data.get(CONF_NAME, '-')} ({component_data[CONF_PLATFORM]})"
+ for component_data in self._subentry_data["components"].values()
+ )
+ menu_options = [
+ "entity",
+ "update_entity",
+ ]
+ if len(self._subentry_data["components"]) > 1:
+ menu_options.append("delete_entity")
+ menu_options.extend(["device", "availability"])
+ if self._subentry_data != self._get_reconfigure_subentry().data:
+ menu_options.append("save_changes")
+ return self.async_show_menu(
+ step_id="summary_menu",
+ menu_options=menu_options,
+ description_placeholders={
+ "mqtt_device": mqtt_device,
+ "mqtt_items": mqtt_items,
+ },
+ )
+
+ async def async_step_save_changes(
+ self, user_input: dict[str, Any] | None = None
+ ) -> SubentryFlowResult:
+ """Save the changes made to the subentry."""
+ entry = self._get_entry()
+ subentry = self._get_reconfigure_subentry()
+ entity_registry = er.async_get(self.hass)
+
+ # When a component is removed from the MQTT device,
+ # And we save the changes to the subentry,
+ # we need to clean up stale entity registry entries.
+ # The component id is used as a part of the unique id of the entity.
+ for unique_id, platform in [
+ (
+ f"{subentry.subentry_id}_{component_id}",
+ subentry.data["components"][component_id][CONF_PLATFORM],
+ )
+ for component_id in subentry.data["components"]
+ if component_id not in self._subentry_data["components"]
+ ]:
+ if entity_id := entity_registry.async_get_entity_id(
+ platform, DOMAIN, unique_id
+ ):
+ entity_registry.async_remove(entity_id)
+
+ return self.async_update_and_abort(
+ entry,
+ subentry,
+ data=self._subentry_data,
+ title=self._subentry_data[CONF_DEVICE][CONF_NAME],
+ )
+
+
+@callback
+def async_is_pem_data(data: bytes) -> bool:
+ """Return True if data is in PEM format."""
+ return (
+ b"-----BEGIN CERTIFICATE-----" in data
+ or b"-----BEGIN PRIVATE KEY-----" in data
+ or b"-----BEGIN EC PRIVATE KEY-----" in data
+ or b"-----BEGIN RSA PRIVATE KEY-----" in data
+ or b"-----BEGIN ENCRYPTED PRIVATE KEY-----" in data
+ )
+
+
+class PEMType(IntEnum):
+ """Type of PEM data."""
+
+ CERTIFICATE = 1
+ PRIVATE_KEY = 2
+
+
+@callback
+def async_convert_to_pem(
+ data: bytes, pem_type: PEMType, password: str | None = None
+) -> str | None:
+ """Convert data to PEM format."""
+ try:
+ if async_is_pem_data(data):
+ if not password:
+ # Assume unencrypted PEM encoded private key
+ return data.decode(DEFAULT_ENCODING)
+ # Return decrypted PEM encoded private key
+ return (
+ load_pem_private_key(data, password=password.encode(DEFAULT_ENCODING))
+ .private_bytes(
+ encoding=Encoding.PEM,
+ format=PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=NoEncryption(),
+ )
+ .decode(DEFAULT_ENCODING)
+ )
+ # Convert from DER encoding to PEM
+ if pem_type == PEMType.CERTIFICATE:
+ return (
+ load_der_x509_certificate(data)
+ .public_bytes(
+ encoding=Encoding.PEM,
+ )
+ .decode(DEFAULT_ENCODING)
+ )
+ # Assume DER encoded private key
+ pem_key_data: bytes = load_der_private_key(
+ data, password.encode(DEFAULT_ENCODING) if password else None
+ ).private_bytes(
+ encoding=Encoding.PEM,
+ format=PrivateFormat.TraditionalOpenSSL,
+ encryption_algorithm=NoEncryption(),
+ )
+ return pem_key_data.decode("utf-8")
+ except (TypeError, ValueError, SSLError):
+ _LOGGER.exception("Error converting %s file data to PEM format", pem_type.name)
+ return None
+
+
+async def _get_uploaded_file(hass: HomeAssistant, id: str) -> bytes:
+ """Get file content from uploaded certificate or key file."""
+
+ def _proces_uploaded_file() -> bytes:
with process_uploaded_file(hass, id) as file_path:
- return file_path.read_text(encoding=DEFAULT_ENCODING)
+ return file_path.read_bytes()
return await hass.async_add_executor_job(_proces_uploaded_file)
-async def async_get_broker_settings(
+def _validate_pki_file(
+ file_id: str | None, pem_data: str | None, errors: dict[str, str], error: str
+) -> bool:
+ """Return False if uploaded file could not be converted to PEM format."""
+ if file_id and not pem_data:
+ errors["base"] = error
+ return False
+ return True
+
+
+async def async_get_broker_settings( # noqa: C901
flow: ConfigFlow | OptionsFlow,
fields: OrderedDict[Any, Any],
entry_config: MappingProxyType[str, Any] | None,
@@ -768,6 +1797,10 @@ async def async_get_broker_settings(
validated_user_input.update(user_input)
client_certificate_id: str | None = user_input.get(CONF_CLIENT_CERT)
client_key_id: str | None = user_input.get(CONF_CLIENT_KEY)
+ # We do not store the private key password in the entry data
+ client_key_password: str | None = validated_user_input.pop(
+ CONF_CLIENT_KEY_PASSWORD, None
+ )
if (client_certificate_id and not client_key_id) or (
not client_certificate_id and client_key_id
):
@@ -775,7 +1808,14 @@ async def async_get_broker_settings(
return False
certificate_id: str | None = user_input.get(CONF_CERTIFICATE)
if certificate_id:
- certificate = await _get_uploaded_file(hass, certificate_id)
+ certificate_data_raw = await _get_uploaded_file(hass, certificate_id)
+ certificate = async_convert_to_pem(
+ certificate_data_raw, PEMType.CERTIFICATE
+ )
+ if not _validate_pki_file(
+ certificate_id, certificate, errors, "bad_certificate"
+ ):
+ return False
# Return to form for file upload CA cert or client cert and key
if (
@@ -797,9 +1837,26 @@ async def async_get_broker_settings(
return False
if client_certificate_id:
- client_certificate = await _get_uploaded_file(hass, client_certificate_id)
+ client_certificate_data = await _get_uploaded_file(
+ hass, client_certificate_id
+ )
+ client_certificate = async_convert_to_pem(
+ client_certificate_data, PEMType.CERTIFICATE
+ )
+ if not _validate_pki_file(
+ client_certificate_id, client_certificate, errors, "bad_client_cert"
+ ):
+ return False
+
if client_key_id:
- client_key = await _get_uploaded_file(hass, client_key_id)
+ client_key_data = await _get_uploaded_file(hass, client_key_id)
+ client_key = async_convert_to_pem(
+ client_key_data, PEMType.PRIVATE_KEY, password=client_key_password
+ )
+ if not _validate_pki_file(
+ client_key_id, client_key, errors, "client_key_error"
+ ):
+ return False
certificate_data: dict[str, Any] = {}
if certificate:
@@ -956,6 +2013,14 @@ async def async_get_broker_settings(
description={"suggested_value": user_input_basic.get(CONF_CLIENT_KEY)},
)
] = KEY_UPLOAD_SELECTOR
+ fields[
+ vol.Optional(
+ CONF_CLIENT_KEY_PASSWORD,
+ description={
+ "suggested_value": user_input_basic.get(CONF_CLIENT_KEY_PASSWORD)
+ },
+ )
+ ] = PASSWORD_SELECTOR
verification_mode = current_config.get(SET_CA_CERT) or (
"off"
if current_ca_certificate is None
@@ -1023,14 +2088,14 @@ def try_connection(
result: queue.Queue[bool] = queue.Queue(maxsize=1)
def on_connect(
- client_: mqtt.Client,
- userdata: None,
- flags: dict[str, Any],
- result_code: int,
- properties: mqtt.Properties | None = None,
+ _mqttc: mqtt.Client,
+ _userdata: None,
+ _connect_flags: mqtt.ConnectFlags,
+ reason_code: mqtt.ReasonCode,
+ _properties: mqtt.Properties | None = None,
) -> None:
"""Handle connection result."""
- result.put(result_code == mqtt.CONNACK_ACCEPTED)
+ result.put(not reason_code.is_failure)
client.on_connect = on_connect
@@ -1060,7 +2125,7 @@ def check_certicate_chain() -> str | None:
with open(private_key, "rb") as client_key_file:
load_pem_private_key(client_key_file.read(), password=None)
except (TypeError, ValueError):
- return "bad_client_key"
+ return "client_key_error"
# Check the certificate chain
context = SSLContext(PROTOCOL_TLS_CLIENT)
if client_certificate and private_key:
diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py
index 007b3b7e576..090fc74aa88 100644
--- a/homeassistant/components/mqtt/const.py
+++ b/homeassistant/components/mqtt/const.py
@@ -56,20 +56,56 @@ CONF_SUPPORTED_FEATURES = "supported_features"
CONF_ACTION_TEMPLATE = "action_template"
CONF_ACTION_TOPIC = "action_topic"
+CONF_BLUE_TEMPLATE = "blue_template"
+CONF_BRIGHTNESS_COMMAND_TEMPLATE = "brightness_command_template"
+CONF_BRIGHTNESS_COMMAND_TOPIC = "brightness_command_topic"
+CONF_BRIGHTNESS_SCALE = "brightness_scale"
+CONF_BRIGHTNESS_STATE_TOPIC = "brightness_state_topic"
+CONF_BRIGHTNESS_TEMPLATE = "brightness_template"
+CONF_BRIGHTNESS_VALUE_TEMPLATE = "brightness_value_template"
+CONF_COLOR_MODE = "color_mode"
+CONF_COLOR_MODE_STATE_TOPIC = "color_mode_state_topic"
+CONF_COLOR_MODE_VALUE_TEMPLATE = "color_mode_value_template"
+CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template"
+CONF_COLOR_TEMP_COMMAND_TOPIC = "color_temp_command_topic"
CONF_COLOR_TEMP_KELVIN = "color_temp_kelvin"
+CONF_COLOR_TEMP_TEMPLATE = "color_temp_template"
+CONF_COLOR_TEMP_STATE_TOPIC = "color_temp_state_topic"
+CONF_COLOR_TEMP_VALUE_TEMPLATE = "color_temp_value_template"
+CONF_COMMAND_OFF_TEMPLATE = "command_off_template"
+CONF_COMMAND_ON_TEMPLATE = "command_on_template"
CONF_CURRENT_HUMIDITY_TEMPLATE = "current_humidity_template"
CONF_CURRENT_HUMIDITY_TOPIC = "current_humidity_topic"
CONF_CURRENT_TEMP_TEMPLATE = "current_temperature_template"
CONF_CURRENT_TEMP_TOPIC = "current_temperature_topic"
CONF_ENABLED_BY_DEFAULT = "enabled_by_default"
+CONF_EFFECT_COMMAND_TEMPLATE = "effect_command_template"
+CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic"
+CONF_EFFECT_LIST = "effect_list"
+CONF_EFFECT_STATE_TOPIC = "effect_state_topic"
+CONF_EFFECT_TEMPLATE = "effect_template"
+CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template"
CONF_ENTITY_PICTURE = "entity_picture"
+CONF_EXPIRE_AFTER = "expire_after"
+CONF_FLASH = "flash"
+CONF_FLASH_TIME_LONG = "flash_time_long"
+CONF_FLASH_TIME_SHORT = "flash_time_short"
+CONF_GREEN_TEMPLATE = "green_template"
+CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
+CONF_HS_COMMAND_TOPIC = "hs_command_topic"
+CONF_HS_STATE_TOPIC = "hs_state_topic"
+CONF_HS_VALUE_TEMPLATE = "hs_value_template"
+CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
CONF_MAX_KELVIN = "max_kelvin"
+CONF_MAX_MIREDS = "max_mireds"
CONF_MIN_KELVIN = "min_kelvin"
+CONF_MIN_MIREDS = "min_mireds"
CONF_MODE_COMMAND_TEMPLATE = "mode_command_template"
CONF_MODE_COMMAND_TOPIC = "mode_command_topic"
CONF_MODE_LIST = "modes"
CONF_MODE_STATE_TEMPLATE = "mode_state_template"
CONF_MODE_STATE_TOPIC = "mode_state_topic"
+CONF_ON_COMMAND_TYPE = "on_command_type"
CONF_PAYLOAD_CLOSE = "payload_close"
CONF_PAYLOAD_OPEN = "payload_open"
CONF_PAYLOAD_STOP = "payload_stop"
@@ -78,10 +114,25 @@ CONF_POSITION_OPEN = "position_open"
CONF_POWER_COMMAND_TOPIC = "power_command_topic"
CONF_POWER_COMMAND_TEMPLATE = "power_command_template"
CONF_PRECISION = "precision"
+CONF_RED_TEMPLATE = "red_template"
+CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template"
+CONF_RGB_COMMAND_TOPIC = "rgb_command_topic"
+CONF_RGB_STATE_TOPIC = "rgb_state_topic"
+CONF_RGB_VALUE_TEMPLATE = "rgb_value_template"
+CONF_RGBW_COMMAND_TEMPLATE = "rgbw_command_template"
+CONF_RGBW_COMMAND_TOPIC = "rgbw_command_topic"
+CONF_RGBW_STATE_TOPIC = "rgbw_state_topic"
+CONF_RGBW_VALUE_TEMPLATE = "rgbw_value_template"
+CONF_RGBWW_COMMAND_TEMPLATE = "rgbww_command_template"
+CONF_RGBWW_COMMAND_TOPIC = "rgbww_command_topic"
+CONF_RGBWW_STATE_TOPIC = "rgbww_state_topic"
+CONF_RGBWW_VALUE_TEMPLATE = "rgbww_value_template"
CONF_STATE_CLOSED = "state_closed"
CONF_STATE_CLOSING = "state_closing"
CONF_STATE_OPEN = "state_open"
CONF_STATE_OPENING = "state_opening"
+CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
+CONF_SUPPORTED_COLOR_MODES = "supported_color_modes"
CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template"
CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic"
CONF_TEMP_STATE_TEMPLATE = "temperature_state_template"
@@ -89,7 +140,15 @@ CONF_TEMP_STATE_TOPIC = "temperature_state_topic"
CONF_TEMP_INITIAL = "initial"
CONF_TEMP_MAX = "max_temp"
CONF_TEMP_MIN = "min_temp"
+CONF_TRANSITION = "transition"
+CONF_XY_COMMAND_TEMPLATE = "xy_command_template"
+CONF_XY_COMMAND_TOPIC = "xy_command_topic"
+CONF_XY_STATE_TOPIC = "xy_state_topic"
+CONF_XY_VALUE_TEMPLATE = "xy_value_template"
+CONF_WHITE_COMMAND_TOPIC = "white_command_topic"
+CONF_WHITE_SCALE = "white_scale"
+# Config flow constants
CONF_CERTIFICATE = "certificate"
CONF_CLIENT_KEY = "client_key"
CONF_CLIENT_CERT = "client_cert"
@@ -110,15 +169,23 @@ CONF_CONFIGURATION_URL = "configuration_url"
CONF_OBJECT_ID = "object_id"
CONF_SUPPORT_URL = "support_url"
+DEFAULT_BRIGHTNESS = False
+DEFAULT_BRIGHTNESS_SCALE = 255
DEFAULT_PREFIX = "homeassistant"
DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status"
DEFAULT_DISCOVERY = True
+DEFAULT_EFFECT = False
DEFAULT_ENCODING = "utf-8"
+DEFAULT_FLASH_TIME_LONG = 10
+DEFAULT_FLASH_TIME_SHORT = 2
DEFAULT_OPTIMISTIC = False
+DEFAULT_ON_COMMAND_TYPE = "last"
DEFAULT_QOS = 0
DEFAULT_PAYLOAD_AVAILABLE = "online"
DEFAULT_PAYLOAD_CLOSE = "CLOSE"
DEFAULT_PAYLOAD_NOT_AVAILABLE = "offline"
+DEFAULT_PAYLOAD_OFF = "OFF"
+DEFAULT_PAYLOAD_ON = "ON"
DEFAULT_PAYLOAD_OPEN = "OPEN"
DEFAULT_PORT = 1883
DEFAULT_RETAIN = False
@@ -127,6 +194,7 @@ DEFAULT_WS_PATH = "/"
DEFAULT_POSITION_CLOSED = 0
DEFAULT_POSITION_OPEN = 100
DEFAULT_RETAIN = False
+DEFAULT_WHITE_SCALE = 255
PROTOCOL_31 = "3.1"
PROTOCOL_311 = "3.1.1"
diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py
index 626e0cef64a..428c4d0e205 100644
--- a/homeassistant/components/mqtt/cover.py
+++ b/homeassistant/components/mqtt/cover.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
@@ -81,6 +81,7 @@ CONF_TILT_STATUS_TOPIC = "tilt_status_topic"
CONF_TILT_STATUS_TEMPLATE = "tilt_status_template"
CONF_STATE_STOPPED = "state_stopped"
+CONF_PAYLOAD_STOP_TILT = "payload_stop_tilt"
CONF_TILT_CLOSED_POSITION = "tilt_closed_value"
CONF_TILT_MAX = "tilt_max"
CONF_TILT_MIN = "tilt_min"
@@ -203,6 +204,9 @@ _PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend(
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_GET_POSITION_TEMPLATE): cv.template,
vol.Optional(CONF_TILT_COMMAND_TEMPLATE): cv.template,
+ vol.Optional(CONF_PAYLOAD_STOP_TILT, default=DEFAULT_PAYLOAD_STOP): vol.Any(
+ cv.string, None
+ ),
}
).extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
@@ -220,7 +224,7 @@ DISCOVERY_SCHEMA = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT cover through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
@@ -592,6 +596,12 @@ class MqttCover(MqttEntity, CoverEntity):
self._attr_current_cover_tilt_position = tilt_percentage
self.async_write_ha_state()
+ async def async_stop_cover_tilt(self, **kwargs: Any) -> None:
+ """Stop moving the cover tilt."""
+ await self.async_publish_with_config(
+ self._config[CONF_TILT_COMMAND_TOPIC], self._config[CONF_PAYLOAD_STOP_TILT]
+ )
+
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
position_percentage = kwargs[ATTR_POSITION]
diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py
index d3ad57ef43d..9a10170641e 100644
--- a/homeassistant/components/mqtt/device_tracker.py
+++ b/homeassistant/components/mqtt/device_tracker.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
import logging
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -22,14 +22,19 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
from . import subscription
from .config import MQTT_BASE_SCHEMA
-from .const import CONF_PAYLOAD_RESET, CONF_STATE_TOPIC
-from .entity import CONF_JSON_ATTRS_TOPIC, MqttEntity, async_setup_entity_entry_helper
+from .const import (
+ CONF_JSON_ATTRS_TEMPLATE,
+ CONF_JSON_ATTRS_TOPIC,
+ CONF_PAYLOAD_RESET,
+ CONF_STATE_TOPIC,
+)
+from .entity import MqttEntity, async_setup_entity_entry_helper
from .models import MqttValueTemplate, ReceiveMessage
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_subscribe_topic
@@ -79,7 +84,7 @@ DISCOVERY_SCHEMA = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT event through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
@@ -111,6 +116,7 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity):
self._value_template = MqttValueTemplate(
config.get(CONF_VALUE_TEMPLATE), entity=self
).async_render_with_possible_json_value
+ self._attr_source_type = self._config[CONF_SOURCE_TYPE]
@callback
def _tracker_message_received(self, msg: ReceiveMessage) -> None:
@@ -124,72 +130,82 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity):
)
return
if payload == self._config[CONF_PAYLOAD_HOME]:
- self._location_name = STATE_HOME
+ self._attr_location_name = STATE_HOME
elif payload == self._config[CONF_PAYLOAD_NOT_HOME]:
- self._location_name = STATE_NOT_HOME
+ self._attr_location_name = STATE_NOT_HOME
elif payload == self._config[CONF_PAYLOAD_RESET]:
- self._location_name = None
+ self._attr_location_name = None
else:
if TYPE_CHECKING:
assert isinstance(msg.payload, str)
- self._location_name = msg.payload
+ self._attr_location_name = msg.payload
@callback
def _prepare_subscribe_topics(self) -> None:
"""(Re)Subscribe to topics."""
self.add_subscription(
- CONF_STATE_TOPIC, self._tracker_message_received, {"_location_name"}
+ CONF_STATE_TOPIC, self._tracker_message_received, {"_attr_location_name"}
)
- @property
- def force_update(self) -> bool:
- """Do not force updates if the state is the same."""
- return False
-
async def _subscribe_topics(self) -> None:
"""(Re)Subscribe to topics."""
subscription.async_subscribe_topics_internal(self.hass, self._sub_state)
- @property
- def latitude(self) -> float | None:
- """Return latitude if provided in extra_state_attributes or None."""
+ @callback
+ def _process_update_extra_state_attributes(
+ self, extra_state_attributes: dict[str, Any]
+ ) -> None:
+ """Extract the location from the extra state attributes."""
if (
- self.extra_state_attributes is not None
- and ATTR_LATITUDE in self.extra_state_attributes
+ ATTR_LATITUDE in extra_state_attributes
+ or ATTR_LONGITUDE in extra_state_attributes
):
- latitude: float = self.extra_state_attributes[ATTR_LATITUDE]
- return latitude
- return None
+ latitude: float | None
+ longitude: float | None
+ gps_accuracy: int
+ # Reset manually set location to allow automatic zone detection
+ self._attr_location_name = None
+ if isinstance(
+ latitude := extra_state_attributes.get(ATTR_LATITUDE), (int, float)
+ ) and isinstance(
+ longitude := extra_state_attributes.get(ATTR_LONGITUDE), (int, float)
+ ):
+ self._attr_latitude = latitude
+ self._attr_longitude = longitude
+ else:
+ # Invalid or incomplete coordinates, reset location
+ self._attr_latitude = None
+ self._attr_longitude = None
+ _LOGGER.warning(
+ "Extra state attributes received at % and template %s "
+ "contain invalid or incomplete location info. Got %s",
+ self._config.get(CONF_JSON_ATTRS_TEMPLATE),
+ self._config.get(CONF_JSON_ATTRS_TOPIC),
+ extra_state_attributes,
+ )
- @property
- def location_accuracy(self) -> int:
- """Return location accuracy if provided in extra_state_attributes or None."""
- if (
- self.extra_state_attributes is not None
- and ATTR_GPS_ACCURACY in self.extra_state_attributes
- ):
- accuracy: int = self.extra_state_attributes[ATTR_GPS_ACCURACY]
- return accuracy
- return 0
+ if ATTR_GPS_ACCURACY in extra_state_attributes:
+ if isinstance(
+ gps_accuracy := extra_state_attributes[ATTR_GPS_ACCURACY],
+ (int, float),
+ ):
+ self._attr_location_accuracy = gps_accuracy
+ else:
+ _LOGGER.warning(
+ "Extra state attributes received at % and template %s "
+ "contain invalid GPS accuracy setting, "
+ "gps_accuracy was set to 0 as the default. Got %s",
+ self._config.get(CONF_JSON_ATTRS_TEMPLATE),
+ self._config.get(CONF_JSON_ATTRS_TOPIC),
+ extra_state_attributes,
+ )
+ self._attr_location_accuracy = 0
- @property
- def longitude(self) -> float | None:
- """Return longitude if provided in extra_state_attributes or None."""
- if (
- self.extra_state_attributes is not None
- and ATTR_LONGITUDE in self.extra_state_attributes
- ):
- longitude: float = self.extra_state_attributes[ATTR_LONGITUDE]
- return longitude
- return None
+ else:
+ self._attr_location_accuracy = 0
- @property
- def location_name(self) -> str | None:
- """Return a location name for the current location of the device."""
- return self._location_name
-
- @property
- def source_type(self) -> SourceType:
- """Return the source type, eg gps or router, of the device."""
- source_type: SourceType = self._config[CONF_SOURCE_TYPE]
- return source_type
+ self._attr_extra_state_attributes = {
+ attribute: value
+ for attribute, value in extra_state_attributes.items()
+ if attribute not in {ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE}
+ }
diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py
index a14240ce008..4ebdbbb6236 100644
--- a/homeassistant/components/mqtt/discovery.py
+++ b/homeassistant/components/mqtt/discovery.py
@@ -154,18 +154,14 @@ def get_origin_support_url(discovery_payload: MQTTDiscoveryPayload) -> str | Non
@callback
def async_log_discovery_origin_info(
- message: str, discovery_payload: MQTTDiscoveryPayload, level: int = logging.INFO
+ message: str, discovery_payload: MQTTDiscoveryPayload
) -> None:
"""Log information about the discovery and origin."""
- # We only log origin info once per device discovery
- if not _LOGGER.isEnabledFor(level):
- # bail out early if logging is disabled
+ if not _LOGGER.isEnabledFor(logging.DEBUG):
+ # bail out early if debug logging is disabled
return
- _LOGGER.log(
- level,
- "%s%s",
- message,
- get_origin_log_string(discovery_payload, include_url=True),
+ _LOGGER.debug(
+ "%s%s", message, get_origin_log_string(discovery_payload, include_url=True)
)
@@ -258,7 +254,7 @@ def _generate_device_config(
comp_config = config[CONF_COMPONENTS]
for platform, discover_id in mqtt_data.discovery_already_discovered:
ids = discover_id.split(" ")
- component_node_id = ids.pop(0)
+ component_node_id = f"{ids.pop(1)} {ids.pop(0)}" if len(ids) > 2 else ids.pop(0)
component_object_id = " ".join(ids)
if not ids:
continue
@@ -562,7 +558,7 @@ async def async_start( # noqa: C901
elif already_discovered:
# Dispatch update
message = f"Component has already been discovered: {component} {discovery_id}, sending update"
- async_log_discovery_origin_info(message, payload, logging.DEBUG)
+ async_log_discovery_origin_info(message, payload)
async_dispatcher_send(
hass, MQTT_DISCOVERY_UPDATED.format(*discovery_hash), payload
)
diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py
index fb047cc8d5e..1202f04ed42 100644
--- a/homeassistant/components/mqtt/entity.py
+++ b/homeassistant/components/mqtt/entity.py
@@ -43,7 +43,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity, async_generate_entity_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import (
async_track_device_registry_updated_event,
async_track_entity_registry_updated_event,
@@ -111,6 +111,7 @@ from .discovery import (
from .models import (
DATA_MQTT,
MessageCallbackType,
+ MqttSubentryData,
MqttValueTemplate,
MqttValueTemplateException,
PublishPayloadType,
@@ -122,7 +123,7 @@ from .subscription import (
async_subscribe_topics_internal,
async_unsubscribe_topics,
)
-from .util import mqtt_config_entry_enabled
+from .util import learn_more_url, mqtt_config_entry_enabled
_LOGGER = logging.getLogger(__name__)
@@ -238,7 +239,7 @@ def async_setup_entity_entry_helper(
entry: ConfigEntry,
entity_class: type[MqttEntity] | None,
domain: str,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
discovery_schema: VolSchemaType,
platform_schema_modern: VolSchemaType,
schema_class_mapping: dict[str, type[MqttEntity]] | None = None,
@@ -282,11 +283,10 @@ def async_setup_entity_entry_helper(
@callback
def _async_setup_entities() -> None:
- """Set up MQTT items from configuration.yaml."""
+ """Set up MQTT items from subentries and configuration.yaml."""
nonlocal entity_class
mqtt_data = hass.data[DATA_MQTT]
- if not (config_yaml := mqtt_data.config):
- return
+ config_yaml = mqtt_data.config
yaml_configs: list[ConfigType] = [
config
for config_item in config_yaml
@@ -294,6 +294,45 @@ def async_setup_entity_entry_helper(
for config in configs
if config_domain == domain
]
+ # process subentry entity setup
+ for config_subentry_id, subentry in entry.subentries.items():
+ subentry_data = cast(MqttSubentryData, subentry.data)
+ availability_config = subentry_data.get("availability", {})
+ subentry_entities: list[Entity] = []
+ device_config = subentry_data["device"].copy()
+ device_mqtt_options = device_config.pop("mqtt_settings", {})
+ device_config["identifiers"] = config_subentry_id
+ for component_id, component_data in subentry_data["components"].items():
+ if component_data["platform"] != domain:
+ continue
+ component_config: dict[str, Any] = component_data.copy()
+ component_config[CONF_UNIQUE_ID] = (
+ f"{config_subentry_id}_{component_id}"
+ )
+ component_config[CONF_DEVICE] = device_config
+ component_config.pop("platform")
+ component_config.update(availability_config)
+ component_config.update(device_mqtt_options)
+
+ try:
+ config = platform_schema_modern(component_config)
+ if schema_class_mapping is not None:
+ entity_class = schema_class_mapping[config[CONF_SCHEMA]]
+ if TYPE_CHECKING:
+ assert entity_class is not None
+ subentry_entities.append(entity_class(hass, config, entry, None))
+ except vol.Invalid as exc:
+ _LOGGER.error(
+ "Schema violation occurred when trying to set up "
+ "entity from subentry %s %s %s: %s",
+ config_subentry_id,
+ subentry.title,
+ subentry.data,
+ exc,
+ )
+
+ async_add_entities(subentry_entities, config_subentry_id=config_subentry_id)
+
entities: list[Entity] = []
for yaml_config in yaml_configs:
try:
@@ -309,9 +348,6 @@ def async_setup_entity_entry_helper(
line = getattr(yaml_config, "__line__", "?")
issue_id = hex(hash(frozenset(yaml_config)))
yaml_config_str = yaml_dump(yaml_config)
- learn_more_url = (
- f"https://www.home-assistant.io/integrations/{domain}.mqtt/"
- )
async_create_issue(
hass,
DOMAIN,
@@ -319,7 +355,7 @@ def async_setup_entity_entry_helper(
issue_domain=domain,
is_fixable=False,
severity=IssueSeverity.ERROR,
- learn_more_url=learn_more_url,
+ learn_more_url=learn_more_url(domain),
translation_placeholders={
"domain": domain,
"config_file": config_file,
@@ -363,6 +399,10 @@ class MqttAttributesMixin(Entity):
_attributes_extra_blocked: frozenset[str] = frozenset()
_attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None
+ _message_callback: Callable[
+ [MessageCallbackType, set[str] | None, ReceiveMessage], None
+ ]
+ _process_update_extra_state_attributes: Callable[[dict[str, Any]], None]
def __init__(self, config: ConfigType) -> None:
"""Initialize the JSON attributes mixin."""
@@ -397,9 +437,15 @@ class MqttAttributesMixin(Entity):
CONF_JSON_ATTRS_TOPIC: {
"topic": self._attributes_config.get(CONF_JSON_ATTRS_TOPIC),
"msg_callback": partial(
- self._message_callback, # type: ignore[attr-defined]
+ self._message_callback,
self._attributes_message_received,
- {"_attr_extra_state_attributes"},
+ {
+ "_attr_extra_state_attributes",
+ "_attr_gps_accuracy",
+ "_attr_latitude",
+ "_attr_location_name",
+ "_attr_longitude",
+ },
),
"entity_id": self.entity_id,
"qos": self._attributes_config.get(CONF_QOS),
@@ -438,7 +484,11 @@ class MqttAttributesMixin(Entity):
if k not in MQTT_ATTRIBUTES_BLOCKED
and k not in self._attributes_extra_blocked
}
- self._attr_extra_state_attributes = filtered_dict
+ if hasattr(self, "_process_update_extra_state_attributes"):
+ self._process_update_extra_state_attributes(filtered_dict)
+ else:
+ self._attr_extra_state_attributes = filtered_dict
+
else:
_LOGGER.warning("JSON result was not a dictionary")
@@ -446,6 +496,10 @@ class MqttAttributesMixin(Entity):
class MqttAvailabilityMixin(Entity):
"""Mixin used for platforms that report availability."""
+ _message_callback: Callable[
+ [MessageCallbackType, set[str] | None, ReceiveMessage], None
+ ]
+
def __init__(self, config: ConfigType) -> None:
"""Initialize the availability mixin."""
self._availability_sub_state: dict[str, EntitySubscription] = {}
@@ -511,7 +565,7 @@ class MqttAvailabilityMixin(Entity):
f"availability_{topic}": {
"topic": topic,
"msg_callback": partial(
- self._message_callback, # type: ignore[attr-defined]
+ self._message_callback,
self._availability_message_received,
{"available"},
),
diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py
index 5855f94dad7..aef21838d59 100644
--- a/homeassistant/components/mqtt/event.py
+++ b/homeassistant/components/mqtt/event.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object
@@ -73,7 +73,7 @@ DISCOVERY_SCHEMA = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT event through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py
index d8e96eb2734..3fac4d4ffe0 100644
--- a/homeassistant/components/mqtt/fan.py
+++ b/homeassistant/components/mqtt/fan.py
@@ -28,7 +28,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, VolSchemaType
@@ -190,7 +190,7 @@ DISCOVERY_SCHEMA = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT fan through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py
index bffe0ec1420..07ddcddb13a 100644
--- a/homeassistant/components/mqtt/humidifier.py
+++ b/homeassistant/components/mqtt/humidifier.py
@@ -31,7 +31,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, VolSchemaType
@@ -183,7 +183,7 @@ TOPICS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT humidifier through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py
index 4b7b2d783d2..a668608dd55 100644
--- a/homeassistant/components/mqtt/image.py
+++ b/homeassistant/components/mqtt/image.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType
@@ -82,7 +82,7 @@ DISCOVERY_SCHEMA = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT image through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py
index 87577c4b4d9..1917c56f209 100644
--- a/homeassistant/components/mqtt/lawn_mower.py
+++ b/homeassistant/components/mqtt/lawn_mower.py
@@ -10,6 +10,7 @@ import voluptuous as vol
from homeassistant.components import lawn_mower
from homeassistant.components.lawn_mower import (
+ ENTITY_ID_FORMAT,
LawnMowerActivity,
LawnMowerEntity,
LawnMowerEntityFeature,
@@ -18,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
@@ -50,7 +51,6 @@ CONF_START_MOWING_COMMAND_TOPIC = "start_mowing_command_topic"
CONF_START_MOWING_COMMAND_TEMPLATE = "start_mowing_command_template"
DEFAULT_NAME = "MQTT Lawn Mower"
-ENTITY_ID_FORMAT = lawn_mower.DOMAIN + ".{}"
MQTT_LAWN_MOWER_ATTRIBUTES_BLOCKED: frozenset[str] = frozenset()
@@ -80,7 +80,7 @@ DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EX
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT lawn mower through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py
index 328f80cb5ea..3ffad9226be 100644
--- a/homeassistant/components/mqtt/light/__init__.py
+++ b/homeassistant/components/mqtt/light/__init__.py
@@ -9,7 +9,7 @@ import voluptuous as vol
from homeassistant.components import light
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, VolSchemaType
from ..entity import async_setup_entity_entry_helper
@@ -69,7 +69,7 @@ PLATFORM_SCHEMA_MODERN = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT lights through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py
index a2f424b247d..a950aced665 100644
--- a/homeassistant/components/mqtt/light/schema_basic.py
+++ b/homeassistant/components/mqtt/light/schema_basic.py
@@ -51,12 +51,58 @@ from homeassistant.util import color as color_util
from .. import subscription
from ..config import MQTT_RW_SCHEMA
from ..const import (
+ CONF_BRIGHTNESS_COMMAND_TEMPLATE,
+ CONF_BRIGHTNESS_COMMAND_TOPIC,
+ CONF_BRIGHTNESS_SCALE,
+ CONF_BRIGHTNESS_STATE_TOPIC,
+ CONF_BRIGHTNESS_VALUE_TEMPLATE,
+ CONF_COLOR_MODE_STATE_TOPIC,
+ CONF_COLOR_MODE_VALUE_TEMPLATE,
+ CONF_COLOR_TEMP_COMMAND_TEMPLATE,
+ CONF_COLOR_TEMP_COMMAND_TOPIC,
CONF_COLOR_TEMP_KELVIN,
+ CONF_COLOR_TEMP_STATE_TOPIC,
+ CONF_COLOR_TEMP_VALUE_TEMPLATE,
CONF_COMMAND_TOPIC,
+ CONF_EFFECT_COMMAND_TEMPLATE,
+ CONF_EFFECT_COMMAND_TOPIC,
+ CONF_EFFECT_LIST,
+ CONF_EFFECT_STATE_TOPIC,
+ CONF_EFFECT_VALUE_TEMPLATE,
+ CONF_HS_COMMAND_TEMPLATE,
+ CONF_HS_COMMAND_TOPIC,
+ CONF_HS_STATE_TOPIC,
+ CONF_HS_VALUE_TEMPLATE,
CONF_MAX_KELVIN,
+ CONF_MAX_MIREDS,
CONF_MIN_KELVIN,
+ CONF_MIN_MIREDS,
+ CONF_ON_COMMAND_TYPE,
+ CONF_RGB_COMMAND_TEMPLATE,
+ CONF_RGB_COMMAND_TOPIC,
+ CONF_RGB_STATE_TOPIC,
+ CONF_RGB_VALUE_TEMPLATE,
+ CONF_RGBW_COMMAND_TEMPLATE,
+ CONF_RGBW_COMMAND_TOPIC,
+ CONF_RGBW_STATE_TOPIC,
+ CONF_RGBW_VALUE_TEMPLATE,
+ CONF_RGBWW_COMMAND_TEMPLATE,
+ CONF_RGBWW_COMMAND_TOPIC,
+ CONF_RGBWW_STATE_TOPIC,
+ CONF_RGBWW_VALUE_TEMPLATE,
CONF_STATE_TOPIC,
CONF_STATE_VALUE_TEMPLATE,
+ CONF_WHITE_COMMAND_TOPIC,
+ CONF_WHITE_SCALE,
+ CONF_XY_COMMAND_TEMPLATE,
+ CONF_XY_COMMAND_TOPIC,
+ CONF_XY_STATE_TOPIC,
+ CONF_XY_VALUE_TEMPLATE,
+ DEFAULT_BRIGHTNESS_SCALE,
+ DEFAULT_ON_COMMAND_TYPE,
+ DEFAULT_PAYLOAD_OFF,
+ DEFAULT_PAYLOAD_ON,
+ DEFAULT_WHITE_SCALE,
PAYLOAD_NONE,
)
from ..entity import MqttEntity
@@ -74,47 +120,7 @@ from .schema import MQTT_LIGHT_SCHEMA_SCHEMA
_LOGGER = logging.getLogger(__name__)
-CONF_BRIGHTNESS_COMMAND_TEMPLATE = "brightness_command_template"
-CONF_BRIGHTNESS_COMMAND_TOPIC = "brightness_command_topic"
-CONF_BRIGHTNESS_SCALE = "brightness_scale"
-CONF_BRIGHTNESS_STATE_TOPIC = "brightness_state_topic"
-CONF_BRIGHTNESS_VALUE_TEMPLATE = "brightness_value_template"
-CONF_COLOR_MODE_STATE_TOPIC = "color_mode_state_topic"
-CONF_COLOR_MODE_VALUE_TEMPLATE = "color_mode_value_template"
-CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template"
-CONF_COLOR_TEMP_COMMAND_TOPIC = "color_temp_command_topic"
-CONF_COLOR_TEMP_STATE_TOPIC = "color_temp_state_topic"
-CONF_COLOR_TEMP_VALUE_TEMPLATE = "color_temp_value_template"
-CONF_EFFECT_COMMAND_TEMPLATE = "effect_command_template"
-CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic"
-CONF_EFFECT_LIST = "effect_list"
-CONF_EFFECT_STATE_TOPIC = "effect_state_topic"
-CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template"
-CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
-CONF_HS_COMMAND_TOPIC = "hs_command_topic"
-CONF_HS_STATE_TOPIC = "hs_state_topic"
-CONF_HS_VALUE_TEMPLATE = "hs_value_template"
-CONF_MAX_MIREDS = "max_mireds"
-CONF_MIN_MIREDS = "min_mireds"
-CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template"
-CONF_RGB_COMMAND_TOPIC = "rgb_command_topic"
-CONF_RGB_STATE_TOPIC = "rgb_state_topic"
-CONF_RGB_VALUE_TEMPLATE = "rgb_value_template"
-CONF_RGBW_COMMAND_TEMPLATE = "rgbw_command_template"
-CONF_RGBW_COMMAND_TOPIC = "rgbw_command_topic"
-CONF_RGBW_STATE_TOPIC = "rgbw_state_topic"
-CONF_RGBW_VALUE_TEMPLATE = "rgbw_value_template"
-CONF_RGBWW_COMMAND_TEMPLATE = "rgbww_command_template"
-CONF_RGBWW_COMMAND_TOPIC = "rgbww_command_topic"
-CONF_RGBWW_STATE_TOPIC = "rgbww_state_topic"
-CONF_RGBWW_VALUE_TEMPLATE = "rgbww_value_template"
-CONF_XY_COMMAND_TEMPLATE = "xy_command_template"
-CONF_XY_COMMAND_TOPIC = "xy_command_topic"
-CONF_XY_STATE_TOPIC = "xy_state_topic"
-CONF_XY_VALUE_TEMPLATE = "xy_value_template"
-CONF_WHITE_COMMAND_TOPIC = "white_command_topic"
-CONF_WHITE_SCALE = "white_scale"
-CONF_ON_COMMAND_TYPE = "on_command_type"
+DEFAULT_NAME = "MQTT LightEntity"
MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset(
{
@@ -137,13 +143,6 @@ MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset(
}
)
-DEFAULT_BRIGHTNESS_SCALE = 255
-DEFAULT_NAME = "MQTT LightEntity"
-DEFAULT_PAYLOAD_OFF = "OFF"
-DEFAULT_PAYLOAD_ON = "ON"
-DEFAULT_WHITE_SCALE = 255
-DEFAULT_ON_COMMAND_TYPE = "last"
-
VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"]
COMMAND_TEMPLATE_KEYS = [
diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py
index 14e21e61d48..fc76d4bcf6c 100644
--- a/homeassistant/components/mqtt/light/schema_json.py
+++ b/homeassistant/components/mqtt/light/schema_json.py
@@ -31,7 +31,6 @@ from homeassistant.components.light import (
LightEntity,
LightEntityFeature,
brightness_supported,
- color_supported,
valid_supported_color_modes,
)
from homeassistant.const import (
@@ -56,13 +55,28 @@ from homeassistant.util.json import json_loads_object
from .. import subscription
from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA
from ..const import (
+ CONF_COLOR_MODE,
CONF_COLOR_TEMP_KELVIN,
CONF_COMMAND_TOPIC,
+ CONF_EFFECT_LIST,
+ CONF_FLASH,
+ CONF_FLASH_TIME_LONG,
+ CONF_FLASH_TIME_SHORT,
CONF_MAX_KELVIN,
+ CONF_MAX_MIREDS,
CONF_MIN_KELVIN,
+ CONF_MIN_MIREDS,
CONF_QOS,
CONF_RETAIN,
CONF_STATE_TOPIC,
+ CONF_SUPPORTED_COLOR_MODES,
+ CONF_TRANSITION,
+ DEFAULT_BRIGHTNESS,
+ DEFAULT_BRIGHTNESS_SCALE,
+ DEFAULT_EFFECT,
+ DEFAULT_FLASH_TIME_LONG,
+ DEFAULT_FLASH_TIME_SHORT,
+ DEFAULT_WHITE_SCALE,
)
from ..entity import MqttEntity
from ..models import ReceiveMessage
@@ -79,25 +93,10 @@ _LOGGER = logging.getLogger(__name__)
DOMAIN = "mqtt_json"
-DEFAULT_BRIGHTNESS = False
-DEFAULT_EFFECT = False
-DEFAULT_FLASH_TIME_LONG = 10
-DEFAULT_FLASH_TIME_SHORT = 2
DEFAULT_NAME = "MQTT JSON Light"
-DEFAULT_BRIGHTNESS_SCALE = 255
-DEFAULT_WHITE_SCALE = 255
-
-CONF_COLOR_MODE = "color_mode"
-CONF_SUPPORTED_COLOR_MODES = "supported_color_modes"
-
-CONF_EFFECT_LIST = "effect_list"
-
-CONF_FLASH_TIME_LONG = "flash_time_long"
-CONF_FLASH_TIME_SHORT = "flash_time_short"
-
-CONF_MAX_MIREDS = "max_mireds"
-CONF_MIN_MIREDS = "min_mireds"
+DEFAULT_FLASH = True
+DEFAULT_TRANSITION = True
_PLATFORM_SCHEMA_BASE = (
MQTT_RW_SCHEMA.extend(
@@ -109,6 +108,7 @@ _PLATFORM_SCHEMA_BASE = (
vol.Optional(CONF_COLOR_TEMP_KELVIN, default=False): cv.boolean,
vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean,
vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_FLASH, default=DEFAULT_FLASH): cv.boolean,
vol.Optional(
CONF_FLASH_TIME_LONG, default=DEFAULT_FLASH_TIME_LONG
): cv.positive_int,
@@ -131,6 +131,7 @@ _PLATFORM_SCHEMA_BASE = (
vol.Unique(),
valid_supported_color_modes,
),
+ vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.boolean,
vol.Optional(CONF_WHITE_SCALE, default=DEFAULT_WHITE_SCALE): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
@@ -205,18 +206,23 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
for key in (CONF_FLASH_TIME_SHORT, CONF_FLASH_TIME_LONG)
}
- self._attr_supported_features = (
- LightEntityFeature.TRANSITION | LightEntityFeature.FLASH
- )
self._attr_supported_features |= (
config[CONF_EFFECT] and LightEntityFeature.EFFECT
)
+ self._attr_supported_features |= config[CONF_FLASH] and LightEntityFeature.FLASH
+ self._attr_supported_features |= (
+ config[CONF_TRANSITION] and LightEntityFeature.TRANSITION
+ )
if supported_color_modes := self._config.get(CONF_SUPPORTED_COLOR_MODES):
self._attr_supported_color_modes = supported_color_modes
if self.supported_color_modes and len(self.supported_color_modes) == 1:
self._attr_color_mode = next(iter(self.supported_color_modes))
else:
self._attr_color_mode = ColorMode.UNKNOWN
+ elif config.get(CONF_BRIGHTNESS):
+ # Brightness is supported and no supported_color_modes are set,
+ # so set brightness as the supported color mode.
+ self._attr_supported_color_modes = {ColorMode.BRIGHTNESS}
def _update_color(self, values: dict[str, Any]) -> None:
color_mode: str = values["color_mode"]
@@ -289,7 +295,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
elif values["state"] is None:
self._attr_is_on = None
- if color_supported(self.supported_color_modes) and "color_mode" in values:
+ if "color_mode" in values:
self._update_color(values)
if brightness_supported(self.supported_color_modes):
diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py
index 901cee6f14c..f561f15fb51 100644
--- a/homeassistant/components/mqtt/light/schema_template.py
+++ b/homeassistant/components/mqtt/light/schema_template.py
@@ -40,10 +40,21 @@ from homeassistant.util import color as color_util
from .. import subscription
from ..config import MQTT_RW_SCHEMA
from ..const import (
+ CONF_BLUE_TEMPLATE,
+ CONF_BRIGHTNESS_TEMPLATE,
CONF_COLOR_TEMP_KELVIN,
+ CONF_COLOR_TEMP_TEMPLATE,
+ CONF_COMMAND_OFF_TEMPLATE,
+ CONF_COMMAND_ON_TEMPLATE,
CONF_COMMAND_TOPIC,
+ CONF_EFFECT_LIST,
+ CONF_EFFECT_TEMPLATE,
+ CONF_GREEN_TEMPLATE,
CONF_MAX_KELVIN,
+ CONF_MAX_MIREDS,
CONF_MIN_KELVIN,
+ CONF_MIN_MIREDS,
+ CONF_RED_TEMPLATE,
CONF_STATE_TOPIC,
PAYLOAD_NONE,
)
@@ -51,6 +62,7 @@ from ..entity import MqttEntity
from ..models import (
MqttCommandTemplate,
MqttValueTemplate,
+ PayloadSentinel,
PublishPayloadType,
ReceiveMessage,
)
@@ -64,18 +76,6 @@ DOMAIN = "mqtt_template"
DEFAULT_NAME = "MQTT Template Light"
-CONF_BLUE_TEMPLATE = "blue_template"
-CONF_BRIGHTNESS_TEMPLATE = "brightness_template"
-CONF_COLOR_TEMP_TEMPLATE = "color_temp_template"
-CONF_COMMAND_OFF_TEMPLATE = "command_off_template"
-CONF_COMMAND_ON_TEMPLATE = "command_on_template"
-CONF_EFFECT_LIST = "effect_list"
-CONF_EFFECT_TEMPLATE = "effect_template"
-CONF_GREEN_TEMPLATE = "green_template"
-CONF_MAX_MIREDS = "max_mireds"
-CONF_MIN_MIREDS = "min_mireds"
-CONF_RED_TEMPLATE = "red_template"
-
COMMAND_TEMPLATES = (CONF_COMMAND_ON_TEMPLATE, CONF_COMMAND_OFF_TEMPLATE)
VALUE_TEMPLATES = (
CONF_BLUE_TEMPLATE,
@@ -127,7 +127,9 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
_command_templates: dict[
str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType]
]
- _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]]
+ _value_templates: dict[
+ str, Callable[[ReceivePayloadType, ReceivePayloadType], ReceivePayloadType]
+ ]
_fixed_color_mode: ColorMode | str | None
_topics: dict[str, str | None]
@@ -204,73 +206,133 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
@callback
def _state_received(self, msg: ReceiveMessage) -> None:
"""Handle new MQTT messages."""
- state = self._value_templates[CONF_STATE_TEMPLATE](msg.payload)
- if state == STATE_ON:
+ state_value = self._value_templates[CONF_STATE_TEMPLATE](
+ msg.payload,
+ PayloadSentinel.NONE,
+ )
+ if not state_value:
+ _LOGGER.debug(
+ "Ignoring message from '%s' with empty state value", msg.topic
+ )
+ elif state_value == STATE_ON:
self._attr_is_on = True
- elif state == STATE_OFF:
+ elif state_value == STATE_OFF:
self._attr_is_on = False
- elif state == PAYLOAD_NONE:
+ elif state_value == PAYLOAD_NONE:
self._attr_is_on = None
else:
- _LOGGER.warning("Invalid state value received")
+ _LOGGER.warning(
+ "Invalid state value '%s' received from %s",
+ state_value,
+ msg.topic,
+ )
if CONF_BRIGHTNESS_TEMPLATE in self._config:
- try:
- if brightness := int(
- self._value_templates[CONF_BRIGHTNESS_TEMPLATE](msg.payload)
- ):
- self._attr_brightness = brightness
- else:
- _LOGGER.debug(
- "Ignoring zero brightness value for entity %s",
- self.entity_id,
+ brightness_value = self._value_templates[CONF_BRIGHTNESS_TEMPLATE](
+ msg.payload,
+ PayloadSentinel.NONE,
+ )
+ if not brightness_value:
+ _LOGGER.debug(
+ "Ignoring message from '%s' with empty brightness value",
+ msg.topic,
+ )
+ else:
+ try:
+ if brightness := int(brightness_value):
+ self._attr_brightness = brightness
+ else:
+ _LOGGER.debug(
+ "Ignoring zero brightness value for entity %s",
+ self.entity_id,
+ )
+ except ValueError:
+ _LOGGER.warning(
+ "Invalid brightness value '%s' received from %s",
+ brightness_value,
+ msg.topic,
)
- except ValueError:
- _LOGGER.warning("Invalid brightness value received from %s", msg.topic)
-
if CONF_COLOR_TEMP_TEMPLATE in self._config:
- try:
- color_temp = self._value_templates[CONF_COLOR_TEMP_TEMPLATE](
- msg.payload
+ color_temp_value = self._value_templates[CONF_COLOR_TEMP_TEMPLATE](
+ msg.payload,
+ PayloadSentinel.NONE,
+ )
+ if not color_temp_value:
+ _LOGGER.debug(
+ "Ignoring message from '%s' with empty color temperature value",
+ msg.topic,
)
- self._attr_color_temp_kelvin = (
- int(color_temp)
- if self._color_temp_kelvin
- else color_util.color_temperature_mired_to_kelvin(int(color_temp))
- if color_temp != "None"
- else None
- )
- except ValueError:
- _LOGGER.warning("Invalid color temperature value received")
+ else:
+ try:
+ self._attr_color_temp_kelvin = (
+ int(color_temp_value)
+ if self._color_temp_kelvin
+ else color_util.color_temperature_mired_to_kelvin(
+ int(color_temp_value)
+ )
+ if color_temp_value != "None"
+ else None
+ )
+ except ValueError:
+ _LOGGER.warning(
+ "Invalid color temperature value '%s' received from %s",
+ color_temp_value,
+ msg.topic,
+ )
if (
CONF_RED_TEMPLATE in self._config
and CONF_GREEN_TEMPLATE in self._config
and CONF_BLUE_TEMPLATE in self._config
):
- try:
- red = self._value_templates[CONF_RED_TEMPLATE](msg.payload)
- green = self._value_templates[CONF_GREEN_TEMPLATE](msg.payload)
- blue = self._value_templates[CONF_BLUE_TEMPLATE](msg.payload)
- if red == "None" and green == "None" and blue == "None":
- self._attr_hs_color = None
- else:
- self._attr_hs_color = color_util.color_RGB_to_hs(
- int(red), int(green), int(blue)
- )
+ red_value = self._value_templates[CONF_RED_TEMPLATE](
+ msg.payload,
+ PayloadSentinel.NONE,
+ )
+ green_value = self._value_templates[CONF_GREEN_TEMPLATE](
+ msg.payload,
+ PayloadSentinel.NONE,
+ )
+ blue_value = self._value_templates[CONF_BLUE_TEMPLATE](
+ msg.payload,
+ PayloadSentinel.NONE,
+ )
+ if not red_value or not green_value or not blue_value:
+ _LOGGER.debug(
+ "Ignoring message from '%s' with empty color value", msg.topic
+ )
+ elif red_value == "None" and green_value == "None" and blue_value == "None":
+ self._attr_hs_color = None
self._update_color_mode()
- except ValueError:
- _LOGGER.warning("Invalid color value received")
+ else:
+ try:
+ self._attr_hs_color = color_util.color_RGB_to_hs(
+ int(red_value), int(green_value), int(blue_value)
+ )
+ self._update_color_mode()
+ except ValueError:
+ _LOGGER.warning("Invalid color value received from %s", msg.topic)
if CONF_EFFECT_TEMPLATE in self._config:
- effect = str(self._value_templates[CONF_EFFECT_TEMPLATE](msg.payload))
- if (
- effect_list := self._config[CONF_EFFECT_LIST]
- ) and effect in effect_list:
- self._attr_effect = effect
+ effect_value = self._value_templates[CONF_EFFECT_TEMPLATE](
+ msg.payload,
+ PayloadSentinel.NONE,
+ )
+ if not effect_value:
+ _LOGGER.debug(
+ "Ignoring message from '%s' with empty effect value", msg.topic
+ )
+ elif (effect_list := self._config[CONF_EFFECT_LIST]) and str(
+ effect_value
+ ) in effect_list:
+ self._attr_effect = str(effect_value)
else:
- _LOGGER.warning("Unsupported effect value received")
+ _LOGGER.warning(
+ "Unsupported effect value '%s' received from %s",
+ effect_value,
+ msg.topic,
+ )
@callback
def _prepare_subscribe_topics(self) -> None:
diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py
index 895bfba3560..727e689798e 100644
--- a/homeassistant/components/mqtt/lock.py
+++ b/homeassistant/components/mqtt/lock.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
@@ -116,7 +116,7 @@ STATE_CONFIG_KEYS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT lock through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py
index 34c1f304944..8a42797b0f2 100644
--- a/homeassistant/components/mqtt/models.py
+++ b/homeassistant/components/mqtt/models.py
@@ -420,5 +420,41 @@ class MqttComponentConfig:
discovery_payload: MQTTDiscoveryPayload
+class DeviceMqttOptions(TypedDict, total=False):
+ """Hold the shared MQTT specific options for an MQTT device."""
+
+ qos: int
+
+
+class MqttDeviceData(TypedDict, total=False):
+ """Hold the data for an MQTT device."""
+
+ name: str
+ identifiers: str
+ configuration_url: str
+ sw_version: str
+ hw_version: str
+ model: str
+ model_id: str
+ mqtt_settings: DeviceMqttOptions
+
+
+class MqttAvailabilityData(TypedDict, total=False):
+ """Hold the availability configuration for a device."""
+
+ availability_topic: str
+ availability_template: str
+ payload_available: str
+ payload_not_available: str
+
+
+class MqttSubentryData(TypedDict, total=False):
+ """Hold the data for a MQTT subentry."""
+
+ device: MqttDeviceData
+ components: dict[str, dict[str, Any]]
+ availability: MqttAvailabilityData
+
+
DATA_MQTT: HassKey[MqttData] = HassKey("mqtt")
DATA_MQTT_AVAILABLE: HassKey[asyncio.Future[bool]] = HassKey("mqtt_client_available")
diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py
index 7e0a7fd4dd8..0b6dbce38b4 100644
--- a/homeassistant/components/mqtt/notify.py
+++ b/homeassistant/components/mqtt/notify.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA
@@ -39,7 +39,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT notify through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py
index 9b47a3ad23a..c3cc31bf04f 100644
--- a/homeassistant/components/mqtt/number.py
+++ b/homeassistant/components/mqtt/number.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
@@ -70,8 +70,8 @@ MQTT_NUMBER_ATTRIBUTES_BLOCKED = frozenset(
def validate_config(config: ConfigType) -> ConfigType:
"""Validate that the configuration is valid, throws if it isn't."""
- if config[CONF_MIN] >= config[CONF_MAX]:
- raise vol.Invalid(f"'{CONF_MAX}' must be > '{CONF_MIN}'")
+ if config[CONF_MIN] > config[CONF_MAX]:
+ raise vol.Invalid(f"{CONF_MAX} must be >= {CONF_MIN}")
return config
@@ -109,7 +109,7 @@ DISCOVERY_SCHEMA = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT number through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py
index c6651510a36..12f680b6e12 100644
--- a/homeassistant/components/mqtt/scene.py
+++ b/homeassistant/components/mqtt/scene.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from .config import MQTT_BASE_SCHEMA
@@ -43,7 +43,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT scene through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py
index 55d56ecd774..1b3ea1a7c44 100644
--- a/homeassistant/components/mqtt/select.py
+++ b/homeassistant/components/mqtt/select.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
@@ -63,7 +63,7 @@ DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EX
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT select through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py
index ad84ebb09a3..b27ef68368a 100644
--- a/homeassistant/components/mqtt/sensor.py
+++ b/homeassistant/components/mqtt/sensor.py
@@ -11,6 +11,7 @@ import voluptuous as vol
from homeassistant.components import sensor
from homeassistant.components.sensor import (
CONF_STATE_CLASS,
+ DEVICE_CLASS_UNITS,
DEVICE_CLASSES_SCHEMA,
ENTITY_ID_FORMAT,
STATE_CLASSES_SCHEMA,
@@ -31,15 +32,24 @@ from homeassistant.const import (
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
from homeassistant.util import dt as dt_util
from . import subscription
from .config import MQTT_RO_SCHEMA
-from .const import CONF_OPTIONS, CONF_STATE_TOPIC, PAYLOAD_NONE
+from .const import (
+ CONF_EXPIRE_AFTER,
+ CONF_LAST_RESET_VALUE_TEMPLATE,
+ CONF_OPTIONS,
+ CONF_STATE_TOPIC,
+ CONF_SUGGESTED_DISPLAY_PRECISION,
+ DOMAIN,
+ PAYLOAD_NONE,
+)
from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper
from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
@@ -49,10 +59,6 @@ _LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
-CONF_EXPIRE_AFTER = "expire_after"
-CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
-CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
-
MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset(
{
sensor.ATTR_LAST_RESET,
@@ -63,6 +69,10 @@ MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset(
DEFAULT_NAME = "MQTT Sensor"
DEFAULT_FORCE_UPDATE = False
+URL_DOCS_SUPPORTED_SENSOR_UOM = (
+ "https://www.home-assistant.io/integrations/sensor/#device-class"
+)
+
_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend(
{
vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None),
@@ -107,6 +117,23 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT
f"got `{CONF_DEVICE_CLASS}` '{device_class}'"
)
+ if (device_class := config.get(CONF_DEVICE_CLASS)) is None or (
+ unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)
+ ) is None:
+ return config
+
+ if (
+ device_class in DEVICE_CLASS_UNITS
+ and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class]
+ ):
+ _LOGGER.warning(
+ "The unit of measurement `%s` is not valid "
+ "together with device class `%s`. "
+ "this will stop working in HA Core 2025.7.0",
+ unit_of_measurement,
+ device_class,
+ )
+
return config
@@ -124,7 +151,7 @@ DISCOVERY_SCHEMA = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT sensor through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
@@ -155,8 +182,40 @@ class MqttSensor(MqttEntity, RestoreSensor):
None
)
+ @callback
+ def async_check_uom(self) -> None:
+ """Check if the unit of measurement is valid with the device class."""
+ if (
+ self._discovery_data is not None
+ or self.device_class is None
+ or self.native_unit_of_measurement is None
+ ):
+ return
+ if (
+ self.device_class in DEVICE_CLASS_UNITS
+ and self.native_unit_of_measurement
+ not in DEVICE_CLASS_UNITS[self.device_class]
+ ):
+ async_create_issue(
+ self.hass,
+ DOMAIN,
+ self.entity_id,
+ issue_domain=sensor.DOMAIN,
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ learn_more_url=URL_DOCS_SUPPORTED_SENSOR_UOM,
+ translation_placeholders={
+ "uom": self.native_unit_of_measurement,
+ "device_class": self.device_class.value,
+ "entity_id": self.entity_id,
+ },
+ translation_key="invalid_unit_of_measurement",
+ breaks_in_ha_version="2025.7.0",
+ )
+
async def mqtt_async_added_to_hass(self) -> None:
"""Restore state for entities with expire_after set."""
+ self.async_check_uom()
last_state: State | None
last_sensor_data: SensorExtraStoredData | None
if (
diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py
index 5e3ca76e722..48ab4676dea 100644
--- a/homeassistant/components/mqtt/siren.py
+++ b/homeassistant/components/mqtt/siren.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.template import Template
@@ -113,7 +113,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT siren through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json
index fc316306d56..4245af2fc95 100644
--- a/homeassistant/components/mqtt/strings.json
+++ b/homeassistant/components/mqtt/strings.json
@@ -1,8 +1,12 @@
{
"issues": {
"invalid_platform_config": {
- "title": "Invalid config found for mqtt {domain} item",
+ "title": "Invalid config found for MQTT {domain} item",
"description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue."
+ },
+ "invalid_unit_of_measurement": {
+ "title": "Sensor with invalid unit of measurement",
+ "description": "Manual configured Sensor entity **{entity_id}** has a configured unit of measurement **{uom}** which is not valid with configured device class **{device_class}**. Make sure a valid unit of measurement is configured or remove the device class, and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue."
}
},
"config": {
@@ -26,6 +30,7 @@
"client_id": "Client ID (leave empty to randomly generated one)",
"client_cert": "Upload client certificate file",
"client_key": "Upload private key file",
+ "client_key_password": "[%key:common::config_flow::data::password%]",
"keepalive": "The time between sending keep alive messages",
"tls_insecure": "Ignore broker certificate validation",
"protocol": "MQTT protocol",
@@ -38,13 +43,14 @@
"data_description": {
"broker": "The hostname or IP address of your MQTT broker.",
"port": "The port your MQTT broker listens to. For example 1883.",
- "username": "The username to login to your MQTT broker.",
- "password": "The password to login to your MQTT broker.",
+ "username": "The username to log in to your MQTT broker.",
+ "password": "The password to log in to your MQTT broker.",
"advanced_options": "Enable and select **Next** to set advanced options.",
"certificate": "The custom CA certificate file to validate your MQTT brokers certificate.",
"client_id": "The unique ID to identify the Home Assistant MQTT API as MQTT client. It is recommended to leave this option blank.",
"client_cert": "The client certificate to authenticate against your MQTT broker.",
"client_key": "The private key file that belongs to your client certificate.",
+ "client_key_password": "The password for the private key file (if set).",
"keepalive": "A value less than 90 seconds is advised.",
"tls_insecure": "Option to ignore validation of your MQTT broker's certificate.",
"protocol": "The MQTT protocol your broker operates at. For example 3.1.1.",
@@ -62,7 +68,7 @@
"title": "Starting add-on"
},
"hassio_confirm": {
- "title": "MQTT Broker via Home Assistant add-on",
+ "title": "MQTT broker via Home Assistant add-on",
"description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the add-on {addon}?"
},
"reauth_confirm": {
@@ -93,8 +99,8 @@
"bad_will": "Invalid will topic",
"bad_discovery_prefix": "Invalid discovery prefix",
"bad_certificate": "The CA certificate is invalid",
- "bad_client_cert": "Invalid client certificate, ensure a PEM coded file is supplied",
- "bad_client_key": "Invalid private key, ensure a PEM coded file is supplied without password",
+ "bad_client_cert": "Invalid client certificate, ensure a valid file is supplied",
+ "client_key_error": "Invalid private key file or invalid password supplied",
"bad_client_cert_key": "Client certificate and private key are not a valid pair",
"bad_ws_headers": "Supply valid HTTP headers as a JSON object",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -102,6 +108,189 @@
"invalid_inclusion": "The client certificate and private key must be configured together"
}
},
+ "config_subentries": {
+ "device": {
+ "initiate_flow": {
+ "user": "Add MQTT Device",
+ "reconfigure": "Reconfigure MQTT Device"
+ },
+ "entry_type": "MQTT Device",
+ "step": {
+ "availability": {
+ "title": "Availability options",
+ "description": "The availability feature allows a device to report it's availability.",
+ "data": {
+ "availability_topic": "Availability topic",
+ "availability_template": "Availability template",
+ "payload_available": "Payload available",
+ "payload_not_available": "Payload not available"
+ },
+ "data_description": {
+ "availability_topic": "Topic to receive the availability payload on",
+ "availability_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-templates-with-the-mqtt-integration) to render the availability payload received on the availability topic",
+ "payload_available": "The payload that indicates the device is available (defaults to 'online')",
+ "payload_not_available": "The payload that indicates the device is not available (defaults to 'offline')"
+ }
+ },
+ "device": {
+ "title": "Configure MQTT device details",
+ "description": "Enter the MQTT device details:",
+ "data": {
+ "name": "[%key:common::config_flow::data::name%]",
+ "configuration_url": "Configuration URL",
+ "sw_version": "Software version",
+ "hw_version": "Hardware version",
+ "model": "Model",
+ "model_id": "Model ID"
+ },
+ "data_description": {
+ "name": "The name of the manually added MQTT device.",
+ "configuration_url": "A link to the webpage that can manage the configuration of this device. Can be either a 'http://', 'https://' or an internal 'homeassistant://' URL.",
+ "sw_version": "The software version of the device. E.g. '2025.1.0'.",
+ "hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.",
+ "model": "E.g. 'Cleanmaster Pro'.",
+ "model_id": "E.g. '123NK2PRO'."
+ },
+ "sections": {
+ "mqtt_settings": {
+ "name": "MQTT settings",
+ "data": {
+ "qos": "QoS"
+ },
+ "data_description": {
+ "qos": "The Quality of Service value the device's entities should use."
+ }
+ }
+ }
+ },
+ "summary_menu": {
+ "title": "Reconfigure \"{mqtt_device}\"",
+ "description": "Entities set up:\n{mqtt_items}\n\nDecide what to do next:",
+ "menu_options": {
+ "entity": "Add another entity to \"{mqtt_device}\"",
+ "update_entity": "Update entity properties",
+ "delete_entity": "Delete an entity",
+ "availability": "Configure availability",
+ "device": "Update device properties",
+ "save_changes": "Save changes"
+ }
+ },
+ "entity": {
+ "title": "Configure MQTT device \"{mqtt_device}\"",
+ "description": "Configure the basic {platform_label}entity settings{entity_name_label}",
+ "data": {
+ "platform": "Type of entity",
+ "name": "Entity name",
+ "entity_picture": "Entity picture"
+ },
+ "data_description": {
+ "platform": "The type of the entity to configure.",
+ "name": "The name of the entity. Leave empty to set it to `None` to [mark it as main feature of the MQTT device](https://www.home-assistant.io/integrations/mqtt/#naming-of-mqtt-entities).",
+ "entity_picture": "An URL to a picture to be assigned."
+ }
+ },
+ "delete_entity": {
+ "title": "Delete entity",
+ "description": "Delete an entity. The entity will be removed from the device. Removing an entity will break any automations or scripts that depend on it.",
+ "data": {
+ "component": "Entity"
+ },
+ "data_description": {
+ "component": "Select the entity you want to delete. Minimal one entity is required."
+ }
+ },
+ "update_entity": {
+ "title": "Select entity",
+ "description": "Select the entity you want to update",
+ "data": {
+ "component": "Entity"
+ },
+ "data_description": {
+ "component": "Select the entity you want to update."
+ }
+ },
+ "entity_platform_config": {
+ "title": "Configure MQTT device \"{mqtt_device}\"",
+ "description": "Please configure specific details for {platform} entity \"{entity}\":",
+ "data": {
+ "device_class": "Device class",
+ "state_class": "State class",
+ "unit_of_measurement": "Unit of measurement",
+ "options": "Add option"
+ },
+ "data_description": {
+ "device_class": "The Device class of the {platform} entity. [Learn more.]({url}#device_class)",
+ "state_class": "The [State class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)",
+ "unit_of_measurement": "Defines the unit of measurement of the sensor, if any.",
+ "options": "Options for allowed sensor state values. The sensor’s Device class must be set to Enumeration. The 'Options' setting cannot be used together with State class or Unit of measurement."
+ },
+ "sections": {
+ "advanced_settings": {
+ "name": "Advanced options",
+ "data": {
+ "suggested_display_precision": "Suggested display precision"
+ },
+ "data_description": {
+ "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)"
+ }
+ }
+ }
+ },
+ "mqtt_platform_config": {
+ "title": "Configure MQTT device \"{mqtt_device}\"",
+ "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":",
+ "data": {
+ "command_topic": "Command topic",
+ "command_template": "Command template",
+ "state_topic": "State topic",
+ "value_template": "Value template",
+ "last_reset_value_template": "Last reset value template",
+ "force_update": "Force update",
+ "optimistic": "Optimistic",
+ "retain": "Retain"
+ },
+ "data_description": {
+ "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)",
+ "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic.",
+ "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)",
+ "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value.",
+ "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)",
+ "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)",
+ "optimistic": "Flag that defines if the {platform} entity works in optimistic mode. [Learn more.]({url}#optimistic)",
+ "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker."
+ },
+ "sections": {
+ "advanced_settings": {
+ "name": "Advanced settings",
+ "data": {
+ "expire_after": "Expire after"
+ },
+ "data_description": {
+ "expire_after": "If set, it defines the number of seconds after the sensor’s state expires, if it’s not updated. After expiry, the sensor’s state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)"
+ }
+ }
+ }
+ }
+ },
+ "abort": {
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ },
+ "create_entry": {
+ "default": "MQTT device with {platform} entity \"{entity}\" was set up successfully.\n\nNote that you can reconfigure the MQTT device at any time, e.g. to add more entities."
+ },
+ "error": {
+ "invalid_input": "Invalid value",
+ "invalid_subscribe_topic": "Invalid subscribe topic",
+ "invalid_template": "Invalid template",
+ "invalid_uom": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected device class, please either remove the device class, select a device class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list",
+ "invalid_url": "Invalid URL",
+ "options_not_allowed_with_state_class_or_uom": "The 'Options' setting is not allowed when state class or unit of measurement are used",
+ "options_device_class_enum": "The 'Options' setting must be used with the Enumeration device class. If you continue, the existing options will be reset",
+ "options_with_enum_device_class": "Configure options for the enumeration sensor",
+ "uom_required_for_device_class": "The selected device class requires a unit"
+ }
+ }
+ },
"device_automation": {
"trigger_type": {
"button_short_press": "\"{subtype}\" pressed",
@@ -207,7 +396,7 @@
"bad_discovery_prefix": "[%key:component::mqtt::config::error::bad_discovery_prefix%]",
"bad_certificate": "[%key:component::mqtt::config::error::bad_certificate%]",
"bad_client_cert": "[%key:component::mqtt::config::error::bad_client_cert%]",
- "bad_client_key": "[%key:component::mqtt::config::error::bad_client_key%]",
+ "client_key_error": "[%key:component::mqtt::config::error::client_key_error%]",
"bad_client_cert_key": "[%key:component::mqtt::config::error::bad_client_cert_key%]",
"bad_ws_headers": "[%key:component::mqtt::config::error::bad_ws_headers%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -215,12 +404,92 @@
}
},
"selector": {
+ "device_class_sensor": {
+ "options": {
+ "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
+ "area": "[%key:component::sensor::entity_component::area::name%]",
+ "aqi": "[%key:component::sensor::entity_component::aqi::name%]",
+ "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
+ "battery": "[%key:component::sensor::entity_component::battery::name%]",
+ "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]",
+ "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
+ "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
+ "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]",
+ "current": "[%key:component::sensor::entity_component::current::name%]",
+ "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
+ "data_size": "[%key:component::sensor::entity_component::data_size::name%]",
+ "date": "[%key:component::sensor::entity_component::date::name%]",
+ "distance": "[%key:component::sensor::entity_component::distance::name%]",
+ "duration": "[%key:component::sensor::entity_component::duration::name%]",
+ "energy": "[%key:component::sensor::entity_component::energy::name%]",
+ "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]",
+ "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
+ "enum": "Enumeration",
+ "frequency": "[%key:component::sensor::entity_component::frequency::name%]",
+ "gas": "[%key:component::sensor::entity_component::gas::name%]",
+ "humidity": "[%key:component::sensor::entity_component::humidity::name%]",
+ "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
+ "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
+ "moisture": "[%key:component::sensor::entity_component::moisture::name%]",
+ "monetary": "[%key:component::sensor::entity_component::monetary::name%]",
+ "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
+ "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
+ "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
+ "ozone": "[%key:component::sensor::entity_component::ozone::name%]",
+ "ph": "[%key:component::sensor::entity_component::ph::name%]",
+ "pm1": "[%key:component::sensor::entity_component::pm1::name%]",
+ "pm10": "[%key:component::sensor::entity_component::pm10::name%]",
+ "pm25": "[%key:component::sensor::entity_component::pm25::name%]",
+ "power": "[%key:component::sensor::entity_component::power::name%]",
+ "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
+ "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
+ "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
+ "pressure": "[%key:component::sensor::entity_component::pressure::name%]",
+ "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
+ "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
+ "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
+ "speed": "[%key:component::sensor::entity_component::speed::name%]",
+ "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
+ "temperature": "[%key:component::sensor::entity_component::temperature::name%]",
+ "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
+ "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
+ "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]",
+ "voltage": "[%key:component::sensor::entity_component::voltage::name%]",
+ "volume": "[%key:component::sensor::entity_component::volume::name%]",
+ "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]",
+ "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
+ "water": "[%key:component::sensor::entity_component::water::name%]",
+ "weight": "[%key:component::sensor::entity_component::weight::name%]",
+ "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]",
+ "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
+ }
+ },
+ "device_class_switch": {
+ "options": {
+ "outlet": "[%key:component::switch::entity_component::outlet::name%]",
+ "switch": "[%key:component::switch::title%]"
+ }
+ },
+ "platform": {
+ "options": {
+ "notify": "[%key:component::notify::title%]",
+ "sensor": "[%key:component::sensor::title%]",
+ "switch": "[%key:component::switch::title%]"
+ }
+ },
"set_ca_cert": {
"options": {
"off": "[%key:common::state::off%]",
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
"custom": "Custom"
}
+ },
+ "state_class": {
+ "options": {
+ "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
+ "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
+ "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
+ }
}
},
"services": {
@@ -230,7 +499,7 @@
"fields": {
"evaluate_payload": {
"name": "Evaluate payload",
- "description": "When `payload` is a Python bytes literal, evaluate the bytes literal and publish the raw data."
+ "description": "If 'Payload' is a Python bytes literal, evaluate the bytes literal and publish the raw data."
},
"topic": {
"name": "Topic",
diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py
index a305fa83485..f6996fc77ce 100644
--- a/homeassistant/components/mqtt/switch.py
+++ b/homeassistant/components/mqtt/switch.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType
@@ -70,7 +70,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT switch through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py
index b4ed33a7730..d306fc0819b 100644
--- a/homeassistant/components/mqtt/text.py
+++ b/homeassistant/components/mqtt/text.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import ConfigType, VolSchemaType
@@ -95,7 +95,7 @@ PLATFORM_SCHEMA_MODERN = vol.All(_PLATFORM_SCHEMA_BASE, valid_text_size_configur
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT text through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py
index 59742d24b60..145f0a2562c 100644
--- a/homeassistant/components/mqtt/update.py
+++ b/homeassistant/components/mqtt/update.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, VolSchemaType
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
@@ -26,7 +26,7 @@ from . import subscription
from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA
from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON
from .entity import MqttEntity, async_setup_entity_entry_helper
-from .models import MqttValueTemplate, ReceiveMessage
+from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
from .util import valid_publish_topic, valid_subscribe_topic
@@ -82,7 +82,7 @@ MQTT_JSON_UPDATE_SCHEMA = vol.Schema(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT update entity through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
@@ -136,7 +136,18 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity):
@callback
def _handle_state_message_received(self, msg: ReceiveMessage) -> None:
"""Handle receiving state message via MQTT."""
- payload = self._templates[CONF_VALUE_TEMPLATE](msg.payload)
+ payload = self._templates[CONF_VALUE_TEMPLATE](
+ msg.payload, PayloadSentinel.DEFAULT
+ )
+
+ if payload is PayloadSentinel.DEFAULT:
+ _LOGGER.warning(
+ "Unable to process payload '%s' for topic %s, with value template '%s'",
+ msg.payload,
+ msg.topic,
+ self._config.get(CONF_VALUE_TEMPLATE),
+ )
+ return
if not payload or payload == PAYLOAD_EMPTY_JSON:
_LOGGER.debug(
diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py
index 27bdb4f2a35..e3996c80a8a 100644
--- a/homeassistant/components/mqtt/util.py
+++ b/homeassistant/components/mqtt/util.py
@@ -411,3 +411,9 @@ def migrate_certificate_file_to_content(file_name_or_auto: str) -> str | None:
return certificate_file.read()
except OSError:
return None
+
+
+@callback
+def learn_more_url(platform: str) -> str:
+ """Return the URL for the platform specific MQTT documentation."""
+ return f"https://www.home-assistant.io/integrations/{platform}.mqtt/"
diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py
index ae6b25eff14..f1d2eb34fe1 100644
--- a/homeassistant/components/mqtt/vacuum.py
+++ b/homeassistant/components/mqtt/vacuum.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType
from homeassistant.util.json import json_loads_object
@@ -175,7 +175,7 @@ DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.ALLOW_EXTRA)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT vacuum through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py
index b380199332b..53f7d06429e 100644
--- a/homeassistant/components/mqtt/valve.py
+++ b/homeassistant/components/mqtt/valve.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import ConfigType, VolSchemaType
from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads
from homeassistant.util.percentage import (
@@ -136,7 +136,7 @@ DISCOVERY_SCHEMA = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT valve through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py
index 967eceac326..31d4f0fe30e 100644
--- a/homeassistant/components/mqtt/water_heater.py
+++ b/homeassistant/components/mqtt/water_heater.py
@@ -36,7 +36,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, VolSchemaType
from homeassistant.util.unit_conversion import TemperatureConverter
@@ -166,7 +166,7 @@ DISCOVERY_SCHEMA = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MQTT water heater device through YAML and through MQTT discovery."""
async_setup_entity_entry_helper(
diff --git a/homeassistant/components/mullvad/binary_sensor.py b/homeassistant/components/mullvad/binary_sensor.py
index 2e649d9a586..ad488058025 100644
--- a/homeassistant/components/mullvad/binary_sensor.py
+++ b/homeassistant/components/mullvad/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -28,7 +28,7 @@ BINARY_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
coordinator = hass.data[DOMAIN]
diff --git a/homeassistant/components/mullvad/config_flow.py b/homeassistant/components/mullvad/config_flow.py
index c16f8879a7b..b179c5605ef 100644
--- a/homeassistant/components/mullvad/config_flow.py
+++ b/homeassistant/components/mullvad/config_flow.py
@@ -1,5 +1,6 @@
"""Config flow for Mullvad VPN integration."""
+import logging
from typing import Any
from mullvad_api import MullvadAPI, MullvadAPIError
@@ -8,6 +9,8 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from .const import DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
class MullvadConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Mullvad VPN."""
@@ -24,7 +27,8 @@ class MullvadConfigFlow(ConfigFlow, domain=DOMAIN):
await self.hass.async_add_executor_job(MullvadAPI)
except MullvadAPIError:
errors["base"] = "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(title="Mullvad VPN", data=user_input)
diff --git a/homeassistant/components/music_assistant/__init__.py b/homeassistant/components/music_assistant/__init__.py
index e569bb93a42..a2d2dae9e3f 100644
--- a/homeassistant/components/music_assistant/__init__.py
+++ b/homeassistant/components/music_assistant/__init__.py
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING
from music_assistant_client import MusicAssistantClient
from music_assistant_client.exceptions import CannotConnect, InvalidServerVersion
from music_assistant_models.enums import EventType
-from music_assistant_models.errors import MusicAssistantError
+from music_assistant_models.errors import ActionUnavailable, MusicAssistantError
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP, Platform
@@ -23,7 +23,7 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
-from .actions import register_actions
+from .actions import get_music_assistant_client, register_actions
from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
@@ -137,6 +137,18 @@ async def async_setup_entry(
mass.subscribe(handle_player_removed, EventType.PLAYER_REMOVED)
)
+ # check if any playerconfigs have been removed while we were disconnected
+ all_player_configs = await mass.config.get_player_configs()
+ player_ids = {player.player_id for player in all_player_configs}
+ dev_reg = dr.async_get(hass)
+ dev_entries = dr.async_entries_for_config_entry(dev_reg, entry.entry_id)
+ for device in dev_entries:
+ for identifier in device.identifiers:
+ if identifier[0] == DOMAIN and identifier[1] not in player_ids:
+ dev_reg.async_update_device(
+ device.id, remove_config_entry_id=entry.entry_id
+ )
+
return True
@@ -174,3 +186,31 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await mass_entry_data.mass.disconnect()
return unload_ok
+
+
+async def async_remove_config_entry_device(
+ hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry
+) -> bool:
+ """Remove a config entry from a device."""
+ player_id = next(
+ (
+ identifier[1]
+ for identifier in device_entry.identifiers
+ if identifier[0] == DOMAIN
+ ),
+ None,
+ )
+ if player_id is None:
+ # this should not be possible at all, but guard it anyways
+ return False
+ mass = get_music_assistant_client(hass, config_entry.entry_id)
+ if mass.players.get(player_id) is None:
+ # player is already removed on the server, this is an orphaned device
+ return True
+ # try to remove the player from the server
+ try:
+ await mass.config.remove_player_config(player_id)
+ except ActionUnavailable:
+ return False
+ else:
+ return True
diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py
index bcd33b7fd6c..031229d1544 100644
--- a/homeassistant/components/music_assistant/actions.py
+++ b/homeassistant/components/music_assistant/actions.py
@@ -23,6 +23,7 @@ from .const import (
ATTR_ALBUM_TYPE,
ATTR_ALBUMS,
ATTR_ARTISTS,
+ ATTR_AUDIOBOOKS,
ATTR_CONFIG_ENTRY_ID,
ATTR_FAVORITE,
ATTR_ITEMS,
@@ -32,6 +33,7 @@ from .const import (
ATTR_OFFSET,
ATTR_ORDER_BY,
ATTR_PLAYLISTS,
+ ATTR_PODCASTS,
ATTR_RADIO,
ATTR_SEARCH,
ATTR_SEARCH_ALBUM,
@@ -48,6 +50,15 @@ from .schemas import (
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
+ from music_assistant_models.media_items import (
+ Album,
+ Artist,
+ Audiobook,
+ Playlist,
+ Podcast,
+ Radio,
+ Track,
+ )
from . import MusicAssistantConfigEntry
@@ -154,6 +165,14 @@ async def handle_search(call: ServiceCall) -> ServiceResponse:
media_item_dict_from_mass_item(mass, item)
for item in search_results.radio
],
+ ATTR_AUDIOBOOKS: [
+ media_item_dict_from_mass_item(mass, item)
+ for item in search_results.audiobooks
+ ],
+ ATTR_PODCASTS: [
+ media_item_dict_from_mass_item(mass, item)
+ for item in search_results.podcasts
+ ],
}
)
return response
@@ -173,6 +192,15 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
"offset": offset,
"order_by": order_by,
}
+ library_result: (
+ list[Album]
+ | list[Artist]
+ | list[Track]
+ | list[Radio]
+ | list[Playlist]
+ | list[Audiobook]
+ | list[Podcast]
+ )
if media_type == MediaType.ALBUM:
library_result = await mass.music.get_library_albums(
**base_params,
@@ -181,7 +209,7 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
elif media_type == MediaType.ARTIST:
library_result = await mass.music.get_library_artists(
**base_params,
- album_artists_only=call.data.get(ATTR_ALBUM_ARTISTS_ONLY),
+ album_artists_only=bool(call.data.get(ATTR_ALBUM_ARTISTS_ONLY)),
)
elif media_type == MediaType.TRACK:
library_result = await mass.music.get_library_tracks(
@@ -195,6 +223,14 @@ async def handle_get_library(call: ServiceCall) -> ServiceResponse:
library_result = await mass.music.get_library_playlists(
**base_params,
)
+ elif media_type == MediaType.AUDIOBOOK:
+ library_result = await mass.music.get_library_audiobooks(
+ **base_params,
+ )
+ elif media_type == MediaType.PODCAST:
+ library_result = await mass.music.get_library_podcasts(
+ **base_params,
+ )
else:
raise ServiceValidationError(f"Unsupported media type {media_type}")
diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py
index 1980c495278..d2ee1f75028 100644
--- a/homeassistant/components/music_assistant/const.py
+++ b/homeassistant/components/music_assistant/const.py
@@ -34,6 +34,8 @@ ATTR_ARTISTS = "artists"
ATTR_ALBUMS = "albums"
ATTR_TRACKS = "tracks"
ATTR_PLAYLISTS = "playlists"
+ATTR_AUDIOBOOKS = "audiobooks"
+ATTR_PODCASTS = "podcasts"
ATTR_RADIO = "radio"
ATTR_ITEMS = "items"
ATTR_RADIO_MODE = "radio_mode"
diff --git a/homeassistant/components/music_assistant/manifest.json b/homeassistant/components/music_assistant/manifest.json
index f5cdcf50673..28e8587e90c 100644
--- a/homeassistant/components/music_assistant/manifest.json
+++ b/homeassistant/components/music_assistant/manifest.json
@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",
"loggers": ["music_assistant"],
- "requirements": ["music-assistant-client==1.0.8"],
+ "requirements": ["music-assistant-client==1.2.0"],
"zeroconf": ["_mass._tcp.local."]
}
diff --git a/homeassistant/components/music_assistant/media_browser.py b/homeassistant/components/music_assistant/media_browser.py
index e65d6d4a975..a926e2a0595 100644
--- a/homeassistant/components/music_assistant/media_browser.py
+++ b/homeassistant/components/music_assistant/media_browser.py
@@ -166,6 +166,8 @@ async def build_playlist_items_listing(
) -> BrowseMedia:
"""Build Playlist items browse listing."""
playlist = await mass.music.get_item_by_uri(identifier)
+ if TYPE_CHECKING:
+ assert playlist.uri is not None
return BrowseMedia(
media_class=MediaClass.PLAYLIST,
@@ -219,6 +221,9 @@ async def build_artist_items_listing(
artist = await mass.music.get_item_by_uri(identifier)
albums = await mass.music.get_artist_albums(artist.item_id, artist.provider)
+ if TYPE_CHECKING:
+ assert artist.uri is not None
+
return BrowseMedia(
media_class=MediaType.ARTIST,
media_content_id=artist.uri,
@@ -267,6 +272,9 @@ async def build_album_items_listing(
album = await mass.music.get_item_by_uri(identifier)
tracks = await mass.music.get_album_tracks(album.item_id, album.provider)
+ if TYPE_CHECKING:
+ assert album.uri is not None
+
return BrowseMedia(
media_class=MediaType.ALBUM,
media_content_id=album.uri,
@@ -340,6 +348,9 @@ def build_item(
title = item.name
img_url = mass.get_media_item_image_url(item)
+ if TYPE_CHECKING:
+ assert item.uri is not None
+
return BrowseMedia(
media_class=media_class or item.media_type.value,
media_content_id=item.uri,
diff --git a/homeassistant/components/music_assistant/media_player.py b/homeassistant/components/music_assistant/media_player.py
index 4a7e20046b2..11cc48f28a3 100644
--- a/homeassistant/components/music_assistant/media_player.py
+++ b/homeassistant/components/music_assistant/media_player.py
@@ -9,6 +9,7 @@ import functools
import os
from typing import TYPE_CHECKING, Any, Concatenate
+from music_assistant_models.constants import PLAYER_CONTROL_NONE
from music_assistant_models.enums import (
EventType,
MediaType,
@@ -20,6 +21,7 @@ from music_assistant_models.enums import (
from music_assistant_models.errors import MediaNotFoundError, MusicAssistantError
from music_assistant_models.event import MassEvent
from music_assistant_models.media_items import ItemMapping, MediaItemType, Track
+from music_assistant_models.player_queue import PlayerQueue
import voluptuous as vol
from homeassistant.components import media_source
@@ -41,7 +43,7 @@ from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.util.dt import utc_from_timestamp
@@ -78,26 +80,26 @@ from .schemas import QUEUE_DETAILS_SCHEMA, queue_item_dict_from_mass_item
if TYPE_CHECKING:
from music_assistant_client import MusicAssistantClient
from music_assistant_models.player import Player
- from music_assistant_models.player_queue import PlayerQueue
-SUPPORTED_FEATURES = (
- MediaPlayerEntityFeature.PAUSE
- | MediaPlayerEntityFeature.VOLUME_SET
- | MediaPlayerEntityFeature.STOP
+SUPPORTED_FEATURES_BASE = (
+ MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SHUFFLE_SET
| MediaPlayerEntityFeature.REPEAT_SET
- | MediaPlayerEntityFeature.TURN_ON
- | MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.PLAY
| MediaPlayerEntityFeature.PLAY_MEDIA
- | MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.BROWSE_MEDIA
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE
| MediaPlayerEntityFeature.SEEK
+ # we always add pause support,
+ # regardless if the underlying player actually natively supports pause
+ # because the MA behavior is to internally handle pause with stop
+ # (and a resume position) and we'd like to keep the UX consistent
+ # background info: https://github.com/home-assistant/core/issues/140118
+ | MediaPlayerEntityFeature.PAUSE
)
QUEUE_OPTION_MAP = {
@@ -137,7 +139,7 @@ def catch_musicassistant_error[_R, **P](
async def async_setup_entry(
hass: HomeAssistant,
entry: MusicAssistantConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Music Assistant MediaPlayer(s) from Config Entry."""
mass = entry.runtime_data.mass
@@ -149,6 +151,11 @@ async def async_setup_entry(
assert event.object_id is not None
if event.object_id in added_ids:
return
+ player = mass.players.get(event.object_id)
+ if TYPE_CHECKING:
+ assert player is not None
+ if not player.expose_to_ha:
+ return
added_ids.add(event.object_id)
async_add_entities([MusicAssistantPlayer(mass, event.object_id)])
@@ -157,6 +164,8 @@ async def async_setup_entry(
mass_players = []
# add all current players
for player in mass.players:
+ if not player.expose_to_ha:
+ continue
added_ids.add(player.player_id)
mass_players.append(MusicAssistantPlayer(mass, player.player_id))
@@ -212,11 +221,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
"""Initialize MediaPlayer entity."""
super().__init__(mass, player_id)
self._attr_icon = self.player.icon.replace("mdi-", "mdi:")
- self._attr_supported_features = SUPPORTED_FEATURES
- if PlayerFeature.SET_MEMBERS in self.player.supported_features:
- self._attr_supported_features |= MediaPlayerEntityFeature.GROUPING
- if PlayerFeature.VOLUME_MUTE in self.player.supported_features:
- self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
+ self._set_supported_features()
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
self._prev_time: float = 0
@@ -241,6 +246,19 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
)
)
+ # we subscribe to the player config changed event to update
+ # the supported features of the player
+ async def player_config_changed(event: MassEvent) -> None:
+ self._set_supported_features()
+ await self.async_on_update()
+ self.async_write_ha_state()
+
+ self.async_on_remove(
+ self.mass.subscribe(
+ player_config_changed, EventType.PLAYER_CONFIG_UPDATED, self.player_id
+ )
+ )
+
@property
def active_queue(self) -> PlayerQueue | None:
"""Return the active queue for this player (if any)."""
@@ -271,22 +289,26 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
self._attr_state = MediaPlayerState(player.state.value)
else:
self._attr_state = MediaPlayerState(STATE_OFF)
- group_members_entity_ids: list[str] = []
+
+ group_members: list[str] = []
if player.group_childs:
- # translate MA group_childs to HA group_members as entity id's
- entity_registry = er.async_get(self.hass)
- group_members_entity_ids = [
- entity_id
- for child_id in player.group_childs
- if (
- entity_id := entity_registry.async_get_entity_id(
- self.platform.domain, DOMAIN, child_id
- )
+ group_members = player.group_childs
+ elif player.synced_to and (parent := self.mass.players.get(player.synced_to)):
+ group_members = parent.group_childs
+
+ # translate MA group_childs to HA group_members as entity id's
+ entity_registry = er.async_get(self.hass)
+ group_members_entity_ids: list[str] = [
+ entity_id
+ for child_id in group_members
+ if (
+ entity_id := entity_registry.async_get_entity_id(
+ self.platform.domain, DOMAIN, child_id
)
- ]
- # NOTE: we sort the group_members for now,
- # until the MA API returns them sorted (group_childs is now a set)
- self._attr_group_members = sorted(group_members_entity_ids)
+ )
+ ]
+
+ self._attr_group_members = group_members_entity_ids
self._attr_volume_level = (
player.volume_level / 100 if player.volume_level is not None else None
)
@@ -473,6 +495,8 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
album=album,
media_type=MediaType(media_type) if media_type else None,
):
+ if TYPE_CHECKING:
+ assert item.uri is not None
media_uris.append(item.uri)
if not media_uris:
@@ -575,17 +599,24 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
def _update_media_image_url(
self, player: Player, queue: PlayerQueue | None
) -> None:
- """Update image URL for the active queue item."""
- if queue is None or queue.current_item is None:
- self._attr_media_image_url = None
- return
- if image_url := self.mass.get_media_item_image_url(queue.current_item):
+ """Update image URL."""
+ if queue and queue.current_item:
+ # image_url is provided by an music-assistant queue
+ image_url = self.mass.get_media_item_image_url(queue.current_item)
+ elif player.current_media and player.current_media.image_url:
+ # image_url is provided by an external source
+ image_url = player.current_media.image_url
+ else:
+ image_url = None
+
+ # check if the image is provided via music-assistant and therefore
+ # not accessible from the outside
+ if image_url:
self._attr_media_image_remotely_accessible = (
self.mass.server_url not in image_url
)
- self._attr_media_image_url = image_url
- return
- self._attr_media_image_url = None
+
+ self._attr_media_image_url = image_url
def _update_media_attributes(
self, player: Player, queue: PlayerQueue | None
@@ -680,3 +711,18 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
if isinstance(queue_option, MediaPlayerEnqueue):
queue_option = QUEUE_OPTION_MAP.get(queue_option)
return queue_option
+
+ def _set_supported_features(self) -> None:
+ """Set supported features based on player capabilities."""
+ supported_features = SUPPORTED_FEATURES_BASE
+ if PlayerFeature.SET_MEMBERS in self.player.supported_features:
+ supported_features |= MediaPlayerEntityFeature.GROUPING
+ if self.player.mute_control != PLAYER_CONTROL_NONE:
+ supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
+ if self.player.volume_control != PLAYER_CONTROL_NONE:
+ supported_features |= MediaPlayerEntityFeature.VOLUME_STEP
+ supported_features |= MediaPlayerEntityFeature.VOLUME_SET
+ if self.player.power_control != PLAYER_CONTROL_NONE:
+ supported_features |= MediaPlayerEntityFeature.TURN_ON
+ supported_features |= MediaPlayerEntityFeature.TURN_OFF
+ self._attr_supported_features = supported_features
diff --git a/homeassistant/components/music_assistant/schemas.py b/homeassistant/components/music_assistant/schemas.py
index d8c4fe1649d..7501d3d2038 100644
--- a/homeassistant/components/music_assistant/schemas.py
+++ b/homeassistant/components/music_assistant/schemas.py
@@ -15,6 +15,7 @@ from .const import (
ATTR_ALBUM,
ATTR_ALBUMS,
ATTR_ARTISTS,
+ ATTR_AUDIOBOOKS,
ATTR_BIT_DEPTH,
ATTR_CONTENT_TYPE,
ATTR_CURRENT_INDEX,
@@ -31,6 +32,7 @@ from .const import (
ATTR_OFFSET,
ATTR_ORDER_BY,
ATTR_PLAYLISTS,
+ ATTR_PODCASTS,
ATTR_PROVIDER,
ATTR_QUEUE_ID,
ATTR_QUEUE_ITEM_ID,
@@ -65,20 +67,20 @@ MEDIA_ITEM_SCHEMA = vol.Schema(
def media_item_dict_from_mass_item(
mass: MusicAssistantClient,
- item: MediaItemType | ItemMapping | None,
-) -> dict[str, Any] | None:
+ item: MediaItemType | ItemMapping,
+) -> dict[str, Any]:
"""Parse a Music Assistant MediaItem."""
- if not item:
- return None
- base = {
+ base: dict[str, Any] = {
ATTR_MEDIA_TYPE: item.media_type,
ATTR_URI: item.uri,
ATTR_NAME: item.name,
ATTR_VERSION: item.version,
ATTR_IMAGE: mass.get_media_item_image_url(item),
}
+ artists: list[ItemMapping] | None
if artists := getattr(item, "artists", None):
base[ATTR_ARTISTS] = [media_item_dict_from_mass_item(mass, x) for x in artists]
+ album: ItemMapping | None
if album := getattr(item, "album", None):
base[ATTR_ALBUM] = media_item_dict_from_mass_item(mass, album)
return base
@@ -101,6 +103,12 @@ SEARCH_RESULT_SCHEMA = vol.Schema(
vol.Required(ATTR_RADIO): vol.All(
cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
),
+ vol.Required(ATTR_AUDIOBOOKS): vol.All(
+ cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
+ ),
+ vol.Required(ATTR_PODCASTS): vol.All(
+ cv.ensure_list, [vol.Schema(MEDIA_ITEM_SCHEMA)]
+ ),
},
)
@@ -151,7 +159,11 @@ def queue_item_dict_from_mass_item(
ATTR_QUEUE_ITEM_ID: item.queue_item_id,
ATTR_NAME: item.name,
ATTR_DURATION: item.duration,
- ATTR_MEDIA_ITEM: media_item_dict_from_mass_item(mass, item.media_item),
+ ATTR_MEDIA_ITEM: (
+ media_item_dict_from_mass_item(mass, item.media_item)
+ if item.media_item
+ else None
+ ),
}
if streamdetails := item.streamdetails:
base[ATTR_STREAM_TITLE] = streamdetails.stream_title
diff --git a/homeassistant/components/music_assistant/services.yaml b/homeassistant/components/music_assistant/services.yaml
index 73e8e2d7521..a3715ea2580 100644
--- a/homeassistant/components/music_assistant/services.yaml
+++ b/homeassistant/components/music_assistant/services.yaml
@@ -21,7 +21,10 @@ play_media:
options:
- artist
- album
+ - audiobook
+ - folder
- playlist
+ - podcast
- track
- radio
artist:
@@ -118,7 +121,9 @@ search:
options:
- artist
- album
+ - audiobook
- playlist
+ - podcast
- track
- radio
artist:
@@ -160,7 +165,9 @@ get_library:
options:
- artist
- album
+ - audiobook
- playlist
+ - podcast
- track
- radio
favorite:
diff --git a/homeassistant/components/music_assistant/strings.json b/homeassistant/components/music_assistant/strings.json
index 32b72088518..371ecdc3a86 100644
--- a/homeassistant/components/music_assistant/strings.json
+++ b/homeassistant/components/music_assistant/strings.json
@@ -139,8 +139,8 @@
}
},
"get_library": {
- "name": "Get Library items",
- "description": "Get items from a Music Assistant library.",
+ "name": "Get library items",
+ "description": "Retrieves items from a Music Assistant library.",
"fields": {
"config_entry_id": {
"name": "[%key:component::music_assistant::services::search::fields::config_entry_id::name%]",
@@ -167,7 +167,7 @@
"description": "Offset to start the list from."
},
"order_by": {
- "name": "Order By",
+ "name": "Order by",
"description": "Sort the list by this field."
},
"album_type": {
@@ -176,7 +176,7 @@
},
"album_artists_only": {
"name": "Enable album artists filter (only for artist library)",
- "description": "Only return Album Artists when listing the Artists library items."
+ "description": "Only return album artists when listing the artists library items."
}
}
}
@@ -195,8 +195,11 @@
"options": {
"artist": "Artist",
"album": "Album",
+ "audiobook": "Audiobook",
+ "folder": "Folder",
"track": "Track",
"playlist": "Playlist",
+ "podcast": "Podcast",
"radio": "Radio"
}
},
diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py
index 87bf246f4e0..7a9025762ef 100644
--- a/homeassistant/components/mutesync/binary_sensor.py
+++ b/homeassistant/components/mutesync/binary_sensor.py
@@ -5,7 +5,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import update_coordinator
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -18,7 +18,7 @@ SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the mütesync button."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/mutesync/config_flow.py b/homeassistant/components/mutesync/config_flow.py
index ef03df39968..a2aacfc927e 100644
--- a/homeassistant/components/mutesync/config_flow.py
+++ b/homeassistant/components/mutesync/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
+import logging
from typing import Any
import aiohttp
@@ -16,6 +17,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required("host"): str})
@@ -60,7 +63,8 @@ class MuteSyncConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py
index 41b36a34c20..47629006887 100644
--- a/homeassistant/components/myq/__init__.py
+++ b/homeassistant/components/myq/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- if all(
- config_entry.state is ConfigEntryState.NOT_LOADED
- for config_entry in hass.config_entries.async_entries(DOMAIN)
- if config_entry.entry_id != entry.entry_id
- ):
- ir.async_delete_issue(hass, DOMAIN, DOMAIN)
-
return True
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Remove a config entry."""
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
+ ir.async_delete_issue(hass, DOMAIN, DOMAIN)
+ # Remove any remaining disabled or ignored entries
+ for _entry in hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py
index 19dcce78446..e2aca8b9f01 100644
--- a/homeassistant/components/mysensors/__init__.py
+++ b/homeassistant/components/mysensors/__init__.py
@@ -17,7 +17,6 @@ from .const import (
DOMAIN,
MYSENSORS_DISCOVERED_NODES,
MYSENSORS_GATEWAYS,
- MYSENSORS_ON_UNLOAD,
PLATFORMS,
DevId,
DiscoveryInfo,
@@ -62,13 +61,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if not unload_ok:
return False
- key = MYSENSORS_ON_UNLOAD.format(entry.entry_id)
- if key in hass.data[DOMAIN]:
- for fnct in hass.data[DOMAIN][key]:
- fnct()
-
- hass.data[DOMAIN].pop(key)
-
del hass.data[DOMAIN][MYSENSORS_GATEWAYS][entry.entry_id]
hass.data[DOMAIN].pop(MYSENSORS_DISCOVERED_NODES.format(entry.entry_id), None)
diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py
index 54f7036b79c..e950f083b5b 100644
--- a/homeassistant/components/mysensors/binary_sensor.py
+++ b/homeassistant/components/mysensors/binary_sensor.py
@@ -15,12 +15,11 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import setup_mysensors_platform
from .const import MYSENSORS_DISCOVERY, DiscoveryInfo
from .entity import MySensorsChildEntity
-from .helpers import on_unload
@dataclass(frozen=True)
@@ -71,7 +70,7 @@ SENSORS: dict[str, MySensorsBinarySensorDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
@@ -86,9 +85,7 @@ async def async_setup_entry(
async_add_entities=async_add_entities,
)
- on_unload(
- hass,
- config_entry.entry_id,
+ config_entry.async_on_unload(
async_dispatcher_connect(
hass,
MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.BINARY_SENSOR),
diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py
index 23b7c47ebf3..eb54a76b8a8 100644
--- a/homeassistant/components/mysensors/climate.py
+++ b/homeassistant/components/mysensors/climate.py
@@ -15,13 +15,12 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_system import METRIC_SYSTEM
from . import setup_mysensors_platform
from .const import MYSENSORS_DISCOVERY, DiscoveryInfo
from .entity import MySensorsChildEntity
-from .helpers import on_unload
DICT_HA_TO_MYS = {
HVACMode.AUTO: "AutoChangeOver",
@@ -43,7 +42,7 @@ OPERATION_LIST = [HVACMode.OFF, HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
@@ -57,9 +56,7 @@ async def async_setup_entry(
async_add_entities=async_add_entities,
)
- on_unload(
- hass,
- config_entry.entry_id,
+ config_entry.async_on_unload(
async_dispatcher_connect(
hass,
MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.CLIMATE),
@@ -85,7 +82,10 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity):
and set_req.V_HVAC_SETPOINT_HEAT in self._values
):
features = features | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
- else:
+ elif (
+ set_req.V_HVAC_SETPOINT_COOL in self._values
+ or set_req.V_HVAC_SETPOINT_HEAT in self._values
+ ):
features = features | ClimateEntityFeature.TARGET_TEMPERATURE
return features
@@ -111,13 +111,11 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity):
@property
def target_temperature(self) -> float | None:
- """Return the temperature we try to reach."""
+ """Return the temperature we try to reach.
+
+ Either V_HVAC_SETPOINT_COOL or V_HVAC_SETPOINT_HEAT may be used.
+ """
set_req = self.gateway.const.SetReq
- if (
- set_req.V_HVAC_SETPOINT_COOL in self._values
- and set_req.V_HVAC_SETPOINT_HEAT in self._values
- ):
- return None
temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL)
if temp is None:
temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT)
@@ -127,21 +125,13 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity):
def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach."""
set_req = self.gateway.const.SetReq
- if set_req.V_HVAC_SETPOINT_HEAT in self._values:
- temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL)
- return float(temp) if temp is not None else None
-
- return None
+ return float(self._values[set_req.V_HVAC_SETPOINT_COOL])
@property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach."""
set_req = self.gateway.const.SetReq
- if set_req.V_HVAC_SETPOINT_COOL in self._values:
- temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT)
- return float(temp) if temp is not None else None
-
- return None
+ return float(self._values[set_req.V_HVAC_SETPOINT_HEAT])
@property
def hvac_mode(self) -> HVACMode:
@@ -185,10 +175,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity):
self.gateway.set_child_value(
self.node_id, self.child_id, value_type, value, ack=1
)
- if self.assumed_state:
- # Optimistically assume that device has changed state
- self._values[value_type] = value
- self.async_write_ha_state()
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target temperature."""
@@ -196,10 +182,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity):
self.gateway.set_child_value(
self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode, ack=1
)
- if self.assumed_state:
- # Optimistically assume that device has changed state
- self._values[set_req.V_HVAC_SPEED] = fan_mode
- self.async_write_ha_state()
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target temperature."""
@@ -210,10 +192,6 @@ class MySensorsHVAC(MySensorsChildEntity, ClimateEntity):
DICT_HA_TO_MYS[hvac_mode],
ack=1,
)
- if self.assumed_state:
- # Optimistically assume that device has changed state
- self._values[self.value_type] = hvac_mode
- self.async_write_ha_state()
@callback
def _async_update(self) -> None:
diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py
index a65b46616d3..a87b78b549e 100644
--- a/homeassistant/components/mysensors/const.py
+++ b/homeassistant/components/mysensors/const.py
@@ -34,7 +34,6 @@ CHILD_CALLBACK: str = "mysensors_child_callback_{}_{}_{}_{}"
NODE_CALLBACK: str = "mysensors_node_callback_{}_{}"
MYSENSORS_DISCOVERY: str = "mysensors_discovery_{}_{}"
MYSENSORS_NODE_DISCOVERY: str = "mysensors_node_discovery"
-MYSENSORS_ON_UNLOAD: str = "mysensors_on_unload_{}"
TYPE: Final = "type"
UPDATE_DELAY: float = 0.1
diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py
index 808589b9022..84346a5d10a 100644
--- a/homeassistant/components/mysensors/cover.py
+++ b/homeassistant/components/mysensors/cover.py
@@ -7,15 +7,14 @@ from typing import Any
from homeassistant.components.cover import ATTR_POSITION, CoverEntity
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import STATE_OFF, STATE_ON, Platform
+from homeassistant.const import STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import setup_mysensors_platform
from .const import MYSENSORS_DISCOVERY, DiscoveryInfo
from .entity import MySensorsChildEntity
-from .helpers import on_unload
@unique
@@ -31,7 +30,7 @@ class CoverState(Enum):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
@@ -45,9 +44,7 @@ async def async_setup_entry(
async_add_entities=async_add_entities,
)
- on_unload(
- hass,
- config_entry.entry_id,
+ config_entry.async_on_unload(
async_dispatcher_connect(
hass,
MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.COVER),
@@ -113,13 +110,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity):
self.gateway.set_child_value(
self.node_id, self.child_id, set_req.V_UP, 1, ack=1
)
- if self.assumed_state:
- # Optimistically assume that cover has changed state.
- if set_req.V_DIMMER in self._values:
- self._values[set_req.V_DIMMER] = 100
- else:
- self._values[set_req.V_LIGHT] = STATE_ON
- self.async_write_ha_state()
async def async_close_cover(self, **kwargs: Any) -> None:
"""Move the cover down."""
@@ -127,13 +117,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity):
self.gateway.set_child_value(
self.node_id, self.child_id, set_req.V_DOWN, 1, ack=1
)
- if self.assumed_state:
- # Optimistically assume that cover has changed state.
- if set_req.V_DIMMER in self._values:
- self._values[set_req.V_DIMMER] = 0
- else:
- self._values[set_req.V_LIGHT] = STATE_OFF
- self.async_write_ha_state()
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
@@ -142,10 +125,6 @@ class MySensorsCover(MySensorsChildEntity, CoverEntity):
self.gateway.set_child_value(
self.node_id, self.child_id, set_req.V_DIMMER, position, ack=1
)
- if self.assumed_state:
- # Optimistically assume that cover has changed state.
- self._values[set_req.V_DIMMER] = position
- self.async_write_ha_state()
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Stop the device."""
diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py
index 5abe6a64e2d..e6368b0b81d 100644
--- a/homeassistant/components/mysensors/device_tracker.py
+++ b/homeassistant/components/mysensors/device_tracker.py
@@ -7,18 +7,17 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import setup_mysensors_platform
from .const import MYSENSORS_DISCOVERY, DiscoveryInfo
from .entity import MySensorsChildEntity
-from .helpers import on_unload
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
@@ -33,9 +32,7 @@ async def async_setup_entry(
async_add_entities=async_add_entities,
)
- on_unload(
- hass,
- config_entry.entry_id,
+ config_entry.async_on_unload(
async_dispatcher_connect(
hass,
MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.DEVICE_TRACKER),
diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py
index bdc83f30b21..91453ea3306 100644
--- a/homeassistant/components/mysensors/gateway.py
+++ b/homeassistant/components/mysensors/gateway.py
@@ -47,7 +47,6 @@ from .handler import HANDLERS
from .helpers import (
discover_mysensors_node,
discover_mysensors_platform,
- on_unload,
validate_child,
validate_node,
)
@@ -293,9 +292,7 @@ async def _gw_start(
"""Stop the gateway."""
await gw_stop(hass, entry, gateway)
- on_unload(
- hass,
- entry.entry_id,
+ entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_this_gw),
)
diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py
index c96ad6cea8e..9ed41dfe4e9 100644
--- a/homeassistant/components/mysensors/helpers.py
+++ b/homeassistant/components/mysensors/helpers.py
@@ -27,7 +27,6 @@ from .const import (
MYSENSORS_DISCOVERED_NODES,
MYSENSORS_DISCOVERY,
MYSENSORS_NODE_DISCOVERY,
- MYSENSORS_ON_UNLOAD,
TYPE_TO_PLATFORMS,
DevId,
GatewayId,
@@ -41,18 +40,6 @@ SCHEMAS: Registry[
] = Registry()
-@callback
-def on_unload(hass: HomeAssistant, gateway_id: GatewayId, fnct: Callable) -> None:
- """Register a callback to be called when entry is unloaded.
-
- This function is used by platforms to cleanup after themselves.
- """
- key = MYSENSORS_ON_UNLOAD.format(gateway_id)
- if key not in hass.data[DOMAIN]:
- hass.data[DOMAIN][key] = []
- hass.data[DOMAIN][key].append(fnct)
-
-
@callback
def discover_mysensors_platform(
hass: HomeAssistant, gateway_id: GatewayId, platform: str, new_devices: list[DevId]
diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py
index 87f60174cab..fa5e625c72b 100644
--- a/homeassistant/components/mysensors/light.py
+++ b/homeassistant/components/mysensors/light.py
@@ -12,22 +12,21 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import STATE_OFF, STATE_ON, Platform
+from homeassistant.const import STATE_ON, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import rgb_hex_to_rgb_list
from . import setup_mysensors_platform
from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType
from .entity import MySensorsChildEntity
-from .helpers import on_unload
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
device_class_map: dict[SensorType, type[MySensorsChildEntity]] = {
@@ -46,9 +45,7 @@ async def async_setup_entry(
async_add_entities=async_add_entities,
)
- on_unload(
- hass,
- config_entry.entry_id,
+ config_entry.async_on_unload(
async_dispatcher_connect(
hass,
MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.LIGHT),
@@ -80,11 +77,6 @@ class MySensorsLight(MySensorsChildEntity, LightEntity):
self.node_id, self.child_id, set_req.V_LIGHT, 1, ack=1
)
- if self.assumed_state:
- # optimistically assume that light has changed state
- self._state = True
- self._values[set_req.V_LIGHT] = STATE_ON
-
def _turn_on_dimmer(self, **kwargs: Any) -> None:
"""Turn on dimmer child device."""
set_req = self.gateway.const.SetReq
@@ -101,20 +93,10 @@ class MySensorsLight(MySensorsChildEntity, LightEntity):
self.node_id, self.child_id, set_req.V_DIMMER, percent, ack=1
)
- if self.assumed_state:
- # optimistically assume that light has changed state
- self._attr_brightness = brightness
- self._values[set_req.V_DIMMER] = percent
-
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
value_type = self.gateway.const.SetReq.V_LIGHT
self.gateway.set_child_value(self.node_id, self.child_id, value_type, 0, ack=1)
- if self.assumed_state:
- # optimistically assume that light has changed state
- self._state = False
- self._values[value_type] = STATE_OFF
- self.async_write_ha_state()
@callback
def _async_update_light(self) -> None:
@@ -142,8 +124,6 @@ class MySensorsLightDimmer(MySensorsLight):
"""Turn the device on."""
self._turn_on_light()
self._turn_on_dimmer(**kwargs)
- if self.assumed_state:
- self.async_write_ha_state()
@callback
def _async_update(self) -> None:
@@ -164,8 +144,6 @@ class MySensorsLightRGB(MySensorsLight):
self._turn_on_light()
self._turn_on_dimmer(**kwargs)
self._turn_on_rgb(**kwargs)
- if self.assumed_state:
- self.async_write_ha_state()
def _turn_on_rgb(self, **kwargs: Any) -> None:
"""Turn on RGB child device."""
@@ -179,11 +157,6 @@ class MySensorsLightRGB(MySensorsLight):
self.node_id, self.child_id, self.value_type, hex_color, ack=1
)
- if self.assumed_state:
- # optimistically assume that light has changed state
- self._attr_rgb_color = new_rgb
- self._values[self.value_type] = hex_color
-
@callback
def _async_update(self) -> None:
"""Update the controller with the latest value from a sensor."""
@@ -212,8 +185,6 @@ class MySensorsLightRGBW(MySensorsLightRGB):
self._turn_on_light()
self._turn_on_dimmer(**kwargs)
self._turn_on_rgbw(**kwargs)
- if self.assumed_state:
- self.async_write_ha_state()
def _turn_on_rgbw(self, **kwargs: Any) -> None:
"""Turn on RGBW child device."""
@@ -227,11 +198,6 @@ class MySensorsLightRGBW(MySensorsLightRGB):
self.node_id, self.child_id, self.value_type, hex_color, ack=1
)
- if self.assumed_state:
- # optimistically assume that light has changed state
- self._attr_rgbw_color = new_rgbw
- self._values[self.value_type] = hex_color
-
@callback
def _async_update_rgb_or_w(self) -> None:
"""Update the controller with values from RGBW child."""
diff --git a/homeassistant/components/mysensors/remote.py b/homeassistant/components/mysensors/remote.py
index 1a4f6fdaa90..ccb67f78eba 100644
--- a/homeassistant/components/mysensors/remote.py
+++ b/homeassistant/components/mysensors/remote.py
@@ -14,18 +14,17 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import setup_mysensors_platform
from .const import MYSENSORS_DISCOVERY, DiscoveryInfo
from .entity import MySensorsChildEntity
-from .helpers import on_unload
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
@@ -40,9 +39,7 @@ async def async_setup_entry(
async_add_entities=async_add_entities,
)
- on_unload(
- hass,
- config_entry.entry_id,
+ config_entry.async_on_unload(
async_dispatcher_connect(
hass,
MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.REMOTE),
diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py
index eec3c6bcd79..3793bed8af2 100644
--- a/homeassistant/components/mysensors/sensor.py
+++ b/homeassistant/components/mysensors/sensor.py
@@ -35,7 +35,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_system import METRIC_SYSTEM
from . import setup_mysensors_platform
@@ -50,7 +50,6 @@ from .const import (
NodeDiscoveryInfo,
)
from .entity import MySensorNodeEntity, MySensorsChildEntity
-from .helpers import on_unload
SENSORS: dict[str, SensorEntityDescription] = {
"V_TEMP": SensorEntityDescription(
@@ -102,6 +101,8 @@ SENSORS: dict[str, SensorEntityDescription] = {
key="V_DIRECTION",
native_unit_of_measurement=DEGREE,
icon="mdi:compass",
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
"V_WEIGHT": SensorEntityDescription(
key="V_WEIGHT",
@@ -210,7 +211,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
@@ -232,9 +233,7 @@ async def async_setup_entry(
gateway: BaseAsyncGateway = hass.data[DOMAIN][MYSENSORS_GATEWAYS][gateway_id]
async_add_entities([MyBatterySensor(gateway_id, gateway, node_id)])
- on_unload(
- hass,
- config_entry.entry_id,
+ config_entry.async_on_unload(
async_dispatcher_connect(
hass,
MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.SENSOR),
@@ -242,9 +241,7 @@ async def async_setup_entry(
),
)
- on_unload(
- hass,
- config_entry.entry_id,
+ config_entry.async_on_unload(
async_dispatcher_connect(
hass,
MYSENSORS_NODE_DISCOVERY,
diff --git a/homeassistant/components/mysensors/strings.json b/homeassistant/components/mysensors/strings.json
index 30fe5f46d6b..1636cb076cc 100644
--- a/homeassistant/components/mysensors/strings.json
+++ b/homeassistant/components/mysensors/strings.json
@@ -21,16 +21,16 @@
"device": "IP address of the gateway",
"tcp_port": "[%key:common::config_flow::data::port%]",
"version": "MySensors version",
- "persistence_file": "persistence file (leave empty to auto-generate)"
+ "persistence_file": "Persistence file (leave empty to auto-generate)"
}
},
"gw_serial": {
"description": "Serial gateway setup",
"data": {
"device": "Serial port",
- "baud_rate": "baud rate",
+ "baud_rate": "Baud rate",
"version": "[%key:component::mysensors::config::step::gw_tcp::data::version%]",
- "persistence_file": "Persistence file (leave empty to auto-generate)"
+ "persistence_file": "[%key:component::mysensors::config::step::gw_tcp::data::persistence_file%]"
}
},
"gw_mqtt": {
@@ -40,7 +40,7 @@
"topic_in_prefix": "Prefix for input topics (topic_in_prefix)",
"topic_out_prefix": "Prefix for output topics (topic_out_prefix)",
"version": "[%key:component::mysensors::config::step::gw_tcp::data::version%]",
- "persistence_file": "[%key:component::mysensors::config::step::gw_serial::data::persistence_file%]"
+ "persistence_file": "[%key:component::mysensors::config::step::gw_tcp::data::persistence_file%]"
}
}
},
diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py
index 4eabf6374f1..9b57102a94c 100644
--- a/homeassistant/components/mysensors/switch.py
+++ b/homeassistant/components/mysensors/switch.py
@@ -6,21 +6,20 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import STATE_OFF, STATE_ON, Platform
+from homeassistant.const import STATE_ON, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import setup_mysensors_platform
from .const import MYSENSORS_DISCOVERY, DiscoveryInfo, SensorType
from .entity import MySensorsChildEntity
-from .helpers import on_unload
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
device_class_map: dict[SensorType, type[MySensorsSwitch]] = {
@@ -48,9 +47,7 @@ async def async_setup_entry(
async_add_entities=async_add_entities,
)
- on_unload(
- hass,
- config_entry.entry_id,
+ config_entry.async_on_unload(
async_dispatcher_connect(
hass,
MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.SWITCH),
@@ -72,17 +69,9 @@ class MySensorsSwitch(MySensorsChildEntity, SwitchEntity):
self.gateway.set_child_value(
self.node_id, self.child_id, self.value_type, 1, ack=1
)
- if self.assumed_state:
- # Optimistically assume that switch has changed state
- self._values[self.value_type] = STATE_ON
- self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
self.gateway.set_child_value(
self.node_id, self.child_id, self.value_type, 0, ack=1
)
- if self.assumed_state:
- # Optimistically assume that switch has changed state
- self._values[self.value_type] = STATE_OFF
- self.async_write_ha_state()
diff --git a/homeassistant/components/mysensors/text.py b/homeassistant/components/mysensors/text.py
index 4edb5ccdbd8..9fdd9da5345 100644
--- a/homeassistant/components/mysensors/text.py
+++ b/homeassistant/components/mysensors/text.py
@@ -7,18 +7,17 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import setup_mysensors_platform
from .const import MYSENSORS_DISCOVERY, DiscoveryInfo
from .entity import MySensorsChildEntity
-from .helpers import on_unload
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up this platform for a specific ConfigEntry(==Gateway)."""
@@ -33,9 +32,7 @@ async def async_setup_entry(
async_add_entities=async_add_entities,
)
- on_unload(
- hass,
- config_entry.entry_id,
+ config_entry.async_on_unload(
async_dispatcher_connect(
hass,
MYSENSORS_DISCOVERY.format(config_entry.entry_id, Platform.TEXT),
diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py
index 5dabb609437..3942f601a20 100644
--- a/homeassistant/components/mystrom/light.py
+++ b/homeassistant/components/mystrom/light.py
@@ -18,7 +18,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
@@ -31,7 +31,9 @@ EFFECT_SUNRISE = "sunrise"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the myStrom entities."""
info = hass.data[DOMAIN][entry.entry_id].info
diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py
index 2c35d35dad6..bd5c9b923a2 100644
--- a/homeassistant/components/mystrom/sensor.py
+++ b/homeassistant/components/mystrom/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfPower, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
@@ -55,7 +55,9 @@ SENSOR_TYPES: tuple[MyStromSwitchSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the myStrom entities."""
device: MyStromSwitch = hass.data[DOMAIN][entry.entry_id].device
diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py
index af135027aac..f626656a4e3 100644
--- a/homeassistant/components/mystrom/switch.py
+++ b/homeassistant/components/mystrom/switch.py
@@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
@@ -21,7 +21,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the myStrom entities."""
device = hass.data[DOMAIN][entry.entry_id].device
diff --git a/homeassistant/components/myuplink/binary_sensor.py b/homeassistant/components/myuplink/binary_sensor.py
index c24bf142b43..785a7ff4532 100644
--- a/homeassistant/components/myuplink/binary_sensor.py
+++ b/homeassistant/components/myuplink/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import F_SERIES
from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator
@@ -58,7 +58,7 @@ def get_description(device_point: DevicePoint) -> BinarySensorEntityDescription
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MyUplinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up myUplink binary_sensor."""
entities: list[BinarySensorEntity] = []
diff --git a/homeassistant/components/myuplink/number.py b/homeassistant/components/myuplink/number.py
index 126dc49163d..33100850837 100644
--- a/homeassistant/components/myuplink/number.py
+++ b/homeassistant/components/myuplink/number.py
@@ -7,7 +7,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, F_SERIES
from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator
@@ -63,7 +63,7 @@ def get_description(device_point: DevicePoint) -> NumberEntityDescription | None
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MyUplinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up myUplink number."""
entities: list[NumberEntity] = []
diff --git a/homeassistant/components/myuplink/select.py b/homeassistant/components/myuplink/select.py
index cad84d18646..36f9be63669 100644
--- a/homeassistant/components/myuplink/select.py
+++ b/homeassistant/components/myuplink/select.py
@@ -9,7 +9,7 @@ from homeassistant.components.select import SelectEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator
@@ -20,7 +20,7 @@ from .helpers import find_matching_platform, skip_entity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MyUplinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up myUplink select."""
entities: list[SelectEntity] = []
diff --git a/homeassistant/components/myuplink/sensor.py b/homeassistant/components/myuplink/sensor.py
index 03734210e9c..3b14cdd4630 100644
--- a/homeassistant/components/myuplink/sensor.py
+++ b/homeassistant/components/myuplink/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import F_SERIES
@@ -214,7 +214,7 @@ def get_description(device_point: DevicePoint) -> SensorEntityDescription | None
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MyUplinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up myUplink sensor."""
diff --git a/homeassistant/components/myuplink/switch.py b/homeassistant/components/myuplink/switch.py
index e175db93278..2d3706f2bdb 100644
--- a/homeassistant/components/myuplink/switch.py
+++ b/homeassistant/components/myuplink/switch.py
@@ -9,7 +9,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, F_SERIES
from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator
@@ -55,7 +55,7 @@ def get_description(device_point: DevicePoint) -> SwitchEntityDescription | None
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MyUplinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up myUplink switch."""
entities: list[SwitchEntity] = []
diff --git a/homeassistant/components/myuplink/update.py b/homeassistant/components/myuplink/update.py
index 8f4975fe1a5..ee259f5cbe8 100644
--- a/homeassistant/components/myuplink/update.py
+++ b/homeassistant/components/myuplink/update.py
@@ -6,7 +6,7 @@ from homeassistant.components.update import (
UpdateEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MyUplinkConfigEntry, MyUplinkDataCoordinator
from .entity import MyUplinkEntity
@@ -20,7 +20,7 @@ UPDATE_DESCRIPTION = UpdateEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: MyUplinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up update entity."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py
index 6b4ca6ff324..d297443c059 100644
--- a/homeassistant/components/nam/__init__.py
+++ b/homeassistant/components/nam/__init__.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
-from aiohttp.client_exceptions import ClientConnectorError, ClientError
+from aiohttp.client_exceptions import ClientError
from nettigo_air_monitor import (
ApiError,
AuthFailedError,
@@ -38,15 +38,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool:
options = ConnectionOptions(host=host, username=username, password=password)
try:
nam = await NettigoAirMonitor.create(websession, options)
- except (ApiError, ClientError, ClientConnectorError, TimeoutError) as err:
- raise ConfigEntryNotReady from err
+ except (ApiError, ClientError) as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="device_communication_error",
+ translation_placeholders={"device": entry.title},
+ ) from err
try:
await nam.async_check_credentials()
- except ApiError as err:
- raise ConfigEntryNotReady from err
+ except (ApiError, ClientError) as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="device_communication_error",
+ translation_placeholders={"device": entry.title},
+ ) from err
except AuthFailedError as err:
- raise ConfigEntryAuthFailed from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_error",
+ translation_placeholders={"device": entry.title},
+ ) from err
coordinator = NAMDataUpdateCoordinator(hass, entry, nam)
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/nam/button.py b/homeassistant/components/nam/button.py
index 980201be28c..791a5fdc27c 100644
--- a/homeassistant/components/nam/button.py
+++ b/homeassistant/components/nam/button.py
@@ -4,6 +4,9 @@ from __future__ import annotations
import logging
+from aiohttp.client_exceptions import ClientError
+from nettigo_air_monitor import ApiError, AuthFailedError
+
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
@@ -11,9 +14,11 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from .const import DOMAIN
from .coordinator import NAMConfigEntry, NAMDataUpdateCoordinator
PARALLEL_UPDATES = 1
@@ -28,7 +33,9 @@ RESTART_BUTTON: ButtonEntityDescription = ButtonEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: NAMConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: NAMConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a Nettigo Air Monitor entities from a config_entry."""
coordinator = entry.runtime_data
@@ -57,4 +64,16 @@ class NAMButton(CoordinatorEntity[NAMDataUpdateCoordinator], ButtonEntity):
async def async_press(self) -> None:
"""Triggers the restart."""
- await self.coordinator.nam.async_restart()
+ try:
+ await self.coordinator.nam.async_restart()
+ except (ApiError, ClientError) as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="device_communication_action_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "device": self.coordinator.config_entry.title,
+ },
+ ) from err
+ except AuthFailedError:
+ self.coordinator.config_entry.async_start_reauth(self.hass)
diff --git a/homeassistant/components/nam/const.py b/homeassistant/components/nam/const.py
index 4b7b50b309a..2dedcf3c68a 100644
--- a/homeassistant/components/nam/const.py
+++ b/homeassistant/components/nam/const.py
@@ -11,6 +11,7 @@ SUFFIX_P1: Final = "_p1"
SUFFIX_P2: Final = "_p2"
SUFFIX_P4: Final = "_p4"
+ATTR_BH1750_ILLUMINANCE: Final = "bh1750_illuminance"
ATTR_BME280_HUMIDITY: Final = "bme280_humidity"
ATTR_BME280_PRESSURE: Final = "bme280_pressure"
ATTR_BME280_TEMPERATURE: Final = "bme280_temperature"
diff --git a/homeassistant/components/nam/coordinator.py b/homeassistant/components/nam/coordinator.py
index 3e2c9c24474..8a898dee378 100644
--- a/homeassistant/components/nam/coordinator.py
+++ b/homeassistant/components/nam/coordinator.py
@@ -64,6 +64,10 @@ class NAMDataUpdateCoordinator(DataUpdateCoordinator[NAMSensors]):
# We do not need to catch AuthFailed exception here because sensor data is
# always available without authorization.
except (ApiError, InvalidSensorDataError, RetryError) as error:
- raise UpdateFailed(error) from error
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ translation_placeholders={"device": self.config_entry.title},
+ ) from error
return data
diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json
index c3a559de50b..1c3b9db7a86 100644
--- a/homeassistant/components/nam/manifest.json
+++ b/homeassistant/components/nam/manifest.json
@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["nettigo_air_monitor"],
- "requirements": ["nettigo-air-monitor==4.0.0"],
+ "requirements": ["nettigo-air-monitor==4.1.0"],
"zeroconf": [
{
"type": "_http._tcp.local.",
diff --git a/homeassistant/components/nam/sensor.py b/homeassistant/components/nam/sensor.py
index 24080d1c3c1..45cfd313e8f 100644
--- a/homeassistant/components/nam/sensor.py
+++ b/homeassistant/components/nam/sensor.py
@@ -19,6 +19,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
+ LIGHT_LUX,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
@@ -27,12 +28,13 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utcnow
from .const import (
+ ATTR_BH1750_ILLUMINANCE,
ATTR_BME280_HUMIDITY,
ATTR_BME280_PRESSURE,
ATTR_BME280_TEMPERATURE,
@@ -83,6 +85,15 @@ class NAMSensorEntityDescription(SensorEntityDescription):
SENSORS: tuple[NAMSensorEntityDescription, ...] = (
+ NAMSensorEntityDescription(
+ key=ATTR_BH1750_ILLUMINANCE,
+ translation_key="bh1750_illuminance",
+ suggested_display_precision=0,
+ native_unit_of_measurement=LIGHT_LUX,
+ device_class=SensorDeviceClass.ILLUMINANCE,
+ state_class=SensorStateClass.MEASUREMENT,
+ value=lambda sensors: sensors.bh1750_illuminance,
+ ),
NAMSensorEntityDescription(
key=ATTR_BME280_HUMIDITY,
translation_key="bme280_humidity",
@@ -356,7 +367,9 @@ SENSORS: tuple[NAMSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: NAMConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: NAMConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a Nettigo Air Monitor entities from a config_entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/nam/strings.json b/homeassistant/components/nam/strings.json
index 2caa4d8bd97..b02eecaa41e 100644
--- a/homeassistant/components/nam/strings.json
+++ b/homeassistant/components/nam/strings.json
@@ -54,6 +54,9 @@
},
"entity": {
"sensor": {
+ "bh1750_illuminance": {
+ "name": "BH1750 illuminance"
+ },
"bme280_humidity": {
"name": "BME280 humidity"
},
@@ -93,11 +96,22 @@
"pmsx003_caqi_level": {
"name": "PMSx003 common air quality index level",
"state": {
- "very_low": "Very low",
- "low": "Low",
- "medium": "Medium",
- "high": "High",
- "very_high": "Very high"
+ "very_low": "[%key:common::state::very_low%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
+ "very_high": "[%key:common::state::very_high%]"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "very_low": "[%key:common::state::very_low%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
+ "very_high": "[%key:common::state::very_high%]"
+ }
+ }
}
},
"pmsx003_pm1": {
@@ -115,11 +129,22 @@
"sds011_caqi_level": {
"name": "SDS011 common air quality index level",
"state": {
- "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]",
- "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]",
- "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]",
- "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]",
- "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]"
+ "very_low": "[%key:common::state::very_low%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
+ "very_high": "[%key:common::state::very_high%]"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "very_low": "[%key:common::state::very_low%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
+ "very_high": "[%key:common::state::very_high%]"
+ }
+ }
}
},
"sds011_pm10": {
@@ -140,11 +165,22 @@
"sps30_caqi_level": {
"name": "SPS30 common air quality index level",
"state": {
- "very_low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_low%]",
- "low": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::low%]",
- "medium": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::medium%]",
- "high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::high%]",
- "very_high": "[%key:component::nam::entity::sensor::pmsx003_caqi_level::state::very_high%]"
+ "very_low": "[%key:common::state::very_low%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
+ "very_high": "[%key:common::state::very_high%]"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "very_low": "[%key:common::state::very_low%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
+ "very_high": "[%key:common::state::very_high%]"
+ }
+ }
}
},
"sps30_pm1": {
@@ -169,5 +205,19 @@
"name": "Last restart"
}
}
+ },
+ "exceptions": {
+ "auth_error": {
+ "message": "Authentication failed for {device}, please update your credentials"
+ },
+ "device_communication_error": {
+ "message": "An error occurred while communicating with {device}"
+ },
+ "device_communication_action_error": {
+ "message": "An error occurred while calling action for {entity} for {device}"
+ },
+ "update_error": {
+ "message": "An error occurred while retrieving data from {device}"
+ }
}
}
diff --git a/homeassistant/components/nanoleaf/button.py b/homeassistant/components/nanoleaf/button.py
index eb997036b48..813d81ab571 100644
--- a/homeassistant/components/nanoleaf/button.py
+++ b/homeassistant/components/nanoleaf/button.py
@@ -3,7 +3,7 @@
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NanoleafConfigEntry, NanoleafCoordinator
from .entity import NanoleafEntity
@@ -12,7 +12,7 @@ from .entity import NanoleafEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: NanoleafConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Nanoleaf button."""
async_add_entities([NanoleafIdentifyButton(entry.runtime_data)])
diff --git a/homeassistant/components/nanoleaf/event.py b/homeassistant/components/nanoleaf/event.py
index e77ee03681a..78ff889bdc5 100644
--- a/homeassistant/components/nanoleaf/event.py
+++ b/homeassistant/components/nanoleaf/event.py
@@ -3,7 +3,7 @@
from homeassistant.components.event import EventEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import TOUCH_MODELS
from .coordinator import NanoleafConfigEntry, NanoleafCoordinator
@@ -13,7 +13,7 @@ from .entity import NanoleafEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: NanoleafConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Nanoleaf event."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py
index 4d73a012765..6d42110d53e 100644
--- a/homeassistant/components/nanoleaf/light.py
+++ b/homeassistant/components/nanoleaf/light.py
@@ -15,7 +15,7 @@ from homeassistant.components.light import (
LightEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NanoleafConfigEntry, NanoleafCoordinator
from .entity import NanoleafEntity
@@ -27,7 +27,7 @@ DEFAULT_NAME = "Nanoleaf"
async def async_setup_entry(
hass: HomeAssistant,
entry: NanoleafConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Nanoleaf light."""
async_add_entities([NanoleafLight(entry.runtime_data)])
diff --git a/homeassistant/components/nasweb/config_flow.py b/homeassistant/components/nasweb/config_flow.py
index 3a9ad3f7d49..298210903dc 100644
--- a/homeassistant/components/nasweb/config_flow.py
+++ b/homeassistant/components/nasweb/config_flow.py
@@ -103,7 +103,7 @@ class NASwebConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "missing_status"
except AbortFlow:
raise
- except Exception: # pylint: disable=broad-except
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
diff --git a/homeassistant/components/nasweb/switch.py b/homeassistant/components/nasweb/switch.py
index c5a9e085b83..740db1ed1a1 100644
--- a/homeassistant/components/nasweb/switch.py
+++ b/homeassistant/components/nasweb/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import DOMAIN as DOMAIN_SWITCH, SwitchEntit
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.helpers.update_coordinator import (
BaseCoordinatorEntity,
@@ -38,7 +38,7 @@ def _get_output(coordinator: NASwebCoordinator, index: int) -> NASwebOutput | No
async def async_setup_entry(
hass: HomeAssistant,
config: NASwebConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up switch platform."""
diff --git a/homeassistant/components/neato/button.py b/homeassistant/components/neato/button.py
index 29114ce5188..8658dfd1b1b 100644
--- a/homeassistant/components/neato/button.py
+++ b/homeassistant/components/neato/button.py
@@ -8,14 +8,16 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_ROBOTS
from .entity import NeatoEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato button from config entry."""
entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]]
diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py
index e4d5f81f33a..42278a3a48f 100644
--- a/homeassistant/components/neato/camera.py
+++ b/homeassistant/components/neato/camera.py
@@ -13,7 +13,7 @@ from urllib3.response import HTTPResponse
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
@@ -26,7 +26,9 @@ ATTR_GENERATED_AT = "generated_at"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato camera with config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json
index e4b471cb5ac..ef7cda52f19 100644
--- a/homeassistant/components/neato/manifest.json
+++ b/homeassistant/components/neato/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/neato",
"iot_class": "cloud_polling",
"loggers": ["pybotvac"],
- "requirements": ["pybotvac==0.0.25"]
+ "requirements": ["pybotvac==0.0.26"]
}
diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py
index c247cc48493..4be02fe1ef7 100644
--- a/homeassistant/components/neato/sensor.py
+++ b/homeassistant/components/neato/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
@@ -27,7 +27,9 @@ BATTERY = "Battery"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Neato sensor using config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py
index 25da1c41df1..1ae06fef44c 100644
--- a/homeassistant/components/neato/switch.py
+++ b/homeassistant/components/neato/switch.py
@@ -13,7 +13,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
from .entity import NeatoEntity
@@ -29,7 +29,9 @@ SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]}
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato switch with config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py
index 1a9285964a2..a1e1382eb04 100644
--- a/homeassistant/components/neato/vacuum.py
+++ b/homeassistant/components/neato/vacuum.py
@@ -21,7 +21,7 @@ from homeassistant.const import ATTR_MODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ACTION,
@@ -58,7 +58,9 @@ ATTR_ZONE = "zone"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Neato vacuum with config entry."""
neato: NeatoHub = hass.data[NEATO_LOGIN]
diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py
index ff3eea9252c..1e7fc54f4f7 100644
--- a/homeassistant/components/nederlandse_spoorwegen/sensor.py
+++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import Throttle
+from homeassistant.util import Throttle, dt as dt_util
_LOGGER = logging.getLogger(__name__)
@@ -119,6 +119,8 @@ class NSDepartureSensor(SensorEntity):
self._time = time
self._state = None
self._trips = None
+ self._first_trip = None
+ self._next_trip = None
@property
def name(self):
@@ -133,44 +135,44 @@ class NSDepartureSensor(SensorEntity):
@property
def extra_state_attributes(self):
"""Return the state attributes."""
- if not self._trips:
+ if not self._trips or self._first_trip is None:
return None
- if self._trips[0].trip_parts:
- route = [self._trips[0].departure]
- route.extend(k.destination for k in self._trips[0].trip_parts)
+ if self._first_trip.trip_parts:
+ route = [self._first_trip.departure]
+ route.extend(k.destination for k in self._first_trip.trip_parts)
# Static attributes
attributes = {
- "going": self._trips[0].going,
+ "going": self._first_trip.going,
"departure_time_planned": None,
"departure_time_actual": None,
"departure_delay": False,
- "departure_platform_planned": self._trips[0].departure_platform_planned,
- "departure_platform_actual": self._trips[0].departure_platform_actual,
+ "departure_platform_planned": self._first_trip.departure_platform_planned,
+ "departure_platform_actual": self._first_trip.departure_platform_actual,
"arrival_time_planned": None,
"arrival_time_actual": None,
"arrival_delay": False,
- "arrival_platform_planned": self._trips[0].arrival_platform_planned,
- "arrival_platform_actual": self._trips[0].arrival_platform_actual,
+ "arrival_platform_planned": self._first_trip.arrival_platform_planned,
+ "arrival_platform_actual": self._first_trip.arrival_platform_actual,
"next": None,
- "status": self._trips[0].status.lower(),
- "transfers": self._trips[0].nr_transfers,
+ "status": self._first_trip.status.lower(),
+ "transfers": self._first_trip.nr_transfers,
"route": route,
"remarks": None,
}
# Planned departure attributes
- if self._trips[0].departure_time_planned is not None:
- attributes["departure_time_planned"] = self._trips[
- 0
- ].departure_time_planned.strftime("%H:%M")
+ if self._first_trip.departure_time_planned is not None:
+ attributes["departure_time_planned"] = (
+ self._first_trip.departure_time_planned.strftime("%H:%M")
+ )
# Actual departure attributes
- if self._trips[0].departure_time_actual is not None:
- attributes["departure_time_actual"] = self._trips[
- 0
- ].departure_time_actual.strftime("%H:%M")
+ if self._first_trip.departure_time_actual is not None:
+ attributes["departure_time_actual"] = (
+ self._first_trip.departure_time_actual.strftime("%H:%M")
+ )
# Delay departure attributes
if (
@@ -182,16 +184,16 @@ class NSDepartureSensor(SensorEntity):
attributes["departure_delay"] = True
# Planned arrival attributes
- if self._trips[0].arrival_time_planned is not None:
- attributes["arrival_time_planned"] = self._trips[
- 0
- ].arrival_time_planned.strftime("%H:%M")
+ if self._first_trip.arrival_time_planned is not None:
+ attributes["arrival_time_planned"] = (
+ self._first_trip.arrival_time_planned.strftime("%H:%M")
+ )
# Actual arrival attributes
- if self._trips[0].arrival_time_actual is not None:
- attributes["arrival_time_actual"] = self._trips[
- 0
- ].arrival_time_actual.strftime("%H:%M")
+ if self._first_trip.arrival_time_actual is not None:
+ attributes["arrival_time_actual"] = (
+ self._first_trip.arrival_time_actual.strftime("%H:%M")
+ )
# Delay arrival attributes
if (
@@ -202,15 +204,14 @@ class NSDepartureSensor(SensorEntity):
attributes["arrival_delay"] = True
# Next attributes
- if len(self._trips) > 1:
- if self._trips[1].departure_time_actual is not None:
- attributes["next"] = self._trips[1].departure_time_actual.strftime(
- "%H:%M"
- )
- elif self._trips[1].departure_time_planned is not None:
- attributes["next"] = self._trips[1].departure_time_planned.strftime(
- "%H:%M"
- )
+ if self._next_trip.departure_time_actual is not None:
+ attributes["next"] = self._next_trip.departure_time_actual.strftime("%H:%M")
+ elif self._next_trip.departure_time_planned is not None:
+ attributes["next"] = self._next_trip.departure_time_planned.strftime(
+ "%H:%M"
+ )
+ else:
+ attributes["next"] = None
return attributes
@@ -225,6 +226,7 @@ class NSDepartureSensor(SensorEntity):
):
self._state = None
self._trips = None
+ self._first_trip = None
return
# Set the search parameter to search from a specific trip time
@@ -236,19 +238,51 @@ class NSDepartureSensor(SensorEntity):
.strftime("%d-%m-%Y %H:%M")
)
else:
- trip_time = datetime.now().strftime("%d-%m-%Y %H:%M")
+ trip_time = dt_util.now().strftime("%d-%m-%Y %H:%M")
try:
self._trips = self._nsapi.get_trips(
trip_time, self._departure, self._via, self._heading, True, 0, 2
)
if self._trips:
- if self._trips[0].departure_time_actual is None:
- planned_time = self._trips[0].departure_time_planned
- self._state = planned_time.strftime("%H:%M")
+ all_times = []
+
+ # If a train is delayed we can observe this through departure_time_actual.
+ for trip in self._trips:
+ if trip.departure_time_actual is None:
+ all_times.append(trip.departure_time_planned)
+ else:
+ all_times.append(trip.departure_time_actual)
+
+ # Remove all trains that already left.
+ filtered_times = [
+ (i, time)
+ for i, time in enumerate(all_times)
+ if time > dt_util.now()
+ ]
+
+ if len(filtered_times) > 0:
+ sorted_times = sorted(filtered_times, key=lambda x: x[1])
+ self._first_trip = self._trips[sorted_times[0][0]]
+ self._state = sorted_times[0][1].strftime("%H:%M")
+
+ # Filter again to remove trains that leave at the exact same time.
+ filtered_times = [
+ (i, time)
+ for i, time in enumerate(all_times)
+ if time > sorted_times[0][1]
+ ]
+
+ if len(filtered_times) > 0:
+ sorted_times = sorted(filtered_times, key=lambda x: x[1])
+ self._next_trip = self._trips[sorted_times[0][0]]
+ else:
+ self._next_trip = None
+
else:
- actual_time = self._trips[0].departure_time_actual
- self._state = actual_time.strftime("%H:%M")
+ self._first_trip = None
+ self._state = None
+
except (
requests.exceptions.ConnectionError,
requests.exceptions.HTTPError,
diff --git a/homeassistant/components/neff/__init__.py b/homeassistant/components/neff/__init__.py
new file mode 100644
index 00000000000..211ce088834
--- /dev/null
+++ b/homeassistant/components/neff/__init__.py
@@ -0,0 +1 @@
+"""Neff virtual integration."""
diff --git a/homeassistant/components/neff/manifest.json b/homeassistant/components/neff/manifest.json
new file mode 100644
index 00000000000..1dfc91f94c9
--- /dev/null
+++ b/homeassistant/components/neff/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "neff",
+ "name": "Neff",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+}
diff --git a/homeassistant/components/ness_alarm/services.yaml b/homeassistant/components/ness_alarm/services.yaml
index b02d5e36805..aed1e1836bd 100644
--- a/homeassistant/components/ness_alarm/services.yaml
+++ b/homeassistant/components/ness_alarm/services.yaml
@@ -7,7 +7,7 @@ aux:
selector:
number:
min: 1
- max: 4
+ max: 8
state:
default: true
selector:
diff --git a/homeassistant/components/ness_alarm/strings.json b/homeassistant/components/ness_alarm/strings.json
index ec4e39a6128..f4490ac98db 100644
--- a/homeassistant/components/ness_alarm/strings.json
+++ b/homeassistant/components/ness_alarm/strings.json
@@ -2,7 +2,7 @@
"services": {
"aux": {
"name": "Aux",
- "description": "Trigger an aux output.",
+ "description": "Changes the state of an aux output.",
"fields": {
"output_id": {
"name": "Output ID",
@@ -10,17 +10,17 @@
},
"state": {
"name": "State",
- "description": "The On/Off State. If P14xE 8E is enabled then a value of true will pulse output x for the time specified in P14(x+4)E."
+ "description": "The on/off state of the output. If P14xE 8E is enabled then turning on will pulse the output for the time specified in P14(x+4)E."
}
}
},
"panic": {
"name": "Panic",
- "description": "Triggers a panic.",
+ "description": "Triggers a panic alarm.",
"fields": {
"code": {
"name": "Code",
- "description": "The user code to use to trigger the panic."
+ "description": "The user code to use to trigger the panic alarm."
}
}
}
diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py
index 67c14bbf544..af85f1fc5ae 100644
--- a/homeassistant/components/nest/__init__.py
+++ b/homeassistant/components/nest/__init__.py
@@ -8,7 +8,7 @@ from collections.abc import Awaitable, Callable
from http import HTTPStatus
import logging
-from aiohttp import web
+from aiohttp import ClientError, ClientResponseError, web
from google_nest_sdm.camera_traits import CameraClipPreviewTrait
from google_nest_sdm.device import Device
from google_nest_sdm.event import EventMessage
@@ -201,11 +201,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NestConfigEntry) -> bool
auth = await api.new_auth(hass, entry)
try:
await auth.async_get_access_token()
- except AuthException as err:
- raise ConfigEntryAuthFailed(f"Authentication error: {err!s}") from err
- except ConfigurationException as err:
- _LOGGER.error("Configuration error: %s", err)
- return False
+ except ClientResponseError as err:
+ if 400 <= err.status < 500:
+ raise ConfigEntryAuthFailed from err
+ raise ConfigEntryNotReady from err
+ except ClientError as err:
+ raise ConfigEntryNotReady from err
subscriber = await api.new_subscriber(hass, entry, auth)
if not subscriber:
diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py
index 727b126dda4..d55826f7ed0 100644
--- a/homeassistant/components/nest/api.py
+++ b/homeassistant/components/nest/api.py
@@ -50,13 +50,14 @@ class AsyncConfigEntryAuth(AbstractAuth):
return cast(str, self._oauth_session.token["access_token"])
async def async_get_creds(self) -> Credentials:
- """Return an OAuth credential for Pub/Sub Subscriber."""
- # We don't have a way for Home Assistant to refresh creds on behalf
- # of the google pub/sub subscriber. Instead, build a full
- # Credentials object with enough information for the subscriber to
- # handle this on its own. We purposely don't refresh the token here
- # even when it is expired to fully hand off this responsibility and
- # know it is working at startup (then if not, fail loudly).
+ """Return an OAuth credential for Pub/Sub Subscriber.
+
+ The subscriber will call this when connecting to the stream to refresh
+ the token. We construct a credentials object using the underlying
+ OAuth2Session since the subscriber may expect the expiry fields to
+ be present.
+ """
+ await self.async_get_access_token()
token = self._oauth_session.token
creds = Credentials( # type: ignore[no-untyped-call]
token=token["access_token"],
diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py
index df02f17444f..f5985da9ff8 100644
--- a/homeassistant/components/nest/camera.py
+++ b/homeassistant/components/nest/camera.py
@@ -30,7 +30,7 @@ from homeassistant.components.camera import (
from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import utcnow
@@ -51,7 +51,9 @@ BACKOFF_MULTIPLIER = 1.5
async def async_setup_entry(
- hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: NestConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the cameras."""
diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py
index 3193d592120..f5eff664f83 100644
--- a/homeassistant/components/nest/climate.py
+++ b/homeassistant/components/nest/climate.py
@@ -30,7 +30,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .device_info import NestDeviceInfo
from .types import NestConfigEntry
@@ -76,7 +76,9 @@ MIN_TEMP_RANGE = 1.66667
async def async_setup_entry(
- hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: NestConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the client entities."""
diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py
index facd429b139..8241b8aa5f8 100644
--- a/homeassistant/components/nest/device_info.py
+++ b/homeassistant/components/nest/device_info.py
@@ -7,7 +7,6 @@ from collections.abc import Mapping
from google_nest_sdm.device import Device
from google_nest_sdm.device_traits import ConnectivityTrait, InfoTrait
-from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -84,8 +83,7 @@ def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]:
"""Return a mapping of all nest devices for all config entries."""
return {
device.name: device
- for config_entry in hass.config_entries.async_entries(DOMAIN)
- if config_entry.state == ConfigEntryState.LOADED
+ for config_entry in hass.config_entries.async_loaded_entries(DOMAIN)
for device in config_entry.runtime_data.device_manager.devices.values()
}
diff --git a/homeassistant/components/nest/event.py b/homeassistant/components/nest/event.py
index 1a2c0317496..9bb041fce6c 100644
--- a/homeassistant/components/nest/event.py
+++ b/homeassistant/components/nest/event.py
@@ -13,7 +13,7 @@ from homeassistant.components.event import (
EventEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .device_info import NestDeviceInfo
from .events import (
@@ -66,7 +66,9 @@ ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
- hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: NestConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors."""
async_add_entities(
diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json
index a0d8bc06640..d9383533300 100644
--- a/homeassistant/components/nest/manifest.json
+++ b/homeassistant/components/nest/manifest.json
@@ -19,5 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/nest",
"iot_class": "cloud_push",
"loggers": ["google_nest_sdm"],
- "requirements": ["google-nest-sdm==7.1.3"]
+ "requirements": ["google-nest-sdm==7.1.4"]
}
diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py
index 146b6f2479e..a3d2901e911 100644
--- a/homeassistant/components/nest/media_source.py
+++ b/homeassistant/components/nest/media_source.py
@@ -20,8 +20,10 @@ from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
+import datetime
import logging
import os
+import pathlib
from typing import Any
from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait
@@ -46,6 +48,7 @@ from homeassistant.components.media_source import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.storage import Store
from homeassistant.helpers.template import DATE_STR_FORMAT
from homeassistant.util import dt as dt_util
@@ -72,6 +75,9 @@ MEDIA_PATH = f"{DOMAIN}/event_media"
# Size of small in-memory disk cache to avoid excessive disk reads
DISK_READ_LRU_MAX_SIZE = 32
+# Remove orphaned media files that are older than this age
+ORPHANED_MEDIA_AGE_CUTOFF = datetime.timedelta(days=7)
+
async def async_get_media_event_store(
hass: HomeAssistant, subscriber: GoogleNestSubscriber
@@ -123,6 +129,12 @@ class NestEventMediaStore(EventMediaStore):
self._media_path = media_path
self._data: dict[str, Any] | None = None
self._devices: Mapping[str, str] | None = {}
+ # Invoke garbage collection for orphaned files one per
+ async_track_time_interval(
+ hass,
+ self.async_remove_orphaned_media,
+ datetime.timedelta(days=1),
+ )
async def async_load(self) -> dict | None:
"""Load data."""
@@ -249,6 +261,68 @@ class NestEventMediaStore(EventMediaStore):
devices[device.name] = device_entry.id
return devices
+ async def async_remove_orphaned_media(self, now: datetime.datetime) -> None:
+ """Remove any media files that are orphaned and not referenced by the active event data.
+
+ The event media store handles garbage collection, but there may be cases where files are
+ left around or unable to be removed. This is a scheduled event that will also check for
+ old orphaned files and remove them when the events are not referenced in the active list
+ of event data.
+
+ Event media files are stored with the format -.suffix. We extract
+ the list of valid timestamps from the event data and remove any files that are not in that list
+ or are older than the cutoff time.
+ """
+ _LOGGER.debug("Checking for orphaned media at %s", now)
+
+ def _cleanup(event_timestamps: dict[str, set[int]]) -> None:
+ time_cutoff = (now - ORPHANED_MEDIA_AGE_CUTOFF).timestamp()
+ media_path = pathlib.Path(self._media_path)
+ for device_id, valid_timestamps in event_timestamps.items():
+ media_files = list(media_path.glob(f"{device_id}/*"))
+ _LOGGER.debug("Found %d files (device=%s)", len(media_files), device_id)
+ for media_file in media_files:
+ if "-" not in media_file.name:
+ continue
+ try:
+ timestamp = int(media_file.name.split("-")[0])
+ except ValueError:
+ continue
+ if timestamp in valid_timestamps or timestamp > time_cutoff:
+ continue
+ _LOGGER.debug("Removing orphaned media file: %s", media_file)
+ try:
+ os.remove(media_file)
+ except OSError as err:
+ _LOGGER.error(
+ "Unable to remove orphaned media file: %s %s",
+ media_file,
+ err,
+ )
+
+ # Nest device id mapped to home assistant device id
+ event_timestamps = await self._get_valid_event_timestamps()
+ await self._hass.async_add_executor_job(_cleanup, event_timestamps)
+
+ async def _get_valid_event_timestamps(self) -> dict[str, set[int]]:
+ """Return a mapping of home assistant device id to valid timestamps."""
+ device_map = await self._get_devices()
+ event_data = await self.async_load() or {}
+ valid_device_timestamps = {}
+ for nest_device_id, device_id in device_map.items():
+ if (device_events := event_data.get(nest_device_id, {})) is None:
+ continue
+ valid_device_timestamps[device_id] = {
+ int(
+ datetime.datetime.fromisoformat(
+ camera_event["timestamp"]
+ ).timestamp()
+ )
+ for events in device_events
+ for camera_event in events["events"].values()
+ }
+ return valid_device_timestamps
+
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
"""Set up Nest media source."""
diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py
index 02a0e305813..a6fda48fe87 100644
--- a/homeassistant/components/nest/sensor.py
+++ b/homeassistant/components/nest/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .device_info import NestDeviceInfo
from .types import NestConfigEntry
@@ -31,7 +31,9 @@ DEVICE_TYPE_MAP = {
async def async_setup_entry(
- hass: HomeAssistant, entry: NestConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: NestConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors."""
diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json
index 23da524ab7e..54f543aa845 100644
--- a/homeassistant/components/nest/strings.json
+++ b/homeassistant/components/nest/strings.json
@@ -58,6 +58,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py
index c478525753a..d35bfa7e8a6 100644
--- a/homeassistant/components/netatmo/binary_sensor.py
+++ b/homeassistant/components/netatmo/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NETATMO_CREATE_WEATHER_SENSOR
from .data_handler import NetatmoDevice
@@ -23,7 +23,9 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Netatmo binary sensors based on a config entry."""
diff --git a/homeassistant/components/netatmo/button.py b/homeassistant/components/netatmo/button.py
index 7b2899c84aa..e77b5188067 100644
--- a/homeassistant/components/netatmo/button.py
+++ b/homeassistant/components/netatmo/button.py
@@ -10,7 +10,7 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_BUTTON
from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice
@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Netatmo button platform."""
diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py
index 3bd7bcd859d..f21998bbac8 100644
--- a/homeassistant/components/netatmo/camera.py
+++ b/homeassistant/components/netatmo/camera.py
@@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_CAMERA_LIGHT_MODE,
@@ -48,7 +48,9 @@ DEFAULT_QUALITY = "high"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Netatmo camera platform."""
diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py
index 02c955beac3..2e3d8c6bcb8 100644
--- a/homeassistant/components/netatmo/climate.py
+++ b/homeassistant/components/netatmo/climate.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import (
@@ -118,7 +118,9 @@ NA_VALVE = DeviceType.NRV
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Netatmo energy platform."""
diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py
index d853694ffea..02d9c2fa3a6 100644
--- a/homeassistant/components/netatmo/config_flow.py
+++ b/homeassistant/components/netatmo/config_flow.py
@@ -135,7 +135,7 @@ class NetatmoOptionsFlowHandler(OptionsFlow):
vol.Optional(
CONF_WEATHER_AREAS,
default=weather_areas,
- ): cv.multi_select({wa: None for wa in weather_areas}),
+ ): cv.multi_select(dict.fromkeys(weather_areas)),
vol.Optional(CONF_NEW_AREA): str,
}
)
diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py
index c34b3a1b47b..a599aacd719 100644
--- a/homeassistant/components/netatmo/cover.py
+++ b/homeassistant/components/netatmo/cover.py
@@ -16,7 +16,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_COVER
from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice
@@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Netatmo cover platform."""
diff --git a/homeassistant/components/netatmo/fan.py b/homeassistant/components/netatmo/fan.py
index 9f3fe7174ff..b0dc74c2b58 100644
--- a/homeassistant/components/netatmo/fan.py
+++ b/homeassistant/components/netatmo/fan.py
@@ -11,7 +11,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_FAN
from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice
@@ -28,7 +28,7 @@ PRESETS = {v: k for k, v in PRESET_MAPPING.items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Netatmo fan platform."""
diff --git a/homeassistant/components/netatmo/light.py b/homeassistant/components/netatmo/light.py
index fe30dc0eaa4..ce28c455dea 100644
--- a/homeassistant/components/netatmo/light.py
+++ b/homeassistant/components/netatmo/light.py
@@ -11,7 +11,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_URL_CONTROL,
@@ -30,7 +30,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Netatmo camera light platform."""
diff --git a/homeassistant/components/netatmo/select.py b/homeassistant/components/netatmo/select.py
index 92568b73e80..e8637c90584 100644
--- a/homeassistant/components/netatmo/select.py
+++ b/homeassistant/components/netatmo/select.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_URL_ENERGY,
@@ -26,7 +26,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Netatmo energy platform schedule selector."""
diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py
index cc233dcc0ce..56b8233912f 100644
--- a/homeassistant/components/netatmo/sensor.py
+++ b/homeassistant/components/netatmo/sensor.py
@@ -38,7 +38,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
@@ -213,7 +213,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
netatmo_name="wind_angle",
entity_registry_enabled_default=False,
native_unit_of_measurement=DEGREE,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
NetatmoSensorEntityDescription(
key="windstrength",
@@ -235,7 +236,8 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
netatmo_name="gust_angle",
entity_registry_enabled_default=False,
native_unit_of_measurement=DEGREE,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
NetatmoSensorEntityDescription(
key="guststrength",
@@ -345,7 +347,8 @@ PUBLIC_WEATHER_STATION_TYPES: tuple[
key="windangle_value",
entity_registry_enabled_default=False,
native_unit_of_measurement=DEGREE,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
value_fn=lambda area: area.get_latest_wind_angles(),
),
NetatmoPublicWeatherSensorEntityDescription(
@@ -360,7 +363,8 @@ PUBLIC_WEATHER_STATION_TYPES: tuple[
translation_key="gust_angle",
entity_registry_enabled_default=False,
native_unit_of_measurement=DEGREE,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
value_fn=lambda area: area.get_latest_gust_angles(),
),
NetatmoPublicWeatherSensorEntityDescription(
@@ -385,7 +389,9 @@ BATTERY_SENSOR_DESCRIPTION = NetatmoSensorEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Netatmo sensor platform."""
diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml
index cab0528199d..c130d8e96e3 100644
--- a/homeassistant/components/netatmo/services.yaml
+++ b/homeassistant/components/netatmo/services.yaml
@@ -39,7 +39,7 @@ set_preset_mode_with_end_datetime:
select:
options:
- "away"
- - "Frost Guard"
+ - "frost_guard"
end_datetime:
required: true
example: '"2019-04-20 05:04:20"'
diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json
index 23b800e460d..580b49ea646 100644
--- a/homeassistant/components/netatmo/strings.json
+++ b/homeassistant/components/netatmo/strings.json
@@ -29,10 +29,10 @@
"public_weather": {
"data": {
"area_name": "Name of the area",
- "lat_ne": "North-East corner latitude",
- "lon_ne": "North-East corner longitude",
- "lat_sw": "South-West corner latitude",
- "lon_sw": "South-West corner longitude",
+ "lat_ne": "Northeast corner latitude",
+ "lon_ne": "Northeast corner longitude",
+ "lat_sw": "Southwest corner latitude",
+ "lon_sw": "Southwest corner longitude",
"mode": "Calculation",
"show_on_map": "Show on map"
},
@@ -175,7 +175,7 @@
"state": {
"frost_guard": "Frost guard",
"schedule": "Schedule",
- "manual": "Manual"
+ "manual": "[%key:common::state::manual%]"
}
}
}
@@ -206,13 +206,13 @@
"name": "Wind direction",
"state": {
"n": "North",
- "ne": "North-east",
+ "ne": "Northeast",
"e": "East",
- "se": "South-east",
+ "se": "Southeast",
"s": "South",
- "sw": "South-west",
+ "sw": "Southwest",
"w": "West",
- "nw": "North-west"
+ "nw": "Northwest"
}
},
"wind_angle": {
@@ -241,10 +241,10 @@
"name": "Reachability"
},
"rf_strength": {
- "name": "Radio"
+ "name": "RF strength"
},
"wifi_strength": {
- "name": "Wi-Fi"
+ "name": "Wi-Fi strength"
},
"health_idx": {
"name": "Health index",
diff --git a/homeassistant/components/netatmo/switch.py b/homeassistant/components/netatmo/switch.py
index 6ba4628a358..9ee37c11528 100644
--- a/homeassistant/components/netatmo/switch.py
+++ b/homeassistant/components/netatmo/switch.py
@@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_URL_CONTROL, NETATMO_CREATE_SWITCH
from .data_handler import HOME, SIGNAL_NAME, NetatmoDevice
@@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Netatmo switch platform."""
diff --git a/homeassistant/components/netgear/button.py b/homeassistant/components/netgear/button.py
index e5b9ec209c7..726c1b2296d 100644
--- a/homeassistant/components/netgear/button.py
+++ b/homeassistant/components/netgear/button.py
@@ -12,7 +12,7 @@ from homeassistant.components.button import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER
@@ -38,7 +38,9 @@ BUTTONS = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button for Netgear component."""
router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER]
diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py
index f7a683326d3..c8ecd8e7e1d 100644
--- a/homeassistant/components/netgear/const.py
+++ b/homeassistant/components/netgear/const.py
@@ -62,6 +62,7 @@ MODELS_V2 = [
"RBR",
"RBS",
"RBW",
+ "RS",
"LBK",
"LBR",
"CBK",
diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py
index b17430d2abb..56f4ecac14f 100644
--- a/homeassistant/components/netgear/device_tracker.py
+++ b/homeassistant/components/netgear/device_tracker.py
@@ -7,7 +7,7 @@ import logging
from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DEVICE_ICONS, DOMAIN, KEY_COORDINATOR, KEY_ROUTER
@@ -18,7 +18,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for Netgear component."""
router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER]
diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py
index d807f7aed0a..521e18098eb 100644
--- a/homeassistant/components/netgear/sensor.py
+++ b/homeassistant/components/netgear/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -274,7 +274,9 @@ SENSOR_LINK_TYPES = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for Netgear component."""
router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER]
diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py
index 85f214d784a..dd8468df099 100644
--- a/homeassistant/components/netgear/switch.py
+++ b/homeassistant/components/netgear/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER
@@ -99,7 +99,9 @@ ROUTER_SWITCH_TYPES = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches for Netgear component."""
router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER]
diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py
index 1fbfee3d892..388ad8bff4f 100644
--- a/homeassistant/components/netgear/update.py
+++ b/homeassistant/components/netgear/update.py
@@ -12,7 +12,7 @@ from homeassistant.components.update import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, KEY_COORDINATOR_FIRMWARE, KEY_ROUTER
@@ -23,7 +23,9 @@ LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up update entities for Netgear component."""
router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER]
diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py
index a756d85c866..47a39a39be0 100644
--- a/homeassistant/components/netgear_lte/__init__.py
+++ b/homeassistant/components/netgear_lte/__init__.py
@@ -6,7 +6,6 @@ from aiohttp.cookiejar import CookieJar
import eternalegypt
from eternalegypt.eternalegypt import SMS
-from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -117,12 +116,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -
async def async_unload_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
hass.data.pop(DOMAIN, None)
for service_name in hass.services.async_services()[DOMAIN]:
hass.services.async_remove(DOMAIN, service_name)
diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py
index cf7e757e8f1..890bcb37443 100644
--- a/homeassistant/components/netgear_lte/binary_sensor.py
+++ b/homeassistant/components/netgear_lte/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NetgearLTEConfigEntry
from .entity import LTEEntity
@@ -39,7 +39,7 @@ BINARY_SENSORS: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: NetgearLTEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Netgear LTE binary sensor."""
async_add_entities(
diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py
index 525d7f8aea0..49301267d9d 100644
--- a/homeassistant/components/netgear_lte/sensor.py
+++ b/homeassistant/components/netgear_lte/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
UnitOfInformation,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import NetgearLTEConfigEntry
@@ -127,7 +127,7 @@ SENSORS: tuple[NetgearLTESensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: NetgearLTEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Netgear LTE sensor."""
async_add_entities(NetgearLTESensor(entry, description) for description in SENSORS)
diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py
index 10046f75127..14c7dc55cf0 100644
--- a/homeassistant/components/network/__init__.py
+++ b/homeassistant/components/network/__init__.py
@@ -4,12 +4,14 @@ from __future__ import annotations
from ipaddress import IPv4Address, IPv6Address, ip_interface
import logging
+from pathlib import Path
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType
from homeassistant.loader import bind_hass
+from homeassistant.util import package
from . import util
from .const import (
@@ -20,13 +22,26 @@ from .const import (
PUBLIC_TARGET_IP,
)
from .models import Adapter
-from .network import Network, async_get_network
+from .network import Network, async_get_loaded_network, async_get_network
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+def _check_docker_without_host_networking() -> bool:
+ """Check if we are not using host networking in Docker."""
+ if not package.is_docker_env():
+ # We are not in Docker, so we don't need to check for host networking
+ return True
+
+ if Path("/proc/sys/net/ipv4/ip_forward").exists():
+ # If we can read this file, we likely have host networking
+ return True
+
+ return False
+
+
@bind_hass
async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]:
"""Get the network adapter configuration."""
@@ -34,6 +49,12 @@ async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]:
return network.adapters
+@callback
+def async_get_loaded_adapters(hass: HomeAssistant) -> list[Adapter]:
+ """Get the network adapter configuration."""
+ return async_get_loaded_network(hass).adapters
+
+
@bind_hass
async def async_get_source_ip(
hass: HomeAssistant, target_ip: str | UndefinedType = UNDEFINED
@@ -74,7 +95,14 @@ async def async_get_enabled_source_ips(
hass: HomeAssistant,
) -> list[IPv4Address | IPv6Address]:
"""Build the list of enabled source ips."""
- adapters = await async_get_adapters(hass)
+ return async_get_enabled_source_ips_from_adapters(await async_get_adapters(hass))
+
+
+@callback
+def async_get_enabled_source_ips_from_adapters(
+ adapters: list[Adapter],
+) -> list[IPv4Address | IPv6Address]:
+ """Build the list of enabled source ips."""
sources: list[IPv4Address | IPv6Address] = []
for adapter in adapters:
if not adapter["enabled"]:
@@ -151,5 +179,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_register_websocket_commands,
)
+ await async_get_network(hass)
+
+ if not await hass.async_add_executor_job(_check_docker_without_host_networking):
+ docs_url = "https://docs.docker.com/network/network-tutorial-host/"
+ install_url = "https://www.home-assistant.io/installation/linux#install-home-assistant-container"
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ "docker_host_network",
+ is_fixable=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key="docker_host_network",
+ learn_more_url=install_url,
+ translation_placeholders={"docs_url": docs_url, "install_url": install_url},
+ )
+
async_register_websocket_commands(hass)
return True
diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py
index 120ae9dfd7c..d8c8858be72 100644
--- a/homeassistant/components/network/const.py
+++ b/homeassistant/components/network/const.py
@@ -12,8 +12,6 @@ DOMAIN: Final = "network"
STORAGE_KEY: Final = "core.network"
STORAGE_VERSION: Final = 1
-DATA_NETWORK: Final = "network"
-
ATTR_ADAPTERS: Final = "adapters"
ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters"
DEFAULT_CONFIGURED_ADAPTERS: list[str] = []
diff --git a/homeassistant/components/network/network.py b/homeassistant/components/network/network.py
index 4158307bb1a..db25bedcaea 100644
--- a/homeassistant/components/network/network.py
+++ b/homeassistant/components/network/network.py
@@ -9,11 +9,12 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.async_ import create_eager_task
+from homeassistant.util.hass_dict import HassKey
from .const import (
ATTR_CONFIGURED_ADAPTERS,
- DATA_NETWORK,
DEFAULT_CONFIGURED_ADAPTERS,
+ DOMAIN,
STORAGE_KEY,
STORAGE_VERSION,
)
@@ -22,8 +23,16 @@ from .util import async_load_adapters, enable_adapters, enable_auto_detected_ada
_LOGGER = logging.getLogger(__name__)
+DATA_NETWORK: HassKey[Network] = HassKey(DOMAIN)
-@singleton(DATA_NETWORK)
+
+@callback
+def async_get_loaded_network(hass: HomeAssistant) -> Network:
+ """Get network singleton."""
+ return hass.data[DATA_NETWORK]
+
+
+@singleton(DOMAIN)
async def async_get_network(hass: HomeAssistant) -> Network:
"""Get network singleton."""
network = Network(hass)
diff --git a/homeassistant/components/network/strings.json b/homeassistant/components/network/strings.json
index 6aca7343221..3e135fff60b 100644
--- a/homeassistant/components/network/strings.json
+++ b/homeassistant/components/network/strings.json
@@ -6,5 +6,11 @@
"ipv6_addresses": "IPv6 addresses",
"announce_addresses": "Announce addresses"
}
+ },
+ "issues": {
+ "docker_host_network": {
+ "title": "Home Assistant is not using host networking",
+ "description": "Home Assistant is running in a container without host networking mode. This can cause networking issues with device discovery, multicast, broadcast, other network features, and incorrectly detecting its own URL and IP addresses, causing issues with media players and sending audio responses to voice assistants.\n\nIt is recommended to run Home Assistant with host networking by adding the `--network host` flag to your Docker run command or setting `network_mode: host` in your `docker-compose.yml` file.\n\nSee the [Docker documentation]({docs_url}) for more information about Docker host networking and refer to the [Home Assistant installation guide]({install_url}) for our recommended and supported setup."
+ }
}
}
diff --git a/homeassistant/components/nexia/binary_sensor.py b/homeassistant/components/nexia/binary_sensor.py
index 204d84ed975..224836c81e6 100644
--- a/homeassistant/components/nexia/binary_sensor.py
+++ b/homeassistant/components/nexia/binary_sensor.py
@@ -2,7 +2,7 @@
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import NexiaThermostatEntity
from .types import NexiaConfigEntry
@@ -11,7 +11,7 @@ from .types import NexiaConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NexiaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for a Nexia device."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py
index 81e7800fd01..e9637a16ae0 100644
--- a/homeassistant/components/nexia/climate.py
+++ b/homeassistant/components/nexia/climate.py
@@ -33,7 +33,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import VolDictType
@@ -53,13 +53,18 @@ PARALLEL_UPDATES = 1 # keep data in sync with only one connection at a time
SERVICE_SET_AIRCLEANER_MODE = "set_aircleaner_mode"
SERVICE_SET_HUMIDIFY_SETPOINT = "set_humidify_setpoint"
+SERVICE_SET_DEHUMIDIFY_SETPOINT = "set_dehumidify_setpoint"
SERVICE_SET_HVAC_RUN_MODE = "set_hvac_run_mode"
SET_AIRCLEANER_SCHEMA: VolDictType = {
vol.Required(ATTR_AIRCLEANER_MODE): cv.string,
}
-SET_HUMIDITY_SCHEMA: VolDictType = {
+SET_HUMIDIFY_SCHEMA: VolDictType = {
+ vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=10, max=45)),
+}
+
+SET_DEHUMIDIFY_SCHEMA: VolDictType = {
vol.Required(ATTR_HUMIDITY): vol.All(vol.Coerce(int), vol.Range(min=35, max=65)),
}
@@ -116,7 +121,7 @@ NEXIA_SUPPORTED = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NexiaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up climate for a Nexia device."""
coordinator = config_entry.runtime_data
@@ -126,9 +131,14 @@ async def async_setup_entry(
platform.async_register_entity_service(
SERVICE_SET_HUMIDIFY_SETPOINT,
- SET_HUMIDITY_SCHEMA,
+ SET_HUMIDIFY_SCHEMA,
f"async_{SERVICE_SET_HUMIDIFY_SETPOINT}",
)
+ platform.async_register_entity_service(
+ SERVICE_SET_DEHUMIDIFY_SETPOINT,
+ SET_DEHUMIDIFY_SCHEMA,
+ f"async_{SERVICE_SET_DEHUMIDIFY_SETPOINT}",
+ )
platform.async_register_entity_service(
SERVICE_SET_AIRCLEANER_MODE,
SET_AIRCLEANER_SCHEMA,
@@ -224,20 +234,48 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity):
return self._zone.get_preset()
async def async_set_humidity(self, humidity: int) -> None:
- """Dehumidify target."""
- if self._thermostat.has_dehumidify_support():
- await self.async_set_dehumidify_setpoint(humidity)
+ """Set humidity targets.
+
+ HA doesn't support separate humidify and dehumidify targets.
+ Set the target for the current mode if in [heat, cool]
+ otherwise set both targets to the clamped values.
+ """
+ zone_current_mode = self._zone.get_current_mode()
+ if zone_current_mode == OPERATION_MODE_HEAT:
+ if self._thermostat.has_humidify_support():
+ await self.async_set_humidify_setpoint(humidity)
+ elif zone_current_mode == OPERATION_MODE_COOL:
+ if self._thermostat.has_dehumidify_support():
+ await self.async_set_dehumidify_setpoint(humidity)
else:
- await self.async_set_humidify_setpoint(humidity)
+ if self._thermostat.has_humidify_support():
+ await self.async_set_humidify_setpoint(humidity)
+ if self._thermostat.has_dehumidify_support():
+ await self.async_set_dehumidify_setpoint(humidity)
self._signal_thermostat_update()
@property
- def target_humidity(self):
- """Humidity indoors setpoint."""
+ def target_humidity(self) -> float | None:
+ """Humidity indoors setpoint.
+
+ In systems that support both humidification and dehumidification,
+ two values for target exist. We must choose one to return.
+
+ :return: The target humidity setpoint.
+ """
+
+ # If heat is on, always return humidify value first
+ if (
+ self._has_humidify_support
+ and self._zone.get_current_mode() == OPERATION_MODE_HEAT
+ ):
+ return percent_conv(self._thermostat.get_humidify_setpoint())
+ # Fall back to previous behavior of returning dehumidify value then humidify
if self._has_dehumidify_support:
return percent_conv(self._thermostat.get_dehumidify_setpoint())
if self._has_humidify_support:
return percent_conv(self._thermostat.get_humidify_setpoint())
+
return None
@property
diff --git a/homeassistant/components/nexia/icons.json b/homeassistant/components/nexia/icons.json
index a2157f5c035..c9434a332df 100644
--- a/homeassistant/components/nexia/icons.json
+++ b/homeassistant/components/nexia/icons.json
@@ -26,6 +26,9 @@
"set_humidify_setpoint": {
"service": "mdi:water-percent"
},
+ "set_dehumidify_setpoint": {
+ "service": "mdi:water-percent"
+ },
"set_hvac_run_mode": {
"service": "mdi:hvac"
}
diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json
index 6a439f869c9..e8a1b53cc08 100644
--- a/homeassistant/components/nexia/manifest.json
+++ b/homeassistant/components/nexia/manifest.json
@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/nexia",
"iot_class": "cloud_polling",
"loggers": ["nexia"],
- "requirements": ["nexia==2.0.9"]
+ "requirements": ["nexia==2.7.0"]
}
diff --git a/homeassistant/components/nexia/number.py b/homeassistant/components/nexia/number.py
index 46cc4d094a3..05d9e5b4614 100644
--- a/homeassistant/components/nexia/number.py
+++ b/homeassistant/components/nexia/number.py
@@ -7,7 +7,7 @@ from nexia.thermostat import NexiaThermostat
from homeassistant.components.number import NumberEntity
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NexiaDataUpdateCoordinator
from .entity import NexiaThermostatEntity
@@ -18,7 +18,7 @@ from .util import percent_conv
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NexiaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for a Nexia device."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/nexia/scene.py b/homeassistant/components/nexia/scene.py
index 60078fab822..fe75eb07e02 100644
--- a/homeassistant/components/nexia/scene.py
+++ b/homeassistant/components/nexia/scene.py
@@ -6,7 +6,7 @@ from nexia.automation import NexiaAutomation
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from .const import ATTR_DESCRIPTION
@@ -20,7 +20,7 @@ SCENE_ACTIVATION_TIME = 5
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NexiaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up automations for a Nexia device."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py
index e50bd750c2f..648b5dc3eeb 100644
--- a/homeassistant/components/nexia/sensor.py
+++ b/homeassistant/components/nexia/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import NexiaThermostatEntity, NexiaThermostatZoneEntity
from .types import NexiaConfigEntry
@@ -22,7 +22,7 @@ from .util import percent_conv
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NexiaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for a Nexia device."""
@@ -114,6 +114,35 @@ async def async_setup_entry(
percent_conv,
)
)
+ # Heating Humidification Setpoint
+ if thermostat.has_humidify_support():
+ entities.append(
+ NexiaThermostatSensor(
+ coordinator,
+ thermostat,
+ "get_humidify_setpoint",
+ "get_humidify_setpoint",
+ SensorDeviceClass.HUMIDITY,
+ PERCENTAGE,
+ SensorStateClass.MEASUREMENT,
+ percent_conv,
+ )
+ )
+
+ # Cooling Dehumidification Setpoint
+ if thermostat.has_dehumidify_support():
+ entities.append(
+ NexiaThermostatSensor(
+ coordinator,
+ thermostat,
+ "get_dehumidify_setpoint",
+ "get_dehumidify_setpoint",
+ SensorDeviceClass.HUMIDITY,
+ PERCENTAGE,
+ SensorStateClass.MEASUREMENT,
+ percent_conv,
+ )
+ )
# Zone Sensors
for zone_id in thermostat.get_zone_ids():
diff --git a/homeassistant/components/nexia/services.yaml b/homeassistant/components/nexia/services.yaml
index ede1f311acf..d010676d14a 100644
--- a/homeassistant/components/nexia/services.yaml
+++ b/homeassistant/components/nexia/services.yaml
@@ -14,6 +14,20 @@ set_aircleaner_mode:
- "quick"
set_humidify_setpoint:
+ target:
+ entity:
+ integration: nexia
+ domain: climate
+ fields:
+ humidity:
+ required: true
+ selector:
+ number:
+ min: 10
+ max: 45
+ unit_of_measurement: "%"
+
+set_dehumidify_setpoint:
target:
entity:
integration: nexia
diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json
index d88ce0b898d..f6b08d5e8e5 100644
--- a/homeassistant/components/nexia/strings.json
+++ b/homeassistant/components/nexia/strings.json
@@ -53,11 +53,20 @@
},
"zone_setpoint_status": {
"name": "Zone setpoint status"
+ },
+ "get_humidify_setpoint": {
+ "name": "Heating humidify setpoint"
+ },
+ "get_dehumidify_setpoint": {
+ "name": "Cooling dehumidify setpoint"
}
},
"switch": {
"hold": {
"name": "Hold"
+ },
+ "emergency_heat": {
+ "name": "Emergency heat"
}
}
},
@@ -73,18 +82,28 @@
}
},
"set_humidify_setpoint": {
- "name": "Set humidify set point",
- "description": "Sets the target humidity.",
+ "name": "Set humidify setpoint",
+ "description": "Sets the target humidity for heating.",
"fields": {
"humidity": {
"name": "Humidity",
- "description": "The humidification setpoint."
+ "description": "The setpoint for humidification when heating."
+ }
+ }
+ },
+ "set_dehumidify_setpoint": {
+ "name": "Set dehumidify setpoint",
+ "description": "Sets the target humidity for cooling.",
+ "fields": {
+ "humidity": {
+ "name": "Humidity",
+ "description": "The setpoint for dehumidification when cooling."
}
}
},
"set_hvac_run_mode": {
- "name": "Set hvac run mode",
- "description": "Sets the HVAC operation mode.",
+ "name": "Set HVAC run mode",
+ "description": "Sets the run and/or operation mode of the HVAC system.",
"fields": {
"run_mode": {
"name": "Run mode",
diff --git a/homeassistant/components/nexia/switch.py b/homeassistant/components/nexia/switch.py
index 9505538e86a..1897ad67414 100644
--- a/homeassistant/components/nexia/switch.py
+++ b/homeassistant/components/nexia/switch.py
@@ -10,7 +10,7 @@ from nexia.zone import NexiaThermostatZone
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NexiaDataUpdateCoordinator
from .entity import NexiaThermostatEntity, NexiaThermostatZoneEntity
@@ -20,7 +20,7 @@ from .types import NexiaConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NexiaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches for a Nexia device."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py
index 554814fe2db..2e184e13fc7 100644
--- a/homeassistant/components/nextbus/sensor.py
+++ b/homeassistant/components/nextbus/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_STOP
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utc_from_timestamp
@@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load values from configuration and initialize the platform."""
_LOGGER.debug(config.data)
diff --git a/homeassistant/components/nextcloud/binary_sensor.py b/homeassistant/components/nextcloud/binary_sensor.py
index 10e1a000a68..f51796e6c7f 100644
--- a/homeassistant/components/nextcloud/binary_sensor.py
+++ b/homeassistant/components/nextcloud/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NextcloudConfigEntry
from .entity import NextcloudEntity
@@ -54,7 +54,7 @@ BINARY_SENSORS: Final[list[BinarySensorEntityDescription]] = [
async def async_setup_entry(
hass: HomeAssistant,
entry: NextcloudConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Nextcloud binary sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py
index a6722821012..63b31f0edde 100644
--- a/homeassistant/components/nextcloud/sensor.py
+++ b/homeassistant/components/nextcloud/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utc_from_timestamp
from .coordinator import NextcloudConfigEntry
@@ -602,7 +602,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [
async def async_setup_entry(
hass: HomeAssistant,
entry: NextcloudConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Nextcloud sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json
index f9f7e4c2294..ef4e3de0f62 100644
--- a/homeassistant/components/nextcloud/strings.json
+++ b/homeassistant/components/nextcloud/strings.json
@@ -21,7 +21,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "connection_error_during_import": "Connection error occured during yaml configuration import",
+ "connection_error_during_import": "Connection error occurred during yaml configuration import",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
@@ -70,7 +70,7 @@
"name": "Cache memory"
},
"nextcloud_cache_num_entries": {
- "name": "Cache number of entires"
+ "name": "Cache number of entries"
},
"nextcloud_cache_num_hits": {
"name": "Cache number of hits"
@@ -88,7 +88,7 @@
"name": "Cache start time"
},
"nextcloud_cache_ttl": {
- "name": "Cache ttl"
+ "name": "Cache TTL"
},
"nextcloud_database_size": {
"name": "Database size"
@@ -268,13 +268,13 @@
"name": "Updates available"
},
"nextcloud_system_cpuload_1": {
- "name": "CPU Load last 1 minute"
+ "name": "CPU load last 1 minute"
},
"nextcloud_system_cpuload_15": {
- "name": "CPU Load last 15 minutes"
+ "name": "CPU load last 15 minutes"
},
"nextcloud_system_cpuload_5": {
- "name": "CPU Load last 5 minutes"
+ "name": "CPU load last 5 minutes"
},
"nextcloud_system_freespace": {
"name": "Free space"
diff --git a/homeassistant/components/nextcloud/update.py b/homeassistant/components/nextcloud/update.py
index aad6412b7b3..b991b001117 100644
--- a/homeassistant/components/nextcloud/update.py
+++ b/homeassistant/components/nextcloud/update.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from homeassistant.components.update import UpdateEntity, UpdateEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NextcloudConfigEntry
from .entity import NextcloudEntity
@@ -13,7 +13,7 @@ from .entity import NextcloudEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: NextcloudConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Nextcloud update entity."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/nextdns/__init__.py b/homeassistant/components/nextdns/__init__.py
index 478ff215c30..eb8bd26cb9b 100644
--- a/homeassistant/components/nextdns/__init__.py
+++ b/homeassistant/components/nextdns/__init__.py
@@ -36,6 +36,7 @@ from .const import (
ATTR_SETTINGS,
ATTR_STATUS,
CONF_PROFILE_ID,
+ DOMAIN,
UPDATE_INTERVAL_ANALYTICS,
UPDATE_INTERVAL_CONNECTION,
UPDATE_INTERVAL_SETTINGS,
@@ -88,9 +89,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: NextDnsConfigEntry) -> b
try:
nextdns = await NextDns.create(websession, api_key)
except (ApiError, ClientConnectorError, RetryError, TimeoutError) as err:
- raise ConfigEntryNotReady from err
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ translation_placeholders={
+ "entry": entry.title,
+ "error": repr(err),
+ },
+ ) from err
except InvalidApiKeyError as err:
- raise ConfigEntryAuthFailed from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_error",
+ translation_placeholders={"entry": entry.title},
+ ) from err
tasks = []
coordinators = {}
diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py
index 08a1f89418f..ed244146efc 100644
--- a/homeassistant/components/nextdns/binary_sensor.py
+++ b/homeassistant/components/nextdns/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import NextDnsConfigEntry
@@ -51,7 +51,7 @@ SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: NextDnsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add NextDNS entities from a config_entry."""
coordinator = entry.runtime_data.connection
diff --git a/homeassistant/components/nextdns/button.py b/homeassistant/components/nextdns/button.py
index 164d725b393..2adccaa304f 100644
--- a/homeassistant/components/nextdns/button.py
+++ b/homeassistant/components/nextdns/button.py
@@ -2,15 +2,19 @@
from __future__ import annotations
-from nextdns import AnalyticsStatus
+from aiohttp import ClientError
+from aiohttp.client_exceptions import ClientConnectorError
+from nextdns import AnalyticsStatus, ApiError, InvalidApiKeyError
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import NextDnsConfigEntry
+from .const import DOMAIN
from .coordinator import NextDnsUpdateCoordinator
PARALLEL_UPDATES = 1
@@ -25,7 +29,7 @@ CLEAR_LOGS_BUTTON = ButtonEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: NextDnsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add aNextDNS entities from a config_entry."""
coordinator = entry.runtime_data.status
@@ -53,4 +57,21 @@ class NextDnsButton(
async def async_press(self) -> None:
"""Trigger cleaning logs."""
- await self.coordinator.nextdns.clear_logs(self.coordinator.profile_id)
+ try:
+ await self.coordinator.nextdns.clear_logs(self.coordinator.profile_id)
+ except (
+ ApiError,
+ ClientConnectorError,
+ TimeoutError,
+ ClientError,
+ ) as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="method_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "error": repr(err),
+ },
+ ) from err
+ except InvalidApiKeyError:
+ self.coordinator.config_entry.async_start_reauth(self.hass)
diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py
index d3327c4c08b..d36064d8fb0 100644
--- a/homeassistant/components/nextdns/config_flow.py
+++ b/homeassistant/components/nextdns/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
+import logging
from typing import Any
from aiohttp.client_exceptions import ClientConnectorError
@@ -19,6 +20,8 @@ from .const import CONF_PROFILE_ID, DOMAIN
AUTH_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
+_LOGGER = logging.getLogger(__name__)
+
async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns:
"""Check if credentials are valid."""
@@ -51,7 +54,8 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_api_key"
except (ApiError, ClientConnectorError, RetryError, TimeoutError):
errors["base"] = "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return await self.async_step_profiles()
@@ -111,7 +115,8 @@ class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_api_key"
except (ApiError, ClientConnectorError, RetryError, TimeoutError):
errors["base"] = "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
diff --git a/homeassistant/components/nextdns/coordinator.py b/homeassistant/components/nextdns/coordinator.py
index 850702e4488..41f6ff43a2a 100644
--- a/homeassistant/components/nextdns/coordinator.py
+++ b/homeassistant/components/nextdns/coordinator.py
@@ -79,9 +79,20 @@ class NextDnsUpdateCoordinator(DataUpdateCoordinator[CoordinatorDataT]):
ClientConnectorError,
RetryError,
) as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ translation_placeholders={
+ "entry": self.config_entry.title,
+ "error": repr(err),
+ },
+ ) from err
except InvalidApiKeyError as err:
- raise ConfigEntryAuthFailed from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_error",
+ translation_placeholders={"entry": self.config_entry.title},
+ ) from err
async def _async_update_data_internal(self) -> CoordinatorDataT:
"""Update data via library."""
diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py
index ef2b5140fa1..0a4a8eaad8f 100644
--- a/homeassistant/components/nextdns/sensor.py
+++ b/homeassistant/components/nextdns/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -286,7 +286,7 @@ SENSORS: tuple[NextDnsSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: NextDnsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a NextDNS entities from a config_entry."""
async_add_entities(
diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json
index f2a5fa2816d..38944a0711e 100644
--- a/homeassistant/components/nextdns/strings.json
+++ b/homeassistant/components/nextdns/strings.json
@@ -359,5 +359,19 @@
"name": "Force YouTube restricted mode"
}
}
+ },
+ "exceptions": {
+ "auth_error": {
+ "message": "Authentication failed for {entry}, please update your API key"
+ },
+ "cannot_connect": {
+ "message": "An error occurred while connecting to the NextDNS API for {entry}: {error}"
+ },
+ "method_error": {
+ "message": "An error occurred while calling the NextDNS API method for {entity}: {error}"
+ },
+ "update_error": {
+ "message": "An error occurred while retrieving data from the NextDNS API for {entry}: {error}"
+ }
}
}
diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py
index 37ff22c7521..8bdca76b955 100644
--- a/homeassistant/components/nextdns/switch.py
+++ b/homeassistant/components/nextdns/switch.py
@@ -8,16 +8,17 @@ from typing import Any
from aiohttp import ClientError
from aiohttp.client_exceptions import ClientConnectorError
-from nextdns import ApiError, Settings
+from nextdns import ApiError, InvalidApiKeyError, Settings
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import NextDnsConfigEntry
+from .const import DOMAIN
from .coordinator import NextDnsUpdateCoordinator
PARALLEL_UPDATES = 1
@@ -525,7 +526,7 @@ SWITCHES = (
async def async_setup_entry(
hass: HomeAssistant,
entry: NextDnsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add NextDNS entities from a config_entry."""
coordinator = entry.runtime_data.settings
@@ -582,9 +583,16 @@ class NextDnsSwitch(
ClientError,
) as err:
raise HomeAssistantError(
- "NextDNS API returned an error calling set_setting for"
- f" {self.entity_id}: {err}"
+ translation_domain=DOMAIN,
+ translation_key="method_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "error": repr(err),
+ },
) from err
+ except InvalidApiKeyError:
+ self.coordinator.config_entry.async_start_reauth(self.hass)
+ return
if result:
self._attr_is_on = new_state
diff --git a/homeassistant/components/nibe_heatpump/binary_sensor.py b/homeassistant/components/nibe_heatpump/binary_sensor.py
index 0cb16bf4485..284e4d83569 100644
--- a/homeassistant/components/nibe_heatpump/binary_sensor.py
+++ b/homeassistant/components/nibe_heatpump/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySenso
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import CoilCoordinator
@@ -18,7 +18,7 @@ from .entity import CoilEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up platform."""
diff --git a/homeassistant/components/nibe_heatpump/button.py b/homeassistant/components/nibe_heatpump/button.py
index df8ceef6479..849912af656 100644
--- a/homeassistant/components/nibe_heatpump/button.py
+++ b/homeassistant/components/nibe_heatpump/button.py
@@ -9,7 +9,7 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, LOGGER
@@ -19,7 +19,7 @@ from .coordinator import CoilCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up platform."""
diff --git a/homeassistant/components/nibe_heatpump/climate.py b/homeassistant/components/nibe_heatpump/climate.py
index 94db90e7f58..1b8a0ecc0df 100644
--- a/homeassistant/components/nibe_heatpump/climate.py
+++ b/homeassistant/components/nibe_heatpump/climate.py
@@ -27,7 +27,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -44,7 +44,7 @@ from .coordinator import CoilCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up platform."""
diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json
index 049ba905f04..a8441fb90d8 100644
--- a/homeassistant/components/nibe_heatpump/manifest.json
+++ b/homeassistant/components/nibe_heatpump/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nibe_heatpump",
"iot_class": "local_polling",
- "requirements": ["nibe==2.14.0"]
+ "requirements": ["nibe==2.17.0"]
}
diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py
index cb379139eed..d85e5e9b765 100644
--- a/homeassistant/components/nibe_heatpump/number.py
+++ b/homeassistant/components/nibe_heatpump/number.py
@@ -8,7 +8,7 @@ from homeassistant.components.number import ENTITY_ID_FORMAT, NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import CoilCoordinator
@@ -18,7 +18,7 @@ from .entity import CoilEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up platform."""
diff --git a/homeassistant/components/nibe_heatpump/select.py b/homeassistant/components/nibe_heatpump/select.py
index 3aecff94649..c92c12a882a 100644
--- a/homeassistant/components/nibe_heatpump/select.py
+++ b/homeassistant/components/nibe_heatpump/select.py
@@ -8,7 +8,7 @@ from homeassistant.components.select import ENTITY_ID_FORMAT, SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import CoilCoordinator
@@ -18,7 +18,7 @@ from .entity import CoilEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up platform."""
diff --git a/homeassistant/components/nibe_heatpump/sensor.py b/homeassistant/components/nibe_heatpump/sensor.py
index d34fed50977..54cd0f7ea34 100644
--- a/homeassistant/components/nibe_heatpump/sensor.py
+++ b/homeassistant/components/nibe_heatpump/sensor.py
@@ -13,17 +13,20 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
+ PERCENTAGE,
EntityCategory,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
+ UnitOfPressure,
UnitOfTemperature,
UnitOfTime,
+ UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import CoilCoordinator
@@ -114,6 +117,20 @@ UNIT_DESCRIPTIONS = {
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfTime.HOURS,
),
+ "min": SensorEntityDescription(
+ key="min",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ ),
+ "s": SensorEntityDescription(
+ key="s",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.DURATION,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ ),
"Hz": SensorEntityDescription(
key="Hz",
entity_category=EntityCategory.DIAGNOSTIC,
@@ -121,13 +138,55 @@ UNIT_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
),
+ "Pa": SensorEntityDescription(
+ key="Pa",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.PRESSURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPressure.PA,
+ ),
+ "kPa": SensorEntityDescription(
+ key="kPa",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.PRESSURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPressure.KPA,
+ ),
+ "bar": SensorEntityDescription(
+ key="bar",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.PRESSURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfPressure.BAR,
+ ),
+ "l/m": SensorEntityDescription(
+ key="l/m",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
+ ),
+ "m³/h": SensorEntityDescription(
+ key="m³/h",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
+ ),
+ "%RH": SensorEntityDescription(
+ key="%RH",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=PERCENTAGE,
+ ),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up platform."""
diff --git a/homeassistant/components/nibe_heatpump/strings.json b/homeassistant/components/nibe_heatpump/strings.json
index 6fa421e0855..c65a76d3364 100644
--- a/homeassistant/components/nibe_heatpump/strings.json
+++ b/homeassistant/components/nibe_heatpump/strings.json
@@ -10,13 +10,13 @@
},
"modbus": {
"data": {
- "model": "Model of Heat Pump",
+ "model": "Model of heat pump",
"modbus_url": "Modbus URL",
- "modbus_unit": "Modbus Unit Identifier"
+ "modbus_unit": "Modbus unit identifier"
},
"data_description": {
- "modbus_url": "Modbus URL that describes the connection to your Heat Pump or MODBUS40 unit. It should be on the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote telnet based Modbus RTU connection.",
- "modbus_unit": "Unit identification for your Heat Pump. Can usually be left at 0."
+ "modbus_url": "Modbus URL that describes the connection to your heat pump or MODBUS40 unit. It should be in the form:\n - `tcp://[HOST]:[PORT]` for Modbus TCP connection\n - `serial://[LOCAL DEVICE]` for a local Modbus RTU connection\n - `rfc2217://[HOST]:[PORT]` for a remote Telnet-based Modbus RTU connection.",
+ "modbus_unit": "Unit identification for your heat pump. Can usually be left at 0."
}
},
"nibegw": {
diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py
index 72b7c20c7b3..2daf3fc48ff 100644
--- a/homeassistant/components/nibe_heatpump/switch.py
+++ b/homeassistant/components/nibe_heatpump/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import CoilCoordinator
@@ -20,7 +20,7 @@ from .entity import CoilEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up platform."""
diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py
index f53df596d27..a72851e7eab 100644
--- a/homeassistant/components/nibe_heatpump/water_heater.py
+++ b/homeassistant/components/nibe_heatpump/water_heater.py
@@ -17,7 +17,7 @@ from homeassistant.components.water_heater import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -32,7 +32,7 @@ from .coordinator import CoilCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up platform."""
diff --git a/homeassistant/components/nice_go/coordinator.py b/homeassistant/components/nice_go/coordinator.py
index e486263fbe5..ffdd9dbd518 100644
--- a/homeassistant/components/nice_go/coordinator.py
+++ b/homeassistant/components/nice_go/coordinator.py
@@ -153,7 +153,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
)
try:
if datetime.now().timestamp() >= expiry_time:
- await self._update_refresh_token()
+ await self.update_refresh_token()
else:
await self.api.authenticate_refresh(
self.refresh_token, async_get_clientsession(self.hass)
@@ -178,7 +178,7 @@ class NiceGOUpdateCoordinator(DataUpdateCoordinator[dict[str, NiceGODevice]]):
else:
self.async_set_updated_data(devices)
- async def _update_refresh_token(self) -> None:
+ async def update_refresh_token(self) -> None:
"""Update the refresh token with Nice G.O. API."""
_LOGGER.debug("Updating the refresh token with Nice G.O. API")
try:
diff --git a/homeassistant/components/nice_go/cover.py b/homeassistant/components/nice_go/cover.py
index 79afbcad532..b9b39711a01 100644
--- a/homeassistant/components/nice_go/cover.py
+++ b/homeassistant/components/nice_go/cover.py
@@ -2,21 +2,17 @@
from typing import Any
-from aiohttp import ClientError
-from nice_go import ApiError
-
from homeassistant.components.cover import (
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
from .coordinator import NiceGOConfigEntry
from .entity import NiceGOEntity
+from .util import retry
DEVICE_CLASSES = {
"WallStation": CoverDeviceClass.GARAGE,
@@ -29,7 +25,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NiceGOConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Nice G.O. cover."""
coordinator = config_entry.runtime_data
@@ -71,30 +67,18 @@ class NiceGOCoverEntity(NiceGOEntity, CoverEntity):
"""Return if cover is closing."""
return self.data.barrier_status == "closing"
+ @retry("close_cover_error")
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close the garage door."""
if self.is_closed:
return
- try:
- await self.coordinator.api.close_barrier(self._device_id)
- except (ApiError, ClientError) as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="close_cover_error",
- translation_placeholders={"exception": str(err)},
- ) from err
+ await self.coordinator.api.close_barrier(self._device_id)
+ @retry("open_cover_error")
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the garage door."""
if self.is_opened:
return
- try:
- await self.coordinator.api.open_barrier(self._device_id)
- except (ApiError, ClientError) as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="open_cover_error",
- translation_placeholders={"exception": str(err)},
- ) from err
+ await self.coordinator.api.open_barrier(self._device_id)
diff --git a/homeassistant/components/nice_go/event.py b/homeassistant/components/nice_go/event.py
index a02c14f87ab..400cc3d2144 100644
--- a/homeassistant/components/nice_go/event.py
+++ b/homeassistant/components/nice_go/event.py
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.event import EventEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import NiceGOConfigEntry
from .entity import NiceGOEntity
@@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NiceGOConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Nice G.O. event."""
diff --git a/homeassistant/components/nice_go/light.py b/homeassistant/components/nice_go/light.py
index cd8170ae353..bf283ed6eff 100644
--- a/homeassistant/components/nice_go/light.py
+++ b/homeassistant/components/nice_go/light.py
@@ -3,23 +3,19 @@
import logging
from typing import TYPE_CHECKING, Any
-from aiohttp import ClientError
-from nice_go import ApiError
-
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
- DOMAIN,
KNOWN_UNSUPPORTED_DEVICE_TYPES,
SUPPORTED_DEVICE_TYPES,
UNSUPPORTED_DEVICE_WARNING,
)
from .coordinator import NiceGOConfigEntry
from .entity import NiceGOEntity
+from .util import retry
_LOGGER = logging.getLogger(__name__)
@@ -27,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NiceGOConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Nice G.O. light."""
@@ -63,26 +59,14 @@ class NiceGOLightEntity(NiceGOEntity, LightEntity):
assert self.data.light_status is not None
return self.data.light_status
+ @retry("light_on_error")
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the light."""
- try:
- await self.coordinator.api.light_on(self._device_id)
- except (ApiError, ClientError) as error:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="light_on_error",
- translation_placeholders={"exception": str(error)},
- ) from error
+ await self.coordinator.api.light_on(self._device_id)
+ @retry("light_off_error")
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the light."""
- try:
- await self.coordinator.api.light_off(self._device_id)
- except (ApiError, ClientError) as error:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="light_off_error",
- translation_placeholders={"exception": str(error)},
- ) from error
+ await self.coordinator.api.light_off(self._device_id)
diff --git a/homeassistant/components/nice_go/switch.py b/homeassistant/components/nice_go/switch.py
index 607b0c827d2..f043a23eab5 100644
--- a/homeassistant/components/nice_go/switch.py
+++ b/homeassistant/components/nice_go/switch.py
@@ -5,23 +5,19 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
-from aiohttp import ClientError
-from nice_go import ApiError
-
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
- DOMAIN,
KNOWN_UNSUPPORTED_DEVICE_TYPES,
SUPPORTED_DEVICE_TYPES,
UNSUPPORTED_DEVICE_WARNING,
)
from .coordinator import NiceGOConfigEntry
from .entity import NiceGOEntity
+from .util import retry
_LOGGER = logging.getLogger(__name__)
@@ -29,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NiceGOConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Nice G.O. switch."""
coordinator = config_entry.runtime_data
@@ -65,26 +61,14 @@ class NiceGOSwitchEntity(NiceGOEntity, SwitchEntity):
assert self.data.vacation_mode is not None
return self.data.vacation_mode
+ @retry("switch_on_error")
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
- try:
- await self.coordinator.api.vacation_mode_on(self.data.id)
- except (ApiError, ClientError) as error:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="switch_on_error",
- translation_placeholders={"exception": str(error)},
- ) from error
+ await self.coordinator.api.vacation_mode_on(self.data.id)
+ @retry("switch_off_error")
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
- try:
- await self.coordinator.api.vacation_mode_off(self.data.id)
- except (ApiError, ClientError) as error:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="switch_off_error",
- translation_placeholders={"exception": str(error)},
- ) from error
+ await self.coordinator.api.vacation_mode_off(self.data.id)
diff --git a/homeassistant/components/nice_go/util.py b/homeassistant/components/nice_go/util.py
new file mode 100644
index 00000000000..02dee6b0ac1
--- /dev/null
+++ b/homeassistant/components/nice_go/util.py
@@ -0,0 +1,66 @@
+"""Utilities for Nice G.O."""
+
+from collections.abc import Callable, Coroutine
+from functools import wraps
+from typing import Any, Protocol, runtime_checkable
+
+from aiohttp import ClientError
+from nice_go import ApiError, AuthFailedError
+
+from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
+from homeassistant.helpers.update_coordinator import UpdateFailed
+
+from .const import DOMAIN
+
+
+@runtime_checkable
+class _ArgsProtocol(Protocol):
+ coordinator: Any
+ hass: Any
+
+
+def retry[_R, **P](
+ translation_key: str,
+) -> Callable[
+ [Callable[P, Coroutine[Any, Any, _R]]], Callable[P, Coroutine[Any, Any, _R]]
+]:
+ """Retry decorator to handle API errors."""
+
+ def decorator(
+ func: Callable[P, Coroutine[Any, Any, _R]],
+ ) -> Callable[P, Coroutine[Any, Any, _R]]:
+ @wraps(func)
+ async def wrapper(*args: P.args, **kwargs: P.kwargs):
+ instance = args[0]
+ if not isinstance(instance, _ArgsProtocol):
+ raise TypeError("First argument must have correct attributes")
+ try:
+ return await func(*args, **kwargs)
+ except (ApiError, ClientError) as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=translation_key,
+ translation_placeholders={"exception": str(err)},
+ ) from err
+ except AuthFailedError:
+ # Try refreshing token and retry
+ try:
+ await instance.coordinator.update_refresh_token()
+ return await func(*args, **kwargs)
+ except (ApiError, ClientError, UpdateFailed) as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=translation_key,
+ translation_placeholders={"exception": str(err)},
+ ) from err
+ except (AuthFailedError, ConfigEntryAuthFailed) as err:
+ instance.coordinator.config_entry.async_start_reauth(instance.hass)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key=translation_key,
+ translation_placeholders={"exception": str(err)},
+ ) from err
+
+ return wrapper
+
+ return decorator
diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py
index 620349ec3c3..de1dadf1143 100644
--- a/homeassistant/components/nightscout/sensor.py
+++ b/homeassistant/components/nightscout/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_DATE, UnitOfBloodGlucoseConcentration
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_DELTA, ATTR_DEVICE, ATTR_DIRECTION, DOMAIN
@@ -27,7 +27,7 @@ DEFAULT_NAME = "Blood Glucose"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Glucose Sensor."""
api = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/niko_home_control/config_flow.py b/homeassistant/components/niko_home_control/config_flow.py
index f37e5e9248a..76e71bc1690 100644
--- a/homeassistant/components/niko_home_control/config_flow.py
+++ b/homeassistant/components/niko_home_control/config_flow.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import logging
from typing import Any
from nhc.controller import NHCController
@@ -12,6 +13,8 @@ from homeassistant.const import CONF_HOST
from .const import DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
@@ -25,7 +28,8 @@ async def test_connection(host: str) -> str | None:
controller = NHCController(host, 8000)
try:
await controller.connect()
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
return "cannot_connect"
return None
diff --git a/homeassistant/components/niko_home_control/cover.py b/homeassistant/components/niko_home_control/cover.py
index b3546b517d5..2ab3438c4d9 100644
--- a/homeassistant/components/niko_home_control/cover.py
+++ b/homeassistant/components/niko_home_control/cover.py
@@ -8,7 +8,7 @@ from nhc.cover import NHCCover
from homeassistant.components.cover import CoverEntity, CoverEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NikoHomeControlConfigEntry
from .entity import NikoHomeControlEntity
@@ -17,7 +17,7 @@ from .entity import NikoHomeControlEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: NikoHomeControlConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Niko Home Control cover entry."""
controller = entry.runtime_data
diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py
index 7c0d11b3388..b0a2d12b004 100644
--- a/homeassistant/components/niko_home_control/light.py
+++ b/homeassistant/components/niko_home_control/light.py
@@ -19,7 +19,10 @@ from homeassistant.const import CONF_HOST
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import NHCController, NikoHomeControlConfigEntry
@@ -80,7 +83,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
entry: NikoHomeControlConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Niko Home Control light entry."""
controller = entry.runtime_data
diff --git a/homeassistant/components/niko_home_control/quality_scale.yaml b/homeassistant/components/niko_home_control/quality_scale.yaml
new file mode 100644
index 00000000000..390efb8fc90
--- /dev/null
+++ b/homeassistant/components/niko_home_control/quality_scale.yaml
@@ -0,0 +1,84 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration does not require polling.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow:
+ status: todo
+ comment: |
+ Be more specific in the config flow with catching exceptions.
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: todo
+ docs-installation-instructions: done
+ docs-removal-instructions: todo
+ entity-event-setup: done
+ 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: |
+ This integration does not provide additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options to configure
+ docs-installation-parameters:
+ status: exempt
+ comment: No options to configure
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: todo
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ 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: done
+ entity-disabled-by-default: todo
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: done
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: |
+ This integration does not require a websession.
+ strict-typing: todo
diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py
index 10d3008fd82..3f7d496aca9 100644
--- a/homeassistant/components/nina/binary_sensor.py
+++ b/homeassistant/components/nina/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -36,7 +36,7 @@ from .coordinator import NINADataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entries."""
diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json
index 45212c0220b..8bb9a347373 100644
--- a/homeassistant/components/nina/manifest.json
+++ b/homeassistant/components/nina/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/nina",
"iot_class": "cloud_polling",
"loggers": ["pynina"],
- "requirements": ["PyNINA==0.3.4"],
+ "requirements": ["PyNINA==0.3.5"],
"single_config_entry": true
}
diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py
index c8e7e7c25ea..afac3f06435 100644
--- a/homeassistant/components/nmap_tracker/device_tracker.py
+++ b/homeassistant/components/nmap_tracker/device_tracker.py
@@ -9,7 +9,7 @@ from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NmapDevice, NmapDeviceScanner, short_hostname, signal_device_update
from .const import DOMAIN
@@ -18,7 +18,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for Nmap Tracker component."""
nmap_tracker = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py
index 7d06baf37b6..4a2783143ca 100644
--- a/homeassistant/components/nmbs/__init__.py
+++ b/homeassistant/components/nmbs/__init__.py
@@ -8,6 +8,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@@ -22,13 +23,13 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the NMBS component."""
- api_client = iRail()
+ api_client = iRail(session=async_get_clientsession(hass))
hass.data.setdefault(DOMAIN, {})
- station_response = await hass.async_add_executor_job(api_client.get_stations)
- if station_response == -1:
+ station_response = await api_client.get_stations()
+ if station_response is None:
return False
- hass.data[DOMAIN] = station_response["station"]
+ hass.data[DOMAIN] = station_response.stations
return True
diff --git a/homeassistant/components/nmbs/config_flow.py b/homeassistant/components/nmbs/config_flow.py
index e45b2d9adeb..60ab015e22b 100644
--- a/homeassistant/components/nmbs/config_flow.py
+++ b/homeassistant/components/nmbs/config_flow.py
@@ -3,11 +3,13 @@
from typing import Any
from pyrail import iRail
+from pyrail.models import StationDetails
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import Platform
from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
BooleanSelector,
SelectOptionDict,
@@ -31,17 +33,15 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize."""
- self.api_client = iRail()
- self.stations: list[dict[str, Any]] = []
+ self.stations: list[StationDetails] = []
- async def _fetch_stations(self) -> list[dict[str, Any]]:
+ async def _fetch_stations(self) -> list[StationDetails]:
"""Fetch the stations."""
- stations_response = await self.hass.async_add_executor_job(
- self.api_client.get_stations
- )
- if stations_response == -1:
+ api_client = iRail(session=async_get_clientsession(self.hass))
+ stations_response = await api_client.get_stations()
+ if stations_response is None:
raise CannotConnect("The API is currently unavailable.")
- return stations_response["station"]
+ return stations_response.stations
async def _fetch_stations_choices(self) -> list[SelectOptionDict]:
"""Fetch the stations options."""
@@ -50,7 +50,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN):
self.stations = await self._fetch_stations()
return [
- SelectOptionDict(value=station["id"], label=station["standardname"])
+ SelectOptionDict(value=station.id, label=station.standard_name)
for station in self.stations
]
@@ -72,12 +72,12 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN):
[station_from] = [
station
for station in self.stations
- if station["id"] == user_input[CONF_STATION_FROM]
+ if station.id == user_input[CONF_STATION_FROM]
]
[station_to] = [
station
for station in self.stations
- if station["id"] == user_input[CONF_STATION_TO]
+ if station.id == user_input[CONF_STATION_TO]
]
vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS) else ""
await self.async_set_unique_id(
@@ -85,7 +85,7 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN):
)
self._abort_if_unique_id_configured()
- config_entry_name = f"Train from {station_from['standardname']} to {station_to['standardname']}"
+ config_entry_name = f"Train from {station_from.standard_name} to {station_to.standard_name}"
return self.async_create_entry(
title=config_entry_name,
data=user_input,
@@ -127,18 +127,18 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN):
station_live = None
for station in self.stations:
if user_input[CONF_STATION_FROM] in (
- station["standardname"],
- station["name"],
+ station.standard_name,
+ station.name,
):
station_from = station
if user_input[CONF_STATION_TO] in (
- station["standardname"],
- station["name"],
+ station.standard_name,
+ station.name,
):
station_to = station
if CONF_STATION_LIVE in user_input and user_input[CONF_STATION_LIVE] in (
- station["standardname"],
- station["name"],
+ station.standard_name,
+ station.name,
):
station_live = station
@@ -148,29 +148,29 @@ class NMBSConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="same_station")
# config flow uses id and not the standard name
- user_input[CONF_STATION_FROM] = station_from["id"]
- user_input[CONF_STATION_TO] = station_to["id"]
+ user_input[CONF_STATION_FROM] = station_from.id
+ user_input[CONF_STATION_TO] = station_to.id
if station_live:
- user_input[CONF_STATION_LIVE] = station_live["id"]
+ user_input[CONF_STATION_LIVE] = station_live.id
entity_registry = er.async_get(self.hass)
prefix = "live"
vias = "_excl_vias" if user_input.get(CONF_EXCLUDE_VIAS, False) else ""
if entity_id := entity_registry.async_get_entity_id(
Platform.SENSOR,
DOMAIN,
- f"{prefix}_{station_live['standardname']}_{station_from['standardname']}_{station_to['standardname']}",
+ f"{prefix}_{station_live.standard_name}_{station_from.standard_name}_{station_to.standard_name}",
):
- new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}"
+ new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}"
entity_registry.async_update_entity(
entity_id, new_unique_id=new_unique_id
)
if entity_id := entity_registry.async_get_entity_id(
Platform.SENSOR,
DOMAIN,
- f"{prefix}_{station_live['name']}_{station_from['name']}_{station_to['name']}",
+ f"{prefix}_{station_live.name}_{station_from.name}_{station_to.name}",
):
- new_unique_id = f"{DOMAIN}_{prefix}_{station_live['id']}_{station_from['id']}_{station_to['id']}{vias}"
+ new_unique_id = f"{DOMAIN}_{prefix}_{station_live.id}_{station_from.id}_{station_to.id}{vias}"
entity_registry.async_update_entity(
entity_id, new_unique_id=new_unique_id
)
diff --git a/homeassistant/components/nmbs/const.py b/homeassistant/components/nmbs/const.py
index fddb7365501..04c8beb327d 100644
--- a/homeassistant/components/nmbs/const.py
+++ b/homeassistant/components/nmbs/const.py
@@ -19,11 +19,7 @@ CONF_SHOW_ON_MAP = "show_on_map"
def find_station_by_name(hass: HomeAssistant, station_name: str):
"""Find given station_name in the station list."""
return next(
- (
- s
- for s in hass.data[DOMAIN]
- if station_name in (s["standardname"], s["name"])
- ),
+ (s for s in hass.data[DOMAIN] if station_name in (s.standard_name, s.name)),
None,
)
@@ -31,6 +27,6 @@ def find_station_by_name(hass: HomeAssistant, station_name: str):
def find_station(hass: HomeAssistant, station_name: str):
"""Find given station_id in the station list."""
return next(
- (s for s in hass.data[DOMAIN] if station_name in s["id"]),
+ (s for s in hass.data[DOMAIN] if station_name in s.id),
None,
)
diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json
index 9016eff11f8..37ff9429a54 100644
--- a/homeassistant/components/nmbs/manifest.json
+++ b/homeassistant/components/nmbs/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["pyrail"],
"quality_scale": "legacy",
- "requirements": ["pyrail==0.0.3"]
+ "requirements": ["pyrail==0.4.1"]
}
diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py
index 6d13777e10a..3552ac3c26d 100644
--- a/homeassistant/components/nmbs/sensor.py
+++ b/homeassistant/components/nmbs/sensor.py
@@ -2,10 +2,12 @@
from __future__ import annotations
+from datetime import datetime
import logging
from typing import Any
from pyrail import iRail
+from pyrail.models import ConnectionDetails, LiveboardDeparture, StationDetails
import voluptuous as vol
from homeassistant.components.sensor import (
@@ -23,7 +25,11 @@ from homeassistant.const import (
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -41,8 +47,6 @@ from .const import ( # noqa: F401
_LOGGER = logging.getLogger(__name__)
-API_FAILURE = -1
-
DEFAULT_NAME = "NMBS"
DEFAULT_ICON = "mdi:train"
@@ -60,12 +64,12 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
)
-def get_time_until(departure_time=None):
+def get_time_until(departure_time: datetime | None = None):
"""Calculate the time between now and a train's departure time."""
if departure_time is None:
return 0
- delta = dt_util.utc_from_timestamp(int(departure_time)) - dt_util.now()
+ delta = dt_util.as_utc(departure_time) - dt_util.utcnow()
return round(delta.total_seconds() / 60)
@@ -74,11 +78,9 @@ def get_delay_in_minutes(delay=0):
return round(int(delay) / 60)
-def get_ride_duration(departure_time, arrival_time, delay=0):
+def get_ride_duration(departure_time: datetime, arrival_time: datetime, delay=0):
"""Calculate the total travel time in minutes."""
- duration = dt_util.utc_from_timestamp(
- int(arrival_time)
- ) - dt_util.utc_from_timestamp(int(departure_time))
+ duration = arrival_time - departure_time
duration_time = int(round(duration.total_seconds() / 60))
return duration_time + get_delay_in_minutes(delay)
@@ -151,10 +153,10 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up NMBS sensor entities based on a config entry."""
- api_client = iRail()
+ api_client = iRail(session=async_get_clientsession(hass))
name = config_entry.data.get(CONF_NAME, None)
show_on_map = config_entry.data.get(CONF_SHOW_ON_MAP, False)
@@ -186,9 +188,9 @@ class NMBSLiveBoard(SensorEntity):
def __init__(
self,
api_client: iRail,
- live_station: dict[str, Any],
- station_from: dict[str, Any],
- station_to: dict[str, Any],
+ live_station: StationDetails,
+ station_from: StationDetails,
+ station_to: StationDetails,
excl_vias: bool,
) -> None:
"""Initialize the sensor for getting liveboard data."""
@@ -198,7 +200,8 @@ class NMBSLiveBoard(SensorEntity):
self._station_to = station_to
self._excl_vias = excl_vias
- self._attrs: dict[str, Any] | None = {}
+ self._attrs: LiveboardDeparture | None = None
+
self._state: str | None = None
self.entity_registry_enabled_default = False
@@ -206,22 +209,20 @@ class NMBSLiveBoard(SensorEntity):
@property
def name(self) -> str:
"""Return the sensor default name."""
- return f"Trains in {self._station['standardname']}"
+ return f"Trains in {self._station.standard_name}"
@property
def unique_id(self) -> str:
"""Return the unique ID."""
- unique_id = (
- f"{self._station['id']}_{self._station_from['id']}_{self._station_to['id']}"
- )
+ unique_id = f"{self._station.id}_{self._station_from.id}_{self._station_to.id}"
vias = "_excl_vias" if self._excl_vias else ""
return f"nmbs_live_{unique_id}{vias}"
@property
def icon(self) -> str:
"""Return the default icon or an alert icon if delays."""
- if self._attrs and int(self._attrs["delay"]) > 0:
+ if self._attrs and int(self._attrs.delay) > 0:
return DEFAULT_ICON_ALERT
return DEFAULT_ICON
@@ -237,15 +238,15 @@ class NMBSLiveBoard(SensorEntity):
if self._state is None or not self._attrs:
return None
- delay = get_delay_in_minutes(self._attrs["delay"])
- departure = get_time_until(self._attrs["time"])
+ delay = get_delay_in_minutes(self._attrs.delay)
+ departure = get_time_until(self._attrs.time)
attrs = {
"departure": f"In {departure} minutes",
"departure_minutes": departure,
- "extra_train": int(self._attrs["isExtra"]) > 0,
- "vehicle_id": self._attrs["vehicle"],
- "monitored_station": self._station["standardname"],
+ "extra_train": self._attrs.is_extra,
+ "vehicle_id": self._attrs.vehicle,
+ "monitored_station": self._station.standard_name,
}
if delay > 0:
@@ -254,28 +255,26 @@ class NMBSLiveBoard(SensorEntity):
return attrs
- def update(self) -> None:
+ async def async_update(self, **kwargs: Any) -> None:
"""Set the state equal to the next departure."""
- liveboard = self._api_client.get_liveboard(self._station["id"])
+ liveboard = await self._api_client.get_liveboard(self._station.id)
- if liveboard == API_FAILURE:
+ if liveboard is None:
_LOGGER.warning("API failed in NMBSLiveBoard")
return
- if not (departures := liveboard.get("departures")):
+ if not (departures := liveboard.departures):
_LOGGER.warning("API returned invalid departures: %r", liveboard)
return
_LOGGER.debug("API returned departures: %r", departures)
- if departures["number"] == "0":
+ if len(departures) == 0:
# No trains are scheduled
return
- next_departure = departures["departure"][0]
+ next_departure = departures[0]
self._attrs = next_departure
- self._state = (
- f"Track {next_departure['platform']} - {next_departure['station']}"
- )
+ self._state = f"Track {next_departure.platform} - {next_departure.station}"
class NMBSSensor(SensorEntity):
@@ -289,8 +288,8 @@ class NMBSSensor(SensorEntity):
api_client: iRail,
name: str,
show_on_map: bool,
- station_from: dict[str, Any],
- station_to: dict[str, Any],
+ station_from: StationDetails,
+ station_to: StationDetails,
excl_vias: bool,
) -> None:
"""Initialize the NMBS connection sensor."""
@@ -301,13 +300,13 @@ class NMBSSensor(SensorEntity):
self._station_to = station_to
self._excl_vias = excl_vias
- self._attrs: dict[str, Any] | None = {}
+ self._attrs: ConnectionDetails | None = None
self._state = None
@property
def unique_id(self) -> str:
"""Return the unique ID."""
- unique_id = f"{self._station_from['id']}_{self._station_to['id']}"
+ unique_id = f"{self._station_from.id}_{self._station_to.id}"
vias = "_excl_vias" if self._excl_vias else ""
return f"nmbs_connection_{unique_id}{vias}"
@@ -316,14 +315,14 @@ class NMBSSensor(SensorEntity):
def name(self) -> str:
"""Return the name of the sensor."""
if self._name is None:
- return f"Train from {self._station_from['standardname']} to {self._station_to['standardname']}"
+ return f"Train from {self._station_from.standard_name} to {self._station_to.standard_name}"
return self._name
@property
def icon(self) -> str:
"""Return the sensor default icon or an alert icon if any delay."""
if self._attrs:
- delay = get_delay_in_minutes(self._attrs["departure"]["delay"])
+ delay = get_delay_in_minutes(self._attrs.departure.delay)
if delay > 0:
return "mdi:alert-octagon"
@@ -335,19 +334,19 @@ class NMBSSensor(SensorEntity):
if self._state is None or not self._attrs:
return None
- delay = get_delay_in_minutes(self._attrs["departure"]["delay"])
- departure = get_time_until(self._attrs["departure"]["time"])
- canceled = int(self._attrs["departure"]["canceled"])
+ delay = get_delay_in_minutes(self._attrs.departure.delay)
+ departure = get_time_until(self._attrs.departure.time)
+ canceled = self._attrs.departure.canceled
attrs = {
- "destination": self._attrs["departure"]["station"],
- "direction": self._attrs["departure"]["direction"]["name"],
- "platform_arriving": self._attrs["arrival"]["platform"],
- "platform_departing": self._attrs["departure"]["platform"],
- "vehicle_id": self._attrs["departure"]["vehicle"],
+ "destination": self._attrs.departure.station,
+ "direction": self._attrs.departure.direction.name,
+ "platform_arriving": self._attrs.arrival.platform,
+ "platform_departing": self._attrs.departure.platform,
+ "vehicle_id": self._attrs.departure.vehicle,
}
- if canceled != 1:
+ if not canceled:
attrs["departure"] = f"In {departure} minutes"
attrs["departure_minutes"] = departure
attrs["canceled"] = False
@@ -361,14 +360,14 @@ class NMBSSensor(SensorEntity):
attrs[ATTR_LONGITUDE] = self.station_coordinates[1]
if self.is_via_connection and not self._excl_vias:
- via = self._attrs["vias"]["via"][0]
+ via = self._attrs.vias[0]
- attrs["via"] = via["station"]
- attrs["via_arrival_platform"] = via["arrival"]["platform"]
- attrs["via_transfer_platform"] = via["departure"]["platform"]
+ attrs["via"] = via.station
+ attrs["via_arrival_platform"] = via.arrival.platform
+ attrs["via_transfer_platform"] = via.departure.platform
attrs["via_transfer_time"] = get_delay_in_minutes(
- via["timebetween"]
- ) + get_delay_in_minutes(via["departure"]["delay"])
+ via.timebetween
+ ) + get_delay_in_minutes(via.departure.delay)
if delay > 0:
attrs["delay"] = f"{delay} minutes"
@@ -387,8 +386,8 @@ class NMBSSensor(SensorEntity):
if self._state is None or not self._attrs:
return []
- latitude = float(self._attrs["departure"]["stationinfo"]["locationY"])
- longitude = float(self._attrs["departure"]["stationinfo"]["locationX"])
+ latitude = float(self._attrs.departure.station_info.latitude)
+ longitude = float(self._attrs.departure.station_info.longitude)
return [latitude, longitude]
@property
@@ -397,24 +396,24 @@ class NMBSSensor(SensorEntity):
if not self._attrs:
return False
- return "vias" in self._attrs and int(self._attrs["vias"]["number"]) > 0
+ return self._attrs.vias is not None and len(self._attrs.vias) > 0
- def update(self) -> None:
+ async def async_update(self, **kwargs: Any) -> None:
"""Set the state to the duration of a connection."""
- connections = self._api_client.get_connections(
- self._station_from["id"], self._station_to["id"]
+ connections = await self._api_client.get_connections(
+ self._station_from.id, self._station_to.id
)
- if connections == API_FAILURE:
+ if connections is None:
_LOGGER.warning("API failed in NMBSSensor")
return
- if not (connection := connections.get("connection")):
+ if not (connection := connections.connections):
_LOGGER.warning("API returned invalid connection: %r", connections)
return
_LOGGER.debug("API returned connection: %r", connection)
- if int(connection[0]["departure"]["left"]) > 0:
+ if connection[0].departure.left:
next_connection = connection[1]
else:
next_connection = connection[0]
@@ -428,9 +427,9 @@ class NMBSSensor(SensorEntity):
return
duration = get_ride_duration(
- next_connection["departure"]["time"],
- next_connection["arrival"]["time"],
- next_connection["departure"]["delay"],
+ next_connection.departure.time,
+ next_connection.arrival.time,
+ next_connection.departure.delay,
)
self._state = duration
diff --git a/homeassistant/components/nmbs/strings.json b/homeassistant/components/nmbs/strings.json
index 3e7aa8d05bd..ac11026577a 100644
--- a/homeassistant/components/nmbs/strings.json
+++ b/homeassistant/components/nmbs/strings.json
@@ -29,7 +29,7 @@
"issues": {
"deprecated_yaml_import_issue_station_not_found": {
"title": "The {integration_title} YAML configuration import failed",
- "description": "Configuring {integration_title} using YAML is being removed but there was an problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
+ "description": "Configuring {integration_title} using YAML is being removed but there was a problem importing your YAML configuration.\n\nThe used station \"{station_name}\" could not be found. Fix it or remove the {integration_title} YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
}
}
}
diff --git a/homeassistant/components/noaa_tides/helpers.py b/homeassistant/components/noaa_tides/helpers.py
new file mode 100644
index 00000000000..734cca68f44
--- /dev/null
+++ b/homeassistant/components/noaa_tides/helpers.py
@@ -0,0 +1,6 @@
+"""Helpers for NOAA Tides integration."""
+
+
+def get_station_unique_id(station_id: str) -> str:
+ """Convert a station ID to a unique ID."""
+ return f"{station_id.lower()}"
diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py
index 0af2c340960..3b5a13b0f15 100644
--- a/homeassistant/components/noaa_tides/sensor.py
+++ b/homeassistant/components/noaa_tides/sensor.py
@@ -22,6 +22,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util.unit_system import METRIC_SYSTEM
+from .helpers import get_station_unique_id
+
if TYPE_CHECKING:
from pandas import Timestamp
@@ -105,6 +107,7 @@ class NOAATidesAndCurrentsSensor(SensorEntity):
self._unit_system = unit_system
self._station = station
self.data: NOAATidesData | None = None
+ self._attr_unique_id = f"{get_station_unique_id(station_id)}_summary"
@property
def name(self) -> str:
diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py
index a089209cde5..771da420213 100644
--- a/homeassistant/components/nobo_hub/climate.py
+++ b/homeassistant/components/nobo_hub/climate.py
@@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_NAME, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import (
@@ -46,7 +46,7 @@ MAX_TEMPERATURE = 40
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Nobø Ecohub platform from UI configuration."""
diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py
index 43f177dd7a0..c24dbe3d21d 100644
--- a/homeassistant/components/nobo_hub/select.py
+++ b/homeassistant/components/nobo_hub/select.py
@@ -10,7 +10,7 @@ from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_HARDWARE_VERSION,
@@ -26,7 +26,7 @@ from .const import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up any temperature sensors connected to the Nobø Ecohub."""
diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py
index 1632b6ba5e7..382fd1b0bf4 100644
--- a/homeassistant/components/nobo_hub/sensor.py
+++ b/homeassistant/components/nobo_hub/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MODEL, ATTR_NAME, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import ATTR_SERIAL, ATTR_ZONE_ID, DOMAIN, NOBO_MANUFACTURER
@@ -22,7 +22,7 @@ from .const import ATTR_SERIAL, ATTR_ZONE_ID, DOMAIN, NOBO_MANUFACTURER
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up any temperature sensors connected to the Nobø Ecohub."""
diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json
index 28be01862e9..5d1b8350edf 100644
--- a/homeassistant/components/nobo_hub/strings.json
+++ b/homeassistant/components/nobo_hub/strings.json
@@ -44,16 +44,16 @@
"entity": {
"select": {
"global_override": {
- "name": "global override",
+ "name": "Global override",
"state": {
- "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
+ "away": "[%key:common::state::not_home%]",
"comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]",
"eco": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::eco%]",
"none": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::none%]"
}
},
"week_profile": {
- "name": "week profile"
+ "name": "Week profile"
}
}
}
diff --git a/homeassistant/components/nordpool/sensor.py b/homeassistant/components/nordpool/sensor.py
index 30910f8e5f6..4bde12afc3c 100644
--- a/homeassistant/components/nordpool/sensor.py
+++ b/homeassistant/components/nordpool/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
from . import NordPoolConfigEntry
@@ -34,7 +34,7 @@ def validate_prices(
index: int,
) -> float | None:
"""Validate and return."""
- if result := func(entity)[area][index]:
+ if (result := func(entity)[area][index]) is not None:
return result / 1000
return None
@@ -271,7 +271,7 @@ DAILY_AVERAGE_PRICES_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: NordPoolConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Nord Pool sensor platform."""
diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json
index cc10a1a0640..7b33f032de1 100644
--- a/homeassistant/components/nordpool/strings.json
+++ b/homeassistant/components/nordpool/strings.json
@@ -15,7 +15,7 @@
},
"data_description": {
"currency": "Select currency to display prices in, EUR is the base currency.",
- "areas": "Areas to display prices for according to Nordpool market areas."
+ "areas": "Areas to display prices for according to Nord Pool market areas."
}
},
"reconfigure": {
@@ -95,11 +95,11 @@
"services": {
"get_prices_for_date": {
"name": "Get prices for date",
- "description": "Retrieve the prices for a specific date.",
+ "description": "Retrieves the prices for a specific date.",
"fields": {
"config_entry": {
- "name": "Select Nord Pool configuration entry",
- "description": "Choose the configuration entry."
+ "name": "Config entry",
+ "description": "The Nord Pool configuration entry for this action."
},
"date": {
"name": "Date",
diff --git a/homeassistant/components/notify/strings.json b/homeassistant/components/notify/strings.json
index e832bfc248a..b33af360448 100644
--- a/homeassistant/components/notify/strings.json
+++ b/homeassistant/components/notify/strings.json
@@ -24,7 +24,7 @@
},
"data": {
"name": "Data",
- "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation."
+ "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation."
}
}
},
@@ -56,7 +56,7 @@
},
"data": {
"name": "Data",
- "description": "Some integrations provide extended functionality. For information on how to use _data_, refer to the integration documentation.."
+ "description": "Some integrations provide extended functionality via this field. For more information, refer to the integration documentation."
}
}
}
diff --git a/homeassistant/components/notion/binary_sensor.py b/homeassistant/components/notion/binary_sensor.py
index 8c57310752a..5552305e867 100644
--- a/homeassistant/components/notion/binary_sensor.py
+++ b/homeassistant/components/notion/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
@@ -107,7 +107,9 @@ BINARY_SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Notion sensors based on a config entry."""
coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/notion/sensor.py b/homeassistant/components/notion/sensor.py
index fb853e65d7d..24496c8391a 100644
--- a/homeassistant/components/notion/sensor.py
+++ b/homeassistant/components/notion/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, SENSOR_MOLD, SENSOR_TEMPERATURE
from .coordinator import NotionDataUpdateCoordinator
@@ -42,7 +42,9 @@ SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Notion sensors based on a config entry."""
coordinator: NotionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py
index 8248c1b9b82..376a07ddb7b 100644
--- a/homeassistant/components/nuheat/climate.py
+++ b/homeassistant/components/nuheat/climate.py
@@ -23,7 +23,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import event as event_helper
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, NUHEAT_API_STATE_SHIFT_DELAY
@@ -55,7 +55,7 @@ SCHEDULE_MODE_TO_PRESET_MODE_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the NuHeat thermostat(s)."""
thermostat, coordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py
index 8269c43813e..2785c46ca17 100644
--- a/homeassistant/components/nuki/binary_sensor.py
+++ b/homeassistant/components/nuki/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NukiEntryData
from .const import DOMAIN as NUKI_DOMAIN
@@ -20,7 +20,9 @@ from .entity import NukiEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Nuki binary sensors."""
entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py
index a2bf7559fc4..3cc972d3555 100644
--- a/homeassistant/components/nuki/lock.py
+++ b/homeassistant/components/nuki/lock.py
@@ -15,7 +15,7 @@ from homeassistant.components.lock import LockEntity, LockEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NukiEntryData
from .const import ATTR_ENABLE, ATTR_UNLATCH, DOMAIN as NUKI_DOMAIN, ERROR_STATES
@@ -24,7 +24,9 @@ from .helpers import CannotConnect
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Nuki lock platform."""
entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py
index d89202ac7d7..4f3890a10cf 100644
--- a/homeassistant/components/nuki/sensor.py
+++ b/homeassistant/components/nuki/sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NukiEntryData
from .const import DOMAIN as NUKI_DOMAIN
@@ -16,7 +16,9 @@ from .entity import NukiEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Nuki lock sensor."""
entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/nuki/strings.json b/homeassistant/components/nuki/strings.json
index beac3cb7f74..84e66c3db96 100644
--- a/homeassistant/components/nuki/strings.json
+++ b/homeassistant/components/nuki/strings.json
@@ -48,8 +48,8 @@
"state_attributes": {
"battery_critical": {
"state": {
- "on": "[%key:component::binary_sensor::entity_component::battery::state::on%]",
- "off": "[%key:component::binary_sensor::entity_component::battery::state::off%]"
+ "on": "[%key:common::state::low%]",
+ "off": "[%key:common::state::normal%]"
}
}
}
@@ -58,12 +58,12 @@
},
"services": {
"lock_n_go": {
- "name": "Lock 'n' go",
- "description": "Nuki Lock 'n' Go.",
+ "name": "Lock 'n' Go",
+ "description": "Unlocks the door, waits a few seconds then re-locks. The wait period can be customized through the app.",
"fields": {
"unlatch": {
"name": "Unlatch",
- "description": "Whether to unlatch the lock."
+ "description": "Whether to also unlatch the door when unlocking it."
}
}
},
diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py
index bdde3a4567e..280edb819d4 100644
--- a/homeassistant/components/number/const.py
+++ b/homeassistant/components/number/const.py
@@ -11,6 +11,7 @@ from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
+ DEGREE,
LIGHT_LUX,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
@@ -158,7 +159,7 @@ class NumberDeviceClass(StrEnum):
DURATION = "duration"
"""Fixed duration.
- Unit of measurement: `d`, `h`, `min`, `s`, `ms`
+ Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs`
"""
ENERGY = "energy"
@@ -322,7 +323,7 @@ class NumberDeviceClass(StrEnum):
REACTIVE_POWER = "reactive_power"
"""Reactive power.
- Unit of measurement: `var`
+ Unit of measurement: `var`, `kvar`
"""
SIGNAL_STRENGTH = "signal_strength"
@@ -424,6 +425,12 @@ class NumberDeviceClass(StrEnum):
- USCS / imperial: `oz`, `lb`
"""
+ WIND_DIRECTION = "wind_direction"
+ """Wind direction.
+
+ Unit of measurement: `°`
+ """
+
WIND_SPEED = "wind_speed"
"""Wind speed.
@@ -455,6 +462,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
UnitOfTime.MINUTES,
UnitOfTime.SECONDS,
UnitOfTime.MILLISECONDS,
+ UnitOfTime.MICROSECONDS,
},
NumberDeviceClass.ENERGY: set(UnitOfEnergy),
NumberDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance),
@@ -479,6 +487,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
NumberDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
NumberDeviceClass.POWER: {
+ UnitOfPower.MILLIWATT,
UnitOfPower.WATT,
UnitOfPower.KILO_WATT,
UnitOfPower.MEGA_WATT,
@@ -488,7 +497,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
NumberDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth),
NumberDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux),
NumberDeviceClass.PRESSURE: set(UnitOfPressure),
- NumberDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE},
+ NumberDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower),
NumberDeviceClass.SIGNAL_STRENGTH: {
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
@@ -516,6 +525,7 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = {
UnitOfVolume.LITERS,
},
NumberDeviceClass.WEIGHT: set(UnitOfMass),
+ NumberDeviceClass.WIND_DIRECTION: {DEGREE},
NumberDeviceClass.WIND_SPEED: set(UnitOfSpeed),
}
diff --git a/homeassistant/components/number/icons.json b/homeassistant/components/number/icons.json
index 636fa0a7751..49103f5cd41 100644
--- a/homeassistant/components/number/icons.json
+++ b/homeassistant/components/number/icons.json
@@ -150,6 +150,9 @@
"weight": {
"default": "mdi:weight"
},
+ "wind_direction": {
+ "default": "mdi:compass-rose"
+ },
"wind_speed": {
"default": "mdi:weather-windy"
}
diff --git a/homeassistant/components/number/services.yaml b/homeassistant/components/number/services.yaml
index dcbb955d739..6a7083a7613 100644
--- a/homeassistant/components/number/services.yaml
+++ b/homeassistant/components/number/services.yaml
@@ -7,5 +7,6 @@ set_value:
fields:
value:
example: 42
+ required: true
selector:
text:
diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json
index cc77d224d72..993120ef3ad 100644
--- a/homeassistant/components/number/strings.json
+++ b/homeassistant/components/number/strings.json
@@ -169,6 +169,9 @@
"weight": {
"name": "[%key:component::sensor::entity_component::weight::name%]"
},
+ "wind_direction": {
+ "name": "[%key:component::sensor::entity_component::wind_direction::name%]"
+ },
"wind_speed": {
"name": "[%key:component::sensor::entity_component::wind_speed::name%]"
}
diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py
index 169dbbbff5d..9e1e77a2aaf 100644
--- a/homeassistant/components/nut/__init__.py
+++ b/homeassistant/components/nut/__init__.py
@@ -23,14 +23,10 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import (
- DEFAULT_SCAN_INTERVAL,
- DOMAIN,
- INTEGRATION_SUPPORTED_COMMANDS,
- PLATFORMS,
-)
+from .const import DOMAIN, INTEGRATION_SUPPORTED_COMMANDS, PLATFORMS
NUT_FAKE_SERIAL = ["unknown", "blank"]
@@ -68,7 +64,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
alias = config.get(CONF_ALIAS)
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
- scan_interval = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
+ if CONF_SCAN_INTERVAL in entry.options:
+ current_options = {**entry.options}
+ current_options.pop(CONF_SCAN_INTERVAL)
+ hass.config_entries.async_update_entry(entry, options=current_options)
data = PyNUTData(host, port, alias, username, password)
@@ -79,9 +78,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
try:
return await data.async_update()
except NUTLoginError as err:
- raise ConfigEntryAuthFailed from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="device_authentication",
+ translation_placeholders={
+ "err": str(err),
+ },
+ ) from err
except NUTError as err:
- raise UpdateFailed(f"Error fetching UPS state: {err}") from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="data_fetch_error",
+ translation_placeholders={
+ "err": str(err),
+ },
+ ) from err
coordinator = DataUpdateCoordinator(
hass,
@@ -89,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
config_entry=entry,
name="NUT resource status",
update_method=async_update_data,
- update_interval=timedelta(seconds=scan_interval),
+ update_interval=timedelta(seconds=60),
always_update=False,
)
@@ -103,30 +114,58 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
)
status = coordinator.data
- _LOGGER.debug("NUT Sensors Available: %s", status)
+ _LOGGER.debug("NUT Sensors Available: %s", status if status else None)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
unique_id = _unique_id_from_status(status)
if unique_id is None:
unique_id = entry.entry_id
+ elif entry.unique_id is None:
+ hass.config_entries.async_update_entry(entry, unique_id=unique_id)
+
if username is not None and password is not None:
+ # Dynamically add outlet integration commands
+ additional_integration_commands = set()
+ if (num_outlets := status.get("outlet.count")) is not None:
+ for outlet_num in range(1, int(num_outlets) + 1):
+ outlet_num_str: str = str(outlet_num)
+ additional_integration_commands |= {
+ f"outlet.{outlet_num_str}.load.cycle",
+ f"outlet.{outlet_num_str}.load.on",
+ f"outlet.{outlet_num_str}.load.off",
+ }
+
+ valid_integration_commands = (
+ INTEGRATION_SUPPORTED_COMMANDS | additional_integration_commands
+ )
+
user_available_commands = {
- device_supported_command
- for device_supported_command in await data.async_list_commands() or {}
- if device_supported_command in INTEGRATION_SUPPORTED_COMMANDS
+ device_command
+ for device_command in await data.async_list_commands() or {}
+ if device_command in valid_integration_commands
}
else:
user_available_commands = set()
+ _LOGGER.debug(
+ "NUT Commands Available: %s",
+ user_available_commands if user_available_commands else None,
+ )
+
entry.runtime_data = NutRuntimeData(
coordinator, data, unique_id, user_available_commands
)
+ connections: set[tuple[str, str]] | None = None
+ if data.device_info.mac_address is not None:
+ connections = {(CONNECTION_NETWORK_MAC, data.device_info.mac_address)}
+
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, unique_id)},
+ connections=connections,
name=data.name.title(),
manufacturer=data.device_info.manufacturer,
model=data.device_info.model,
@@ -141,12 +180,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: NutConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def _async_update_listener(hass: HomeAssistant, entry: NutConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
@@ -214,6 +253,7 @@ class NUTDeviceInfo:
model_id: str | None = None
firmware: str | None = None
serial: str | None = None
+ mac_address: str | None = None
device_location: str | None = None
@@ -240,6 +280,7 @@ class PyNUTData:
self._client = AIONUTClient(self._host, port, username, password, 5, persistent)
self.ups_list: dict[str, str] | None = None
+ self.device_name: str | None = None
self._status: dict[str, str] | None = None
self._device_info: NUTDeviceInfo | None = None
@@ -250,7 +291,7 @@ class PyNUTData:
@property
def name(self) -> str:
- """Return the name of the ups."""
+ """Return the name of the NUT device."""
return self._alias or f"Nut-{self._host}"
@property
@@ -276,9 +317,18 @@ class PyNUTData:
model_id: str | None = self._status.get("device.part")
firmware = _firmware_from_status(self._status)
serial = _serial_from_status(self._status)
+ mac_address: str | None = self._status.get("device.macaddr")
+ if mac_address is not None:
+ mac_address = format_mac(mac_address.rstrip().replace(" ", ":"))
device_location: str | None = self._status.get("device.location")
return NUTDeviceInfo(
- manufacturer, model, model_id, firmware, serial, device_location
+ manufacturer,
+ model,
+ model_id,
+ firmware,
+ serial,
+ mac_address,
+ device_location,
)
async def _async_get_status(self) -> dict[str, str]:
@@ -294,6 +344,8 @@ class PyNUTData:
self._status = await self._async_get_status()
if self._device_info is None:
self._device_info = self._get_device_info()
+ if self.device_name is None:
+ self.device_name = self.name.title()
return self._status
async def async_run_command(self, command_name: str) -> None:
@@ -305,7 +357,12 @@ class PyNUTData:
await self._client.run_command(self._alias, command_name)
except NUTError as err:
raise HomeAssistantError(
- f"Error running command {command_name}, {err}"
+ translation_domain=DOMAIN,
+ translation_key="nut_command_error",
+ translation_placeholders={
+ "command_name": command_name,
+ "err": str(err),
+ },
) from err
async def async_list_commands(self) -> set[str] | None:
diff --git a/homeassistant/components/nut/button.py b/homeassistant/components/nut/button.py
new file mode 100644
index 00000000000..0708056b2e3
--- /dev/null
+++ b/homeassistant/components/nut/button.py
@@ -0,0 +1,67 @@
+"""Provides a switch for switchable NUT outlets."""
+
+from __future__ import annotations
+
+import logging
+
+from homeassistant.components.button import (
+ ButtonDeviceClass,
+ ButtonEntity,
+ ButtonEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import NutConfigEntry
+from .entity import NUTBaseEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: NutConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the NUT buttons."""
+ pynut_data = config_entry.runtime_data
+ coordinator = pynut_data.coordinator
+ status = coordinator.data
+
+ # Dynamically add outlet button types
+ if (num_outlets := status.get("outlet.count")) is None:
+ return
+
+ data = pynut_data.data
+ unique_id = pynut_data.unique_id
+ valid_button_types: dict[str, ButtonEntityDescription] = {}
+ for outlet_num in range(1, int(num_outlets) + 1):
+ outlet_num_str = str(outlet_num)
+ outlet_name: str = status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str
+ valid_button_types |= {
+ f"outlet.{outlet_num_str}.load.cycle": ButtonEntityDescription(
+ key=f"outlet.{outlet_num_str}.load.cycle",
+ translation_key="outlet_number_load_cycle",
+ translation_placeholders={"outlet_name": outlet_name},
+ device_class=ButtonDeviceClass.RESTART,
+ entity_registry_enabled_default=True,
+ ),
+ }
+
+ async_add_entities(
+ NUTButton(coordinator, description, data, unique_id)
+ for button_id, description in valid_button_types.items()
+ if button_id in pynut_data.user_available_commands
+ )
+
+
+class NUTButton(NUTBaseEntity, ButtonEntity):
+ """Representation of a button entity for NUT."""
+
+ async def async_press(self) -> None:
+ """Press the button."""
+ name_list = self.entity_description.key.split(".")
+ command_name = f"{name_list[0]}.{name_list[1]}.load.cycle"
+ await self.pynut_data.async_run_command(command_name)
diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py
index b1b44966d14..a69d898ff6c 100644
--- a/homeassistant/components/nut/config_flow.py
+++ b/homeassistant/components/nut/config_flow.py
@@ -4,45 +4,50 @@ from __future__ import annotations
from collections.abc import Mapping
import logging
+from types import MappingProxyType
from typing import Any
from aionut import NUTError, NUTLoginError
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult
from homeassistant.const import (
CONF_ALIAS,
CONF_BASE,
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
- CONF_SCAN_INTERVAL,
CONF_USERNAME,
)
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
-from . import PyNUTData
-from .const import DEFAULT_HOST, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN
+from . import PyNUTData, _unique_id_from_status
+from .const import DEFAULT_HOST, DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
-AUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str}
+REAUTH_SCHEMA = {vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str}
+
+PASSWORD_NOT_CHANGED = "__**password_not_changed**__"
-def _base_schema(nut_config: dict[str, Any]) -> vol.Schema:
+def _base_schema(
+ nut_config: dict[str, Any] | MappingProxyType[str, Any],
+ use_password_not_changed: bool = False,
+) -> vol.Schema:
"""Generate base schema."""
base_schema = {
vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str,
vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int,
+ vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str,
+ vol.Optional(
+ CONF_PASSWORD,
+ default=PASSWORD_NOT_CHANGED if use_password_not_changed else "",
+ ): str,
}
- base_schema.update(AUTH_SCHEMA)
+
return vol.Schema(base_schema)
@@ -72,6 +77,26 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str,
return {"ups_list": nut_data.ups_list, "available_resources": status}
+def _check_host_port_alias_match(
+ first: Mapping[str, Any], second: Mapping[str, Any]
+) -> bool:
+ """Check if first and second have the same host, port and alias."""
+
+ if first[CONF_HOST] != second[CONF_HOST] or first[CONF_PORT] != second[CONF_PORT]:
+ return False
+
+ first_alias = first.get(CONF_ALIAS)
+ second_alias = second.get(CONF_ALIAS)
+ if (first_alias is None and second_alias is None) or (
+ first_alias is not None
+ and second_alias is not None
+ and first_alias == second_alias
+ ):
+ return True
+
+ return False
+
+
def _format_host_port_alias(user_input: Mapping[str, Any]) -> str:
"""Format a host, port, and alias so it can be used for comparison or display."""
host = user_input[CONF_HOST]
@@ -125,6 +150,11 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
if self._host_port_alias_already_configured(nut_config):
return self.async_abort(reason="already_configured")
+
+ if unique_id := _unique_id_from_status(info["available_resources"]):
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
+
title = _format_host_port_alias(nut_config)
return self.async_create_entry(title=title, data=nut_config)
@@ -138,7 +168,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_ups(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle the picking the ups."""
+ """Handle selecting the NUT device alias."""
errors: dict[str, str] = {}
placeholders: dict[str, str] = {}
nut_config = self.nut_config
@@ -147,8 +177,13 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
self.nut_config.update(user_input)
if self._host_port_alias_already_configured(nut_config):
return self.async_abort(reason="already_configured")
- _, errors, placeholders = await self._async_validate_or_error(nut_config)
+
+ info, errors, placeholders = await self._async_validate_or_error(nut_config)
if not errors:
+ if unique_id := _unique_id_from_status(info["available_resources"]):
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
+
title = _format_host_port_alias(nut_config)
return self.async_create_entry(title=title, data=nut_config)
@@ -159,6 +194,99 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders=placeholders,
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the integration."""
+
+ errors: dict[str, str] = {}
+ placeholders: dict[str, str] = {}
+ reconfigure_entry = self._get_reconfigure_entry()
+ nut_config = self.nut_config
+
+ if user_input is not None:
+ nut_config.update(user_input)
+
+ info, errors, placeholders = await self._async_validate_or_error(nut_config)
+
+ if not errors:
+ if len(info["ups_list"]) > 1:
+ self.ups_list = info["ups_list"]
+ return await self.async_step_reconfigure_ups()
+
+ if not _check_host_port_alias_match(
+ reconfigure_entry.data,
+ nut_config,
+ ) and (self._host_port_alias_already_configured(nut_config)):
+ return self.async_abort(reason="already_configured")
+
+ if unique_id := _unique_id_from_status(info["available_resources"]):
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_mismatch(reason="unique_id_mismatch")
+ if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED:
+ nut_config.pop(CONF_PASSWORD)
+
+ new_title = _format_host_port_alias(nut_config)
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(),
+ unique_id=unique_id,
+ title=new_title,
+ data_updates=nut_config,
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=_base_schema(
+ reconfigure_entry.data,
+ use_password_not_changed=True,
+ ),
+ errors=errors,
+ description_placeholders=placeholders,
+ )
+
+ async def async_step_reconfigure_ups(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle selecting the NUT device alias."""
+
+ errors: dict[str, str] = {}
+ placeholders: dict[str, str] = {}
+ reconfigure_entry = self._get_reconfigure_entry()
+ nut_config = self.nut_config
+
+ if user_input is not None:
+ self.nut_config.update(user_input)
+
+ if not _check_host_port_alias_match(
+ reconfigure_entry.data,
+ nut_config,
+ ) and (self._host_port_alias_already_configured(nut_config)):
+ return self.async_abort(reason="already_configured")
+
+ info, errors, placeholders = await self._async_validate_or_error(nut_config)
+ if not errors:
+ if unique_id := _unique_id_from_status(info["available_resources"]):
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_mismatch(reason="unique_id_mismatch")
+
+ if nut_config[CONF_PASSWORD] == PASSWORD_NOT_CHANGED:
+ nut_config.pop(CONF_PASSWORD)
+
+ new_title = _format_host_port_alias(nut_config)
+ return self.async_update_reload_and_abort(
+ self._get_reconfigure_entry(),
+ unique_id=unique_id,
+ title=new_title,
+ data_updates=nut_config,
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure_ups",
+ data_schema=_ups_schema(self.ups_list or {}),
+ errors=errors,
+ description_placeholders=placeholders,
+ )
+
def _host_port_alias_already_configured(self, user_input: dict[str, Any]) -> bool:
"""See if we already have a nut entry matching user input configured."""
existing_host_port_aliases = {
@@ -200,6 +328,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth input."""
+
errors: dict[str, str] = {}
existing_entry = self.reauth_entry
assert existing_entry
@@ -208,6 +337,7 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: existing_data[CONF_HOST],
CONF_PORT: existing_data[CONF_PORT],
}
+
if user_input is not None:
new_config = {
**existing_data,
@@ -225,37 +355,8 @@ class NutConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders.update(placeholders)
return self.async_show_form(
- description_placeholders=description_placeholders,
step_id="reauth_confirm",
- data_schema=vol.Schema(AUTH_SCHEMA),
+ data_schema=vol.Schema(REAUTH_SCHEMA),
errors=errors,
+ description_placeholders=description_placeholders,
)
-
- @staticmethod
- @callback
- def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
- """Get the options flow for this handler."""
- return OptionsFlowHandler()
-
-
-class OptionsFlowHandler(OptionsFlow):
- """Handle a option flow for nut."""
-
- async def async_step_init(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle options flow."""
- if user_input is not None:
- return self.async_create_entry(title="", data=user_input)
-
- scan_interval = self.config_entry.options.get(
- CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
- )
-
- base_schema = {
- vol.Optional(CONF_SCAN_INTERVAL, default=scan_interval): vol.All(
- vol.Coerce(int), vol.Clamp(min=10, max=300)
- )
- }
-
- return self.async_show_form(step_id="init", data_schema=vol.Schema(base_schema))
diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py
index 6db40a910a0..175e971a12a 100644
--- a/homeassistant/components/nut/const.py
+++ b/homeassistant/components/nut/const.py
@@ -6,7 +6,11 @@ from homeassistant.const import Platform
DOMAIN = "nut"
-PLATFORMS = [Platform.SENSOR]
+PLATFORMS = [
+ Platform.BUTTON,
+ Platform.SENSOR,
+ Platform.SWITCH,
+]
DEFAULT_NAME = "NUT UPS"
DEFAULT_HOST = "localhost"
@@ -15,8 +19,6 @@ DEFAULT_PORT = 3493
KEY_STATUS = "ups.status"
KEY_STATUS_DISPLAY = "ups.status.display"
-DEFAULT_SCAN_INTERVAL = 60
-
STATE_TYPES = {
"OL": "Online",
"OB": "On Battery",
diff --git a/homeassistant/components/nut/device_action.py b/homeassistant/components/nut/device_action.py
index ffaa195deaf..86f7fe5a7e6 100644
--- a/homeassistant/components/nut/device_action.py
+++ b/homeassistant/components/nut/device_action.py
@@ -51,7 +51,11 @@ async def async_call_action_from_config(
runtime_data = _get_runtime_data_from_device_id(hass, device_id)
if not runtime_data:
raise InvalidDeviceAutomationConfig(
- f"Unable to find a NUT device with id {device_id}"
+ translation_domain=DOMAIN,
+ translation_key="device_invalid",
+ translation_placeholders={
+ "device_id": device_id,
+ },
)
await runtime_data.data.async_run_command(command_name)
diff --git a/homeassistant/components/nut/diagnostics.py b/homeassistant/components/nut/diagnostics.py
index 532e4ece76b..ec59fa65c22 100644
--- a/homeassistant/components/nut/diagnostics.py
+++ b/homeassistant/components/nut/diagnostics.py
@@ -39,8 +39,8 @@ async def async_get_config_entry_diagnostics(
hass_device = device_registry.async_get_device(
identifiers={(DOMAIN, hass_data.unique_id)}
)
- if not hass_device:
- return data
+ # Device is always created
+ assert hass_device is not None
data["device"] = {
**attr.asdict(hass_device),
diff --git a/homeassistant/components/nut/entity.py b/homeassistant/components/nut/entity.py
new file mode 100644
index 00000000000..e6536d8aad6
--- /dev/null
+++ b/homeassistant/components/nut/entity.py
@@ -0,0 +1,67 @@
+"""Base entity for the NUT integration."""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from typing import cast
+
+from homeassistant.const import (
+ ATTR_MANUFACTURER,
+ ATTR_MODEL,
+ ATTR_SERIAL_NUMBER,
+ ATTR_SW_VERSION,
+)
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+)
+
+from . import PyNUTData
+from .const import DOMAIN
+
+NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = {
+ "manufacturer": ATTR_MANUFACTURER,
+ "model": ATTR_MODEL,
+ "firmware": ATTR_SW_VERSION,
+ "serial": ATTR_SERIAL_NUMBER,
+}
+
+
+class NUTBaseEntity(CoordinatorEntity[DataUpdateCoordinator]):
+ """NUT base entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: DataUpdateCoordinator,
+ entity_description: EntityDescription,
+ data: PyNUTData,
+ unique_id: str,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(coordinator)
+
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{unique_id}_{entity_description.key}"
+
+ self.pynut_data = data
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, unique_id)},
+ name=self.pynut_data.device_name,
+ )
+ self._attr_device_info.update(_get_nut_device_info(data))
+
+
+def _get_nut_device_info(data: PyNUTData) -> DeviceInfo:
+ """Return a DeviceInfo object filled with NUT device info."""
+ nut_dev_infos = asdict(data.device_info)
+ nut_infos = {
+ info_key: nut_dev_infos[nut_key]
+ for nut_key, info_key in NUT_DEV_INFO_TO_DEV_INFO.items()
+ if nut_dev_infos[nut_key] is not None
+ }
+
+ return cast(DeviceInfo, nut_infos)
diff --git a/homeassistant/components/nut/icons.json b/homeassistant/components/nut/icons.json
index e0f78d6400b..a795368005c 100644
--- a/homeassistant/components/nut/icons.json
+++ b/homeassistant/components/nut/icons.json
@@ -1,6 +1,17 @@
{
"entity": {
+ "button": {
+ "outlet_number_load_cycle": {
+ "default": "mdi:restart"
+ }
+ },
"sensor": {
+ "ambient_humidity_status": {
+ "default": "mdi:information-outline"
+ },
+ "ambient_temperature_status": {
+ "default": "mdi:information-outline"
+ },
"battery_alarm_threshold": {
"default": "mdi:information-outline"
},
@@ -31,17 +42,38 @@
"battery_packs_bad": {
"default": "mdi:information-outline"
},
+ "battery_runtime": {
+ "default": "mdi:clock-outline"
+ },
+ "battery_runtime_low": {
+ "default": "mdi:clock-alert-outline"
+ },
+ "battery_runtime_restart": {
+ "default": "mdi:clock-start"
+ },
"battery_type": {
"default": "mdi:information-outline"
},
+ "battery_voltage_high": {
+ "default": "mdi:battery-high"
+ },
+ "battery_voltage_low": {
+ "default": "mdi:battery-low"
+ },
"input_bypass_phases": {
+ "default": "mdi:sine-wave"
+ },
+ "input_current_status": {
"default": "mdi:information-outline"
},
"input_frequency_status": {
"default": "mdi:information-outline"
},
+ "input_load": {
+ "default": "mdi:percent-box-outline"
+ },
"input_phases": {
- "default": "mdi:information-outline"
+ "default": "mdi:sine-wave"
},
"input_sensitivity": {
"default": "mdi:information-outline"
@@ -49,17 +81,26 @@
"input_transfer_reason": {
"default": "mdi:information-outline"
},
+ "input_voltage_status": {
+ "default": "mdi:information-outline"
+ },
+ "outlet_number_current_status": {
+ "default": "mdi:information-outline"
+ },
+ "outlet_number_desc": {
+ "default": "mdi:information-outline"
+ },
"output_l1_power_percent": {
- "default": "mdi:gauge"
+ "default": "mdi:percent-circle-outline"
},
"output_l2_power_percent": {
- "default": "mdi:gauge"
+ "default": "mdi:percent-circle-outline"
},
"output_l3_power_percent": {
- "default": "mdi:gauge"
+ "default": "mdi:percent-circle-outline"
},
"output_phases": {
- "default": "mdi:information-outline"
+ "default": "mdi:sine-wave"
},
"ups_alarm": {
"default": "mdi:alarm"
@@ -70,20 +111,29 @@
"ups_contacts": {
"default": "mdi:information-outline"
},
+ "ups_delay_reboot": {
+ "default": "mdi:timelapse"
+ },
+ "ups_delay_shutdown": {
+ "default": "mdi:timelapse"
+ },
+ "ups_delay_start": {
+ "default": "mdi:timelapse"
+ },
"ups_display_language": {
"default": "mdi:information-outline"
},
"ups_efficiency": {
- "default": "mdi:gauge"
+ "default": "mdi:percent-outline"
},
"ups_id": {
"default": "mdi:information-outline"
},
"ups_load": {
- "default": "mdi:gauge"
+ "default": "mdi:percent-box-outline"
},
"ups_load_high": {
- "default": "mdi:gauge"
+ "default": "mdi:percent-box-outline"
},
"ups_shutdown": {
"default": "mdi:information-outline"
@@ -106,15 +156,32 @@
"ups_test_date": {
"default": "mdi:calendar"
},
+ "ups_test_interval": {
+ "default": "mdi:timelapse"
+ },
"ups_test_result": {
"default": "mdi:information-outline"
},
+ "ups_timer_reboot": {
+ "default": "mdi:timer-refresh-outline"
+ },
+ "ups_timer_shutdown": {
+ "default": "mdi:timer-stop-outline"
+ },
+ "ups_timer_start": {
+ "default": "mdi:timer-play-outline"
+ },
"ups_type": {
"default": "mdi:information-outline"
},
"ups_watchdog_status": {
"default": "mdi:information-outline"
}
+ },
+ "switch": {
+ "outlet_number_load_poweronoff": {
+ "default": "mdi:power"
+ }
}
}
}
diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json
index fb6c8561b25..1ee85a84caf 100644
--- a/homeassistant/components/nut/manifest.json
+++ b/homeassistant/components/nut/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "nut",
"name": "Network UPS Tools (NUT)",
- "codeowners": ["@bdraco", "@ollo69", "@pestevez"],
+ "codeowners": ["@bdraco", "@ollo69", "@pestevez", "@tdfountain"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nut",
"integration_type": "device",
diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py
index bb702873052..5822f7f7b02 100644
--- a/homeassistant/components/nut/sensor.py
+++ b/homeassistant/components/nut/sensor.py
@@ -1,10 +1,9 @@
-"""Provides a sensor to track various status aspects of a UPS."""
+"""Provides a sensor to track various status aspects of a NUT device."""
from __future__ import annotations
-from dataclasses import asdict
import logging
-from typing import Final, cast
+from typing import Final
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -13,10 +12,6 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
- ATTR_MANUFACTURER,
- ATTR_MODEL,
- ATTR_SERIAL_NUMBER,
- ATTR_SW_VERSION,
PERCENTAGE,
STATE_UNKNOWN,
EntityCategory,
@@ -29,71 +24,806 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import (
- CoordinatorEntity,
- DataUpdateCoordinator,
-)
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import NutConfigEntry, PyNUTData
-from .const import DOMAIN, KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES
+from . import NutConfigEntry
+from .const import KEY_STATUS, KEY_STATUS_DISPLAY, STATE_TYPES
+from .entity import NUTBaseEntity
-NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = {
- "manufacturer": ATTR_MANUFACTURER,
- "model": ATTR_MODEL,
- "firmware": ATTR_SW_VERSION,
- "serial": ATTR_SERIAL_NUMBER,
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+AMBIENT_PRESENT = "ambient.present"
+AMBIENT_SENSORS = {
+ "ambient.humidity",
+ "ambient.humidity.status",
+ "ambient.temperature",
+ "ambient.temperature.status",
}
+BATTERY_CHARGER_STATUS_OPTIONS = [
+ "charging",
+ "discharging",
+ "floating",
+ "resting",
+ "unknown",
+ "disabled",
+ "off",
+]
+FREQUENCY_STATUS_OPTIONS = [
+ "good",
+ "out-of-range",
+]
+THRESHOLD_STATUS_OPTIONS = [
+ "good",
+ "warning-low",
+ "critical-low",
+ "warning-high",
+ "critical-high",
+]
+UPS_BEEPER_STATUS_OPTIONS = [
+ "enabled",
+ "disabled",
+ "muted",
+]
_LOGGER = logging.getLogger(__name__)
+
SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
- "ups.status.display": SensorEntityDescription(
- key="ups.status.display",
- translation_key="ups_status_display",
+ "ambient.humidity": SensorEntityDescription(
+ key="ambient.humidity",
+ translation_key="ambient_humidity",
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
- "ups.status": SensorEntityDescription(
- key="ups.status",
- translation_key="ups_status",
+ "ambient.humidity.status": SensorEntityDescription(
+ key="ambient.humidity.status",
+ translation_key="ambient_humidity_status",
+ device_class=SensorDeviceClass.ENUM,
+ options=THRESHOLD_STATUS_OPTIONS,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
- "ups.alarm": SensorEntityDescription(
- key="ups.alarm",
- translation_key="ups_alarm",
+ "ambient.temperature": SensorEntityDescription(
+ key="ambient.temperature",
+ translation_key="ambient_temperature",
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
- "ups.temperature": SensorEntityDescription(
- key="ups.temperature",
- translation_key="ups_temperature",
+ "ambient.temperature.status": SensorEntityDescription(
+ key="ambient.temperature.status",
+ translation_key="ambient_temperature_status",
+ device_class=SensorDeviceClass.ENUM,
+ options=THRESHOLD_STATUS_OPTIONS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ "battery.alarm.threshold": SensorEntityDescription(
+ key="battery.alarm.threshold",
+ translation_key="battery_alarm_threshold",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.capacity": SensorEntityDescription(
+ key="battery.capacity",
+ translation_key="battery_capacity",
+ native_unit_of_measurement="Ah",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.charge": SensorEntityDescription(
+ key="battery.charge",
+ translation_key="battery_charge",
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.BATTERY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "battery.charge.low": SensorEntityDescription(
+ key="battery.charge.low",
+ translation_key="battery_charge_low",
+ native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.charge.restart": SensorEntityDescription(
+ key="battery.charge.restart",
+ translation_key="battery_charge_restart",
+ native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.charge.warning": SensorEntityDescription(
+ key="battery.charge.warning",
+ translation_key="battery_charge_warning",
+ native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.charger.status": SensorEntityDescription(
+ key="battery.charger.status",
+ translation_key="battery_charger_status",
+ device_class=SensorDeviceClass.ENUM,
+ options=BATTERY_CHARGER_STATUS_OPTIONS,
+ ),
+ "battery.current": SensorEntityDescription(
+ key="battery.current",
+ translation_key="battery_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.current.total": SensorEntityDescription(
+ key="battery.current.total",
+ translation_key="battery_current_total",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.date": SensorEntityDescription(
+ key="battery.date",
+ translation_key="battery_date",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.mfr.date": SensorEntityDescription(
+ key="battery.mfr.date",
+ translation_key="battery_mfr_date",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.packs": SensorEntityDescription(
+ key="battery.packs",
+ translation_key="battery_packs",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.packs.bad": SensorEntityDescription(
+ key="battery.packs.bad",
+ translation_key="battery_packs_bad",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.runtime": SensorEntityDescription(
+ key="battery.runtime",
+ translation_key="battery_runtime",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ device_class=SensorDeviceClass.DURATION,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.runtime.low": SensorEntityDescription(
+ key="battery.runtime.low",
+ translation_key="battery_runtime_low",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ device_class=SensorDeviceClass.DURATION,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.runtime.restart": SensorEntityDescription(
+ key="battery.runtime.restart",
+ translation_key="battery_runtime_restart",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ device_class=SensorDeviceClass.DURATION,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.temperature": SensorEntityDescription(
+ key="battery.temperature",
+ translation_key="battery_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "ups.load": SensorEntityDescription(
- key="ups.load",
- translation_key="ups_load",
+ "battery.type": SensorEntityDescription(
+ key="battery.type",
+ translation_key="battery_type",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.voltage": SensorEntityDescription(
+ key="battery.voltage",
+ translation_key="battery_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.voltage.high": SensorEntityDescription(
+ key="battery.voltage.high",
+ translation_key="battery_voltage_high",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.voltage.low": SensorEntityDescription(
+ key="battery.voltage.low",
+ translation_key="battery_voltage_low",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "battery.voltage.nominal": SensorEntityDescription(
+ key="battery.voltage.nominal",
+ translation_key="battery_voltage_nominal",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.current": SensorEntityDescription(
+ key="input.bypass.current",
+ translation_key="input_bypass_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.frequency": SensorEntityDescription(
+ key="input.bypass.frequency",
+ translation_key="input_bypass_frequency",
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ device_class=SensorDeviceClass.FREQUENCY,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.L1.current": SensorEntityDescription(
+ key="input.bypass.L1.current",
+ translation_key="input_bypass_l1_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.L1-N.voltage": SensorEntityDescription(
+ key="input.bypass.L1-N.voltage",
+ translation_key="input_bypass_l1_n_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.L1.realpower": SensorEntityDescription(
+ key="input.bypass.L1.realpower",
+ translation_key="input_bypass_l1_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.L2.current": SensorEntityDescription(
+ key="input.bypass.L2.current",
+ translation_key="input_bypass_l2_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.L2-N.voltage": SensorEntityDescription(
+ key="input.bypass.L2-N.voltage",
+ translation_key="input_bypass_l2_n_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.L2.realpower": SensorEntityDescription(
+ key="input.bypass.L2.realpower",
+ translation_key="input_bypass_l2_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.L3.current": SensorEntityDescription(
+ key="input.bypass.L3.current",
+ translation_key="input_bypass_l3_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.L3-N.voltage": SensorEntityDescription(
+ key="input.bypass.L3-N.voltage",
+ translation_key="input_bypass_l3_n_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.L3.realpower": SensorEntityDescription(
+ key="input.bypass.L3.realpower",
+ translation_key="input_bypass_l3_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.phases": SensorEntityDescription(
+ key="input.bypass.phases",
+ translation_key="input_bypass_phases",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.realpower": SensorEntityDescription(
+ key="input.bypass.realpower",
+ translation_key="input_bypass_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.bypass.voltage": SensorEntityDescription(
+ key="input.bypass.voltage",
+ translation_key="input_bypass_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.current": SensorEntityDescription(
+ key="input.current",
+ translation_key="input_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_registry_enabled_default=False,
+ ),
+ "input.current.status": SensorEntityDescription(
+ key="input.current.status",
+ translation_key="input_current_status",
+ device_class=SensorDeviceClass.ENUM,
+ options=THRESHOLD_STATUS_OPTIONS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.frequency": SensorEntityDescription(
+ key="input.frequency",
+ translation_key="input_frequency",
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ device_class=SensorDeviceClass.FREQUENCY,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.frequency.nominal": SensorEntityDescription(
+ key="input.frequency.nominal",
+ translation_key="input_frequency_nominal",
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ device_class=SensorDeviceClass.FREQUENCY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.frequency.status": SensorEntityDescription(
+ key="input.frequency.status",
+ translation_key="input_frequency_status",
+ device_class=SensorDeviceClass.ENUM,
+ options=FREQUENCY_STATUS_OPTIONS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L1.current": SensorEntityDescription(
+ key="input.L1.current",
+ translation_key="input_l1_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L1.frequency": SensorEntityDescription(
+ key="input.L1.frequency",
+ translation_key="input_l1_frequency",
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ device_class=SensorDeviceClass.FREQUENCY,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L1-N.voltage": SensorEntityDescription(
+ key="input.L1-N.voltage",
+ translation_key="input_l1_n_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L1.realpower": SensorEntityDescription(
+ key="input.L1.realpower",
+ translation_key="input_l1_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L2.current": SensorEntityDescription(
+ key="input.L2.current",
+ translation_key="input_l2_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L2.frequency": SensorEntityDescription(
+ key="input.L2.frequency",
+ translation_key="input_l2_frequency",
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ device_class=SensorDeviceClass.FREQUENCY,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L2-N.voltage": SensorEntityDescription(
+ key="input.L2-N.voltage",
+ translation_key="input_l2_n_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L2.realpower": SensorEntityDescription(
+ key="input.L2.realpower",
+ translation_key="input_l2_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L3.current": SensorEntityDescription(
+ key="input.L3.current",
+ translation_key="input_l3_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L3.frequency": SensorEntityDescription(
+ key="input.L3.frequency",
+ translation_key="input_l3_frequency",
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ device_class=SensorDeviceClass.FREQUENCY,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L3-N.voltage": SensorEntityDescription(
+ key="input.L3-N.voltage",
+ translation_key="input_l3_n_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.L3.realpower": SensorEntityDescription(
+ key="input.L3.realpower",
+ translation_key="input_l3_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.load": SensorEntityDescription(
+ key="input.load",
+ translation_key="input_load",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
- "ups.load.high": SensorEntityDescription(
- key="ups.load.high",
- translation_key="ups_load_high",
+ "input.phases": SensorEntityDescription(
+ key="input.phases",
+ translation_key="input_phases",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.power": SensorEntityDescription(
+ key="input.power",
+ translation_key="input_power",
+ native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
+ device_class=SensorDeviceClass.APPARENT_POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.realpower": SensorEntityDescription(
+ key="input.realpower",
+ translation_key="input_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.sensitivity": SensorEntityDescription(
+ key="input.sensitivity",
+ translation_key="input_sensitivity",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.transfer.high": SensorEntityDescription(
+ key="input.transfer.high",
+ translation_key="input_transfer_high",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.transfer.low": SensorEntityDescription(
+ key="input.transfer.low",
+ translation_key="input_transfer_low",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.transfer.reason": SensorEntityDescription(
+ key="input.transfer.reason",
+ translation_key="input_transfer_reason",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.voltage": SensorEntityDescription(
+ key="input.voltage",
+ translation_key="input_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "input.voltage.nominal": SensorEntityDescription(
+ key="input.voltage.nominal",
+ translation_key="input_voltage_nominal",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "input.voltage.status": SensorEntityDescription(
+ key="input.voltage.status",
+ translation_key="input_voltage_status",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "outlet.voltage": SensorEntityDescription(
+ key="outlet.voltage",
+ translation_key="outlet_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "output.current": SensorEntityDescription(
+ key="output.current",
+ translation_key="output_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.current.nominal": SensorEntityDescription(
+ key="output.current.nominal",
+ translation_key="output_current_nominal",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.frequency": SensorEntityDescription(
+ key="output.frequency",
+ translation_key="output_frequency",
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ device_class=SensorDeviceClass.FREQUENCY,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.frequency.nominal": SensorEntityDescription(
+ key="output.frequency.nominal",
+ translation_key="output_frequency_nominal",
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ device_class=SensorDeviceClass.FREQUENCY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.L1.current": SensorEntityDescription(
+ key="output.L1.current",
+ translation_key="output_l1_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.L1-N.voltage": SensorEntityDescription(
+ key="output.L1-N.voltage",
+ translation_key="output_l1_n_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.L1.power.percent": SensorEntityDescription(
+ key="output.L1.power.percent",
+ translation_key="output_l1_power_percent",
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "ups.id": SensorEntityDescription(
- key="ups.id",
- translation_key="ups_id",
+ "output.L1.realpower": SensorEntityDescription(
+ key="output.L1.realpower",
+ translation_key="output_l1_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "ups.delay.start": SensorEntityDescription(
- key="ups.delay.start",
- translation_key="ups_delay_start",
- native_unit_of_measurement=UnitOfTime.SECONDS,
- device_class=SensorDeviceClass.DURATION,
+ "output.L2.current": SensorEntityDescription(
+ key="output.L2.current",
+ translation_key="output_l2_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.L2-N.voltage": SensorEntityDescription(
+ key="output.L2-N.voltage",
+ translation_key="output_l2_n_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.L2.power.percent": SensorEntityDescription(
+ key="output.L2.power.percent",
+ translation_key="output_l2_power_percent",
+ native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.L2.realpower": SensorEntityDescription(
+ key="output.L2.realpower",
+ translation_key="output_l2_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.L3.current": SensorEntityDescription(
+ key="output.L3.current",
+ translation_key="output_l3_current",
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.L3-N.voltage": SensorEntityDescription(
+ key="output.L3-N.voltage",
+ translation_key="output_l3_n_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.L3.power.percent": SensorEntityDescription(
+ key="output.L3.power.percent",
+ translation_key="output_l3_power_percent",
+ native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.L3.realpower": SensorEntityDescription(
+ key="output.L3.realpower",
+ translation_key="output_l3_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.phases": SensorEntityDescription(
+ key="output.phases",
+ translation_key="output_phases",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.power": SensorEntityDescription(
+ key="output.power",
+ translation_key="output_power",
+ native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
+ device_class=SensorDeviceClass.APPARENT_POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.power.nominal": SensorEntityDescription(
+ key="output.power.nominal",
+ translation_key="output_power_nominal",
+ native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
+ device_class=SensorDeviceClass.APPARENT_POWER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.realpower": SensorEntityDescription(
+ key="output.realpower",
+ translation_key="output_realpower",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.realpower.nominal": SensorEntityDescription(
+ key="output.realpower.nominal",
+ translation_key="output_realpower_nominal",
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "output.voltage": SensorEntityDescription(
+ key="output.voltage",
+ translation_key="output_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "output.voltage.nominal": SensorEntityDescription(
+ key="output.voltage.nominal",
+ translation_key="output_voltage_nominal",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "ups.alarm": SensorEntityDescription(
+ key="ups.alarm",
+ translation_key="ups_alarm",
+ ),
+ "ups.beeper.status": SensorEntityDescription(
+ key="ups.beeper.status",
+ translation_key="ups_beeper_status",
+ device_class=SensorDeviceClass.ENUM,
+ options=UPS_BEEPER_STATUS_OPTIONS,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "ups.contacts": SensorEntityDescription(
+ key="ups.contacts",
+ translation_key="ups_contacts",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@@ -113,62 +843,20 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "ups.timer.start": SensorEntityDescription(
- key="ups.timer.start",
- translation_key="ups_timer_start",
+ "ups.delay.start": SensorEntityDescription(
+ key="ups.delay.start",
+ translation_key="ups_delay_start",
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "ups.timer.reboot": SensorEntityDescription(
- key="ups.timer.reboot",
- translation_key="ups_timer_reboot",
- native_unit_of_measurement=UnitOfTime.SECONDS,
- device_class=SensorDeviceClass.DURATION,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "ups.timer.shutdown": SensorEntityDescription(
- key="ups.timer.shutdown",
- translation_key="ups_timer_shutdown",
- native_unit_of_measurement=UnitOfTime.SECONDS,
- device_class=SensorDeviceClass.DURATION,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "ups.test.interval": SensorEntityDescription(
- key="ups.test.interval",
- translation_key="ups_test_interval",
- native_unit_of_measurement=UnitOfTime.SECONDS,
- device_class=SensorDeviceClass.DURATION,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "ups.test.result": SensorEntityDescription(
- key="ups.test.result",
- translation_key="ups_test_result",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "ups.test.date": SensorEntityDescription(
- key="ups.test.date",
- translation_key="ups_test_date",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
"ups.display.language": SensorEntityDescription(
key="ups.display.language",
translation_key="ups_display_language",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "ups.contacts": SensorEntityDescription(
- key="ups.contacts",
- translation_key="ups_contacts",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
"ups.efficiency": SensorEntityDescription(
key="ups.efficiency",
translation_key="ups_efficiency",
@@ -177,6 +865,25 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
+ "ups.id": SensorEntityDescription(
+ key="ups.id",
+ translation_key="ups_id",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "ups.load": SensorEntityDescription(
+ key="ups.load",
+ translation_key="ups_load",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ "ups.load.high": SensorEntityDescription(
+ key="ups.load.high",
+ translation_key="ups_load_high",
+ native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
"ups.power": SensorEntityDescription(
key="ups.power",
translation_key="ups_power",
@@ -211,21 +918,9 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "ups.beeper.status": SensorEntityDescription(
- key="ups.beeper.status",
- translation_key="ups_beeper_status",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "ups.type": SensorEntityDescription(
- key="ups.type",
- translation_key="ups_type",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "ups.watchdog.status": SensorEntityDescription(
- key="ups.watchdog.status",
- translation_key="ups_watchdog_status",
+ "ups.shutdown": SensorEntityDescription(
+ key="ups.shutdown",
+ translation_key="ups_shutdown",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@@ -247,725 +942,89 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "ups.shutdown": SensorEntityDescription(
- key="ups.shutdown",
- translation_key="ups_shutdown",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
+ "ups.status": SensorEntityDescription(
+ key="ups.status",
+ translation_key="ups_status",
),
- "battery.charge": SensorEntityDescription(
- key="battery.charge",
- translation_key="battery_charge",
- native_unit_of_measurement=PERCENTAGE,
- device_class=SensorDeviceClass.BATTERY,
- state_class=SensorStateClass.MEASUREMENT,
+ "ups.status.display": SensorEntityDescription(
+ key="ups.status.display",
+ translation_key="ups_status_display",
),
- "battery.charge.low": SensorEntityDescription(
- key="battery.charge.low",
- translation_key="battery_charge_low",
- native_unit_of_measurement=PERCENTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.charge.restart": SensorEntityDescription(
- key="battery.charge.restart",
- translation_key="battery_charge_restart",
- native_unit_of_measurement=PERCENTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.charge.warning": SensorEntityDescription(
- key="battery.charge.warning",
- translation_key="battery_charge_warning",
- native_unit_of_measurement=PERCENTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.charger.status": SensorEntityDescription(
- key="battery.charger.status",
- translation_key="battery_charger_status",
- ),
- "battery.voltage": SensorEntityDescription(
- key="battery.voltage",
- translation_key="battery_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.voltage.nominal": SensorEntityDescription(
- key="battery.voltage.nominal",
- translation_key="battery_voltage_nominal",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.voltage.low": SensorEntityDescription(
- key="battery.voltage.low",
- translation_key="battery_voltage_low",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.voltage.high": SensorEntityDescription(
- key="battery.voltage.high",
- translation_key="battery_voltage_high",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.capacity": SensorEntityDescription(
- key="battery.capacity",
- translation_key="battery_capacity",
- native_unit_of_measurement="Ah",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.current": SensorEntityDescription(
- key="battery.current",
- translation_key="battery_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.current.total": SensorEntityDescription(
- key="battery.current.total",
- translation_key="battery_current_total",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.temperature": SensorEntityDescription(
- key="battery.temperature",
- translation_key="battery_temperature",
+ "ups.temperature": SensorEntityDescription(
+ key="ups.temperature",
+ translation_key="ups_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "battery.runtime": SensorEntityDescription(
- key="battery.runtime",
- translation_key="battery_runtime",
+ "ups.test.date": SensorEntityDescription(
+ key="ups.test.date",
+ translation_key="ups_test_date",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "ups.test.interval": SensorEntityDescription(
+ key="ups.test.interval",
+ translation_key="ups_test_interval",
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "battery.runtime.low": SensorEntityDescription(
- key="battery.runtime.low",
- translation_key="battery_runtime_low",
+ "ups.test.result": SensorEntityDescription(
+ key="ups.test.result",
+ translation_key="ups_test_result",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ "ups.timer.reboot": SensorEntityDescription(
+ key="ups.timer.reboot",
+ translation_key="ups_timer_reboot",
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "battery.runtime.restart": SensorEntityDescription(
- key="battery.runtime.restart",
- translation_key="battery_runtime_restart",
+ "ups.timer.shutdown": SensorEntityDescription(
+ key="ups.timer.shutdown",
+ translation_key="ups_timer_shutdown",
native_unit_of_measurement=UnitOfTime.SECONDS,
device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "battery.alarm.threshold": SensorEntityDescription(
- key="battery.alarm.threshold",
- translation_key="battery_alarm_threshold",
+ "ups.timer.start": SensorEntityDescription(
+ key="ups.timer.start",
+ translation_key="ups_timer_start",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ device_class=SensorDeviceClass.DURATION,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "battery.date": SensorEntityDescription(
- key="battery.date",
- translation_key="battery_date",
+ "ups.type": SensorEntityDescription(
+ key="ups.type",
+ translation_key="ups_type",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "battery.mfr.date": SensorEntityDescription(
- key="battery.mfr.date",
- translation_key="battery_mfr_date",
+ "ups.watchdog.status": SensorEntityDescription(
+ key="ups.watchdog.status",
+ translation_key="ups_watchdog_status",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
- "battery.packs": SensorEntityDescription(
- key="battery.packs",
- translation_key="battery_packs",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.packs.bad": SensorEntityDescription(
- key="battery.packs.bad",
- translation_key="battery_packs_bad",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "battery.type": SensorEntityDescription(
- key="battery.type",
- translation_key="battery_type",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.sensitivity": SensorEntityDescription(
- key="input.sensitivity",
- translation_key="input_sensitivity",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.transfer.low": SensorEntityDescription(
- key="input.transfer.low",
- translation_key="input_transfer_low",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.transfer.high": SensorEntityDescription(
- key="input.transfer.high",
- translation_key="input_transfer_high",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.transfer.reason": SensorEntityDescription(
- key="input.transfer.reason",
- translation_key="input_transfer_reason",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.voltage": SensorEntityDescription(
- key="input.voltage",
- translation_key="input_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- ),
- "input.voltage.nominal": SensorEntityDescription(
- key="input.voltage.nominal",
- translation_key="input_voltage_nominal",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.L1-N.voltage": SensorEntityDescription(
- key="input.L1-N.voltage",
- translation_key="input_l1_n_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.L2-N.voltage": SensorEntityDescription(
- key="input.L2-N.voltage",
- translation_key="input_l2_n_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.L3-N.voltage": SensorEntityDescription(
- key="input.L3-N.voltage",
- translation_key="input_l3_n_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.frequency": SensorEntityDescription(
- key="input.frequency",
- translation_key="input_frequency",
- native_unit_of_measurement=UnitOfFrequency.HERTZ,
- device_class=SensorDeviceClass.FREQUENCY,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.frequency.nominal": SensorEntityDescription(
- key="input.frequency.nominal",
- translation_key="input_frequency_nominal",
- native_unit_of_measurement=UnitOfFrequency.HERTZ,
- device_class=SensorDeviceClass.FREQUENCY,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.frequency.status": SensorEntityDescription(
- key="input.frequency.status",
- translation_key="input_frequency_status",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.L1.frequency": SensorEntityDescription(
- key="input.L1.frequency",
- translation_key="input_l1_frequency",
- native_unit_of_measurement=UnitOfFrequency.HERTZ,
- device_class=SensorDeviceClass.FREQUENCY,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.L2.frequency": SensorEntityDescription(
- key="input.L2.frequency",
- translation_key="input_l2_frequency",
- native_unit_of_measurement=UnitOfFrequency.HERTZ,
- device_class=SensorDeviceClass.FREQUENCY,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.L3.frequency": SensorEntityDescription(
- key="input.L3.frequency",
- translation_key="input_l3_frequency",
- native_unit_of_measurement=UnitOfFrequency.HERTZ,
- device_class=SensorDeviceClass.FREQUENCY,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.current": SensorEntityDescription(
- key="input.bypass.current",
- translation_key="input_bypass_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.L1.current": SensorEntityDescription(
- key="input.bypass.L1.current",
- translation_key="input_bypass_l1_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.L2.current": SensorEntityDescription(
- key="input.bypass.L2.current",
- translation_key="input_bypass_l2_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.L3.current": SensorEntityDescription(
- key="input.bypass.L3.current",
- translation_key="input_bypass_l3_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.frequency": SensorEntityDescription(
- key="input.bypass.frequency",
- translation_key="input_bypass_frequency",
- native_unit_of_measurement=UnitOfFrequency.HERTZ,
- device_class=SensorDeviceClass.FREQUENCY,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.phases": SensorEntityDescription(
- key="input.bypass.phases",
- translation_key="input_bypass_phases",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.realpower": SensorEntityDescription(
- key="input.bypass.realpower",
- translation_key="input_bypass_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.L1.realpower": SensorEntityDescription(
- key="input.bypass.L1.realpower",
- translation_key="input_bypass_l1_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.L2.realpower": SensorEntityDescription(
- key="input.bypass.L2.realpower",
- translation_key="input_bypass_l2_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.L3.realpower": SensorEntityDescription(
- key="input.bypass.L3.realpower",
- translation_key="input_bypass_l3_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.voltage": SensorEntityDescription(
- key="input.bypass.voltage",
- translation_key="input_bypass_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.L1-N.voltage": SensorEntityDescription(
- key="input.bypass.L1-N.voltage",
- translation_key="input_bypass_l1_n_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.L2-N.voltage": SensorEntityDescription(
- key="input.bypass.L2-N.voltage",
- translation_key="input_bypass_l2_n_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.bypass.L3-N.voltage": SensorEntityDescription(
- key="input.bypass.L3-N.voltage",
- translation_key="input_bypass_l3_n_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.current": SensorEntityDescription(
- key="input.current",
- translation_key="input_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_registry_enabled_default=False,
- ),
- "input.L1.current": SensorEntityDescription(
- key="input.L1.current",
- translation_key="input_l1_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.L2.current": SensorEntityDescription(
- key="input.L2.current",
- translation_key="input_l2_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.L3.current": SensorEntityDescription(
- key="input.L3.current",
- translation_key="input_l3_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.phases": SensorEntityDescription(
- key="input.phases",
- translation_key="input_phases",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.realpower": SensorEntityDescription(
- key="input.realpower",
- translation_key="input_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.L1.realpower": SensorEntityDescription(
- key="input.L1.realpower",
- translation_key="input_l1_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.L2.realpower": SensorEntityDescription(
- key="input.L2.realpower",
- translation_key="input_l2_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "input.L3.realpower": SensorEntityDescription(
- key="input.L3.realpower",
- translation_key="input_l3_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.power.nominal": SensorEntityDescription(
- key="output.power.nominal",
- translation_key="output_power_nominal",
- native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
- device_class=SensorDeviceClass.APPARENT_POWER,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L1.power.percent": SensorEntityDescription(
- key="output.L1.power.percent",
- translation_key="output_l1_power_percent",
- native_unit_of_measurement=PERCENTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L2.power.percent": SensorEntityDescription(
- key="output.L2.power.percent",
- translation_key="output_l2_power_percent",
- native_unit_of_measurement=PERCENTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L3.power.percent": SensorEntityDescription(
- key="output.L3.power.percent",
- translation_key="output_l3_power_percent",
- native_unit_of_measurement=PERCENTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.current": SensorEntityDescription(
- key="output.current",
- translation_key="output_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.current.nominal": SensorEntityDescription(
- key="output.current.nominal",
- translation_key="output_current_nominal",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L1.current": SensorEntityDescription(
- key="output.L1.current",
- translation_key="output_l1_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L2.current": SensorEntityDescription(
- key="output.L2.current",
- translation_key="output_l2_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L3.current": SensorEntityDescription(
- key="output.L3.current",
- translation_key="output_l3_current",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.voltage": SensorEntityDescription(
- key="output.voltage",
- translation_key="output_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- ),
- "output.voltage.nominal": SensorEntityDescription(
- key="output.voltage.nominal",
- translation_key="output_voltage_nominal",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L1-N.voltage": SensorEntityDescription(
- key="output.L1-N.voltage",
- translation_key="output_l1_n_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L2-N.voltage": SensorEntityDescription(
- key="output.L2-N.voltage",
- translation_key="output_l2_n_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L3-N.voltage": SensorEntityDescription(
- key="output.L3-N.voltage",
- translation_key="output_l3_n_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.frequency": SensorEntityDescription(
- key="output.frequency",
- translation_key="output_frequency",
- native_unit_of_measurement=UnitOfFrequency.HERTZ,
- device_class=SensorDeviceClass.FREQUENCY,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.frequency.nominal": SensorEntityDescription(
- key="output.frequency.nominal",
- translation_key="output_frequency_nominal",
- native_unit_of_measurement=UnitOfFrequency.HERTZ,
- device_class=SensorDeviceClass.FREQUENCY,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.phases": SensorEntityDescription(
- key="output.phases",
- translation_key="output_phases",
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.power": SensorEntityDescription(
- key="output.power",
- translation_key="output_power",
- native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
- device_class=SensorDeviceClass.APPARENT_POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.realpower": SensorEntityDescription(
- key="output.realpower",
- translation_key="output_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.realpower.nominal": SensorEntityDescription(
- key="output.realpower.nominal",
- translation_key="output_realpower_nominal",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L1.realpower": SensorEntityDescription(
- key="output.L1.realpower",
- translation_key="output_l1_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L2.realpower": SensorEntityDescription(
- key="output.L2.realpower",
- translation_key="output_l2_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "output.L3.realpower": SensorEntityDescription(
- key="output.L3.realpower",
- translation_key="output_l3_realpower",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- entity_registry_enabled_default=False,
- ),
- "ambient.humidity": SensorEntityDescription(
- key="ambient.humidity",
- translation_key="ambient_humidity",
- native_unit_of_measurement=PERCENTAGE,
- device_class=SensorDeviceClass.HUMIDITY,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
- "ambient.temperature": SensorEntityDescription(
- key="ambient.temperature",
- translation_key="ambient_temperature",
- native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- device_class=SensorDeviceClass.TEMPERATURE,
- state_class=SensorStateClass.MEASUREMENT,
- entity_category=EntityCategory.DIAGNOSTIC,
- ),
- "watts": SensorEntityDescription(
- key="watts",
- translation_key="watts",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- ),
}
-def _get_nut_device_info(data: PyNUTData) -> DeviceInfo:
- """Return a DeviceInfo object filled with NUT device info."""
- nut_dev_infos = asdict(data.device_info)
- nut_infos = {
- info_key: nut_dev_infos[nut_key]
- for nut_key, info_key in NUT_DEV_INFO_TO_DEV_INFO.items()
- if nut_dev_infos[nut_key] is not None
- }
-
- return cast(DeviceInfo, nut_infos)
-
-
async def async_setup_entry(
hass: HomeAssistant,
config_entry: NutConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the NUT sensors."""
+ valid_sensor_types: dict[str, SensorEntityDescription]
pynut_data = config_entry.runtime_data
coordinator = pynut_data.coordinator
@@ -973,16 +1032,75 @@ async def async_setup_entry(
unique_id = pynut_data.unique_id
status = coordinator.data
- resources = [sensor_id for sensor_id in SENSOR_TYPES if sensor_id in status]
+ # Dynamically add outlet sensors to valid sensors dictionary
+ if (num_outlets := status.get("outlet.count")) is not None:
+ additional_sensor_types: dict[str, SensorEntityDescription] = {}
+ for outlet_num in range(1, int(num_outlets) + 1):
+ outlet_num_str: str = str(outlet_num)
+ outlet_name: str = (
+ status.get(f"outlet.{outlet_num_str}.name") or outlet_num_str
+ )
+ additional_sensor_types |= {
+ f"outlet.{outlet_num_str}.current": SensorEntityDescription(
+ key=f"outlet.{outlet_num_str}.current",
+ translation_key="outlet_number_current",
+ translation_placeholders={"outlet_name": outlet_name},
+ native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
+ device_class=SensorDeviceClass.CURRENT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ f"outlet.{outlet_num_str}.current_status": SensorEntityDescription(
+ key=f"outlet.{outlet_num_str}.current_status",
+ translation_key="outlet_number_current_status",
+ translation_placeholders={"outlet_name": outlet_name},
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ f"outlet.{outlet_num_str}.desc": SensorEntityDescription(
+ key=f"outlet.{outlet_num_str}.desc",
+ translation_key="outlet_number_desc",
+ translation_placeholders={"outlet_name": outlet_name},
+ ),
+ f"outlet.{outlet_num_str}.power": SensorEntityDescription(
+ key=f"outlet.{outlet_num_str}.power",
+ translation_key="outlet_number_power",
+ translation_placeholders={"outlet_name": outlet_name},
+ native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE,
+ device_class=SensorDeviceClass.APPARENT_POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ f"outlet.{outlet_num_str}.realpower": SensorEntityDescription(
+ key=f"outlet.{outlet_num_str}.realpower",
+ translation_key="outlet_number_realpower",
+ translation_placeholders={"outlet_name": outlet_name},
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ }
+
+ valid_sensor_types = {**SENSOR_TYPES, **additional_sensor_types}
+ else:
+ valid_sensor_types = SENSOR_TYPES
+
+ # If device reports ambient sensors are not present, then remove
+ has_ambient_sensors: bool = status.get(AMBIENT_PRESENT) != "no"
+ resources = [
+ sensor_id
+ for sensor_id in valid_sensor_types
+ if sensor_id in status
+ and (has_ambient_sensors or sensor_id not in AMBIENT_SENSORS)
+ ]
+
# Display status is a special case that falls back to the status value
# of the UPS instead.
- if KEY_STATUS in resources:
+ if KEY_STATUS in status:
resources.append(KEY_STATUS_DISPLAY)
async_add_entities(
NUTSensor(
coordinator,
- SENSOR_TYPES[sensor_type],
+ valid_sensor_types[sensor_type],
data,
unique_id,
)
@@ -990,33 +1108,12 @@ async def async_setup_entry(
)
-class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], SensorEntity):
+class NUTSensor(NUTBaseEntity, SensorEntity):
"""Representation of a sensor entity for NUT status values."""
- _attr_has_entity_name = True
-
- def __init__(
- self,
- coordinator: DataUpdateCoordinator[dict[str, str]],
- sensor_description: SensorEntityDescription,
- data: PyNUTData,
- unique_id: str,
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator)
- self.entity_description = sensor_description
-
- device_name = data.name.title()
- self._attr_unique_id = f"{unique_id}_{sensor_description.key}"
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, unique_id)},
- name=device_name,
- )
- self._attr_device_info.update(_get_nut_device_info(data))
-
@property
def native_value(self) -> str | None:
- """Return entity state from ups."""
+ """Return entity state from NUT device."""
status = self.coordinator.data
if self.entity_description.key == KEY_STATUS_DISPLAY:
return _format_display_state(status)
@@ -1026,6 +1123,6 @@ class NUTSensor(CoordinatorEntity[DataUpdateCoordinator[dict[str, str]]], Sensor
def _format_display_state(status: dict[str, str]) -> str:
"""Return UPS display state."""
try:
- return " ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split())
+ return ", ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split())
except KeyError:
return STATE_UNKNOWN
diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json
index 83b8d340dc1..dff568944b7 100644
--- a/homeassistant/components/nut/strings.json
+++ b/homeassistant/components/nut/strings.json
@@ -10,13 +10,19 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "host": "The hostname or IP address of your NUT server."
+ "host": "The IP address or hostname of your NUT server.",
+ "port": "The network port of your NUT server. The NUT server's default port is '3493'.",
+ "username": "The username to sign in to your NUT server. The username is optional.",
+ "password": "The password to sign in to your NUT server. The password is optional."
}
},
"ups": {
- "title": "Choose the UPS to Monitor",
+ "title": "Choose the NUT server UPS to monitor",
"data": {
- "alias": "Alias"
+ "alias": "NUT server UPS name"
+ },
+ "data_description": {
+ "alias": "The UPS name configured on the NUT server."
}
},
"reauth_confirm": {
@@ -24,27 +30,48 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::nut::config::step::user::data_description::username%]",
+ "password": "[%key:component::nut::config::step::user::data_description::password%]"
+ }
+ },
+ "reconfigure": {
+ "description": "[%key:component::nut::config::step::user::description%]",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]",
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "host": "[%key:component::nut::config::step::user::data_description::host%]",
+ "port": "[%key:component::nut::config::step::user::data_description::port%]",
+ "username": "[%key:component::nut::config::step::user::data_description::username%]",
+ "password": "[%key:component::nut::config::step::user::data_description::password%]"
+ }
+ },
+ "reconfigure_ups": {
+ "title": "[%key:component::nut::config::step::ups::title%]",
+ "data": {
+ "alias": "[%key:component::nut::config::step::ups::data::alias%]"
+ },
+ "data_description": {
+ "alias": "[%key:component::nut::config::step::ups::data_description::alias%]"
}
}
},
"error": {
"cannot_connect": "Connection error: {error}",
- "unknown": "[%key:common::config_flow::error::unknown%]",
- "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_ups_found": "There are no UPS devices available on the NUT server.",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
- }
- },
- "options": {
- "step": {
- "init": {
- "data": {
- "scan_interval": "Scan Interval (seconds)"
- }
- }
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "unique_id_mismatch": "The device's manufacturer, model and serial number identifier does not match the previous identifier."
}
},
"device_automation": {
@@ -78,16 +105,50 @@
}
},
"entity": {
+ "button": {
+ "outlet_number_load_cycle": { "name": "Power cycle outlet {outlet_name}" }
+ },
"sensor": {
"ambient_humidity": { "name": "Ambient humidity" },
+ "ambient_humidity_status": {
+ "name": "Ambient humidity status",
+ "state": {
+ "good": "Good",
+ "warning-low": "Warning low",
+ "critical-low": "Critical low",
+ "warning-high": "Warning high",
+ "critical-high": "Critical high"
+ }
+ },
"ambient_temperature": { "name": "Ambient temperature" },
+ "ambient_temperature_status": {
+ "name": "Ambient temperature status",
+ "state": {
+ "good": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::good%]",
+ "warning-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-low%]",
+ "critical-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-low%]",
+ "warning-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-high%]",
+ "critical-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-high%]"
+ }
+ },
"battery_alarm_threshold": { "name": "Battery alarm threshold" },
"battery_capacity": { "name": "Battery capacity" },
"battery_charge": { "name": "Battery charge" },
"battery_charge_low": { "name": "Low battery setpoint" },
"battery_charge_restart": { "name": "Minimum battery to start" },
"battery_charge_warning": { "name": "Warning battery setpoint" },
- "battery_charger_status": { "name": "Charging status" },
+ "battery_charger_status": {
+ "name": "Charging status",
+ "state": {
+ "charging": "[%key:common::state::charging%]",
+ "discharging": "[%key:common::state::discharging%]",
+ "floating": "Floating",
+ "resting": "Resting",
+ "unknown": "Unknown",
+ "disabled": "[%key:common::state::disabled%]",
+ "off": "[%key:common::state::off%]"
+ }
+ },
"battery_current": { "name": "Battery current" },
"battery_current_total": { "name": "Total battery current" },
"battery_date": { "name": "Battery date" },
@@ -104,74 +165,102 @@
"battery_voltage_low": { "name": "Low battery voltage" },
"battery_voltage_nominal": { "name": "Nominal battery voltage" },
"input_bypass_current": { "name": "Input bypass current" },
- "input_bypass_l1_current": { "name": "Input bypass L1 current" },
- "input_bypass_l2_current": { "name": "Input bypass L2 current" },
- "input_bypass_l3_current": { "name": "Input bypass L3 current" },
- "input_bypass_voltage": { "name": "Input bypass voltage" },
- "input_bypass_l1_n_voltage": { "name": "Input bypass L1-N voltage" },
- "input_bypass_l2_n_voltage": { "name": "Input bypass L2-N voltage" },
- "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" },
"input_bypass_frequency": { "name": "Input bypass frequency" },
+ "input_bypass_l1_current": { "name": "Input bypass L1 current" },
+ "input_bypass_l1_n_voltage": { "name": "Input bypass L1-N voltage" },
+ "input_bypass_l1_realpower": { "name": "Input bypass L1 real power" },
+ "input_bypass_l2_current": { "name": "Input bypass L2 current" },
+ "input_bypass_l2_n_voltage": { "name": "Input bypass L2-N voltage" },
+ "input_bypass_l2_realpower": { "name": "Input bypass L2 real power" },
+ "input_bypass_l3_current": { "name": "Input bypass L3 current" },
+ "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" },
+ "input_bypass_l3_realpower": { "name": "Input bypass L3 real power" },
"input_bypass_phases": { "name": "Input bypass phases" },
"input_bypass_realpower": { "name": "Input bypass real power" },
- "input_bypass_l1_realpower": {
- "name": "Input bypass L1 real power"
- },
- "input_bypass_l2_realpower": {
- "name": "Input bypass L2 real power"
- },
- "input_bypass_l3_realpower": {
- "name": "Input bypass L3 real power"
- },
+ "input_bypass_voltage": { "name": "Input bypass voltage" },
"input_current": { "name": "Input current" },
- "input_l1_current": { "name": "Input L1 current" },
- "input_l2_current": { "name": "Input L2 current" },
- "input_l3_current": { "name": "Input L3 current" },
+ "input_current_status": {
+ "name": "Input current status",
+ "state": {
+ "good": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::good%]",
+ "warning-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-low%]",
+ "critical-low": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-low%]",
+ "warning-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::warning-high%]",
+ "critical-high": "[%key:component::nut::entity::sensor::ambient_humidity_status::state::critical-high%]"
+ }
+ },
"input_frequency": { "name": "Input frequency" },
"input_frequency_nominal": { "name": "Input nominal frequency" },
- "input_frequency_status": { "name": "Input frequency status" },
+ "input_frequency_status": {
+ "name": "Input frequency status",
+ "state": {
+ "good": "Good",
+ "out-of-range": "Out of range"
+ }
+ },
+ "input_l1_current": { "name": "Input L1 current" },
"input_l1_frequency": { "name": "Input L1 line frequency" },
- "input_l2_frequency": { "name": "Input L2 line frequency" },
- "input_l3_frequency": { "name": "Input L3 line frequency" },
- "input_phases": { "name": "Input phases" },
- "input_realpower": { "name": "Input real power" },
+ "input_l1_n_voltage": { "name": "Input L1 voltage" },
"input_l1_realpower": { "name": "Input L1 real power" },
+ "input_l2_current": { "name": "Input L2 current" },
+ "input_l2_frequency": { "name": "Input L2 line frequency" },
+ "input_l2_n_voltage": { "name": "Input L2 voltage" },
"input_l2_realpower": { "name": "Input L2 real power" },
+ "input_l3_current": { "name": "Input L3 current" },
+ "input_l3_frequency": { "name": "Input L3 line frequency" },
+ "input_l3_n_voltage": { "name": "Input L3 voltage" },
"input_l3_realpower": { "name": "Input L3 real power" },
+ "input_load": { "name": "Input load" },
+ "input_phases": { "name": "Input phases" },
+ "input_power": { "name": "Input power" },
+ "input_realpower": { "name": "Input real power" },
"input_sensitivity": { "name": "Input power sensitivity" },
"input_transfer_high": { "name": "High voltage transfer" },
"input_transfer_low": { "name": "Low voltage transfer" },
"input_transfer_reason": { "name": "Voltage transfer reason" },
"input_voltage": { "name": "Input voltage" },
"input_voltage_nominal": { "name": "Nominal input voltage" },
- "input_l1_n_voltage": { "name": "Input L1 voltage" },
- "input_l2_n_voltage": { "name": "Input L2 voltage" },
- "input_l3_n_voltage": { "name": "Input L3 voltage" },
+ "input_voltage_status": { "name": "Input voltage status" },
+ "outlet_number_current": { "name": "Outlet {outlet_name} current" },
+ "outlet_number_current_status": {
+ "name": "Outlet {outlet_name} current status"
+ },
+ "outlet_number_desc": { "name": "Outlet {outlet_name} description" },
+ "outlet_number_power": { "name": "Outlet {outlet_name} power" },
+ "outlet_number_realpower": { "name": "Outlet {outlet_name} real power" },
+ "outlet_voltage": { "name": "Outlet voltage" },
"output_current": { "name": "Output current" },
"output_current_nominal": { "name": "Nominal output current" },
- "output_l1_current": { "name": "Output L1 current" },
- "output_l2_current": { "name": "Output L2 current" },
- "output_l3_current": { "name": "Output L3 current" },
"output_frequency": { "name": "Output frequency" },
"output_frequency_nominal": { "name": "Nominal output frequency" },
+ "output_l1_current": { "name": "Output L1 current" },
+ "output_l1_n_voltage": { "name": "Output L1-N voltage" },
+ "output_l1_power_percent": { "name": "Output L1 power usage" },
+ "output_l1_realpower": { "name": "Output L1 real power" },
+ "output_l2_current": { "name": "Output L2 current" },
+ "output_l2_n_voltage": { "name": "Output L2-N voltage" },
+ "output_l2_power_percent": { "name": "Output L2 power usage" },
+ "output_l2_realpower": { "name": "Output L2 real power" },
+ "output_l3_current": { "name": "Output L3 current" },
+ "output_l3_n_voltage": { "name": "Output L3-N voltage" },
+ "output_l3_power_percent": { "name": "Output L3 power usage" },
+ "output_l3_realpower": { "name": "Output L3 real power" },
"output_phases": { "name": "Output phases" },
"output_power": { "name": "Output apparent power" },
- "output_l2_power_percent": { "name": "Output L2 power usage" },
- "output_l1_power_percent": { "name": "Output L1 power usage" },
- "output_l3_power_percent": { "name": "Output L3 power usage" },
"output_power_nominal": { "name": "Nominal output power" },
"output_realpower": { "name": "Output real power" },
"output_realpower_nominal": { "name": "Nominal output real power" },
- "output_l1_realpower": { "name": "Output L1 real power" },
- "output_l2_realpower": { "name": "Output L2 real power" },
- "output_l3_realpower": { "name": "Output L3 real power" },
"output_voltage": { "name": "Output voltage" },
"output_voltage_nominal": { "name": "Nominal output voltage" },
- "output_l1_n_voltage": { "name": "Output L1-N voltage" },
- "output_l2_n_voltage": { "name": "Output L2-N voltage" },
- "output_l3_n_voltage": { "name": "Output L3-N voltage" },
"ups_alarm": { "name": "Alarms" },
- "ups_beeper_status": { "name": "Beeper status" },
+ "ups_beeper_status": {
+ "name": "Beeper status",
+ "state": {
+ "enabled": "[%key:common::state::enabled%]",
+ "disabled": "[%key:common::state::disabled%]",
+ "muted": "Muted"
+ }
+ },
"ups_contacts": { "name": "External contacts" },
"ups_delay_reboot": { "name": "UPS reboot delay" },
"ups_delay_shutdown": { "name": "UPS shutdown delay" },
@@ -201,8 +290,24 @@
"ups_timer_shutdown": { "name": "Load shutdown timer" },
"ups_timer_start": { "name": "Load start timer" },
"ups_type": { "name": "UPS type" },
- "ups_watchdog_status": { "name": "Watchdog status" },
- "watts": { "name": "Watts" }
+ "ups_watchdog_status": { "name": "Watchdog status" }
+ },
+ "switch": {
+ "outlet_number_load_poweronoff": { "name": "Power outlet {outlet_name}" }
+ }
+ },
+ "exceptions": {
+ "data_fetch_error": {
+ "message": "Error fetching UPS state: {err}"
+ },
+ "device_authentication": {
+ "message": "Device authentication error: {err}"
+ },
+ "device_invalid": {
+ "message": "Unable to find a NUT device with ID {device_id}"
+ },
+ "nut_command_error": {
+ "message": "Error running command {command_name}, {err}"
}
}
}
diff --git a/homeassistant/components/nut/switch.py b/homeassistant/components/nut/switch.py
new file mode 100644
index 00000000000..924a596cc8e
--- /dev/null
+++ b/homeassistant/components/nut/switch.py
@@ -0,0 +1,90 @@
+"""Provides a switch for switchable NUT outlets."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from homeassistant.components.switch import (
+ SwitchDeviceClass,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import NutConfigEntry
+from .entity import NUTBaseEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: NutConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the NUT switches."""
+ pynut_data = config_entry.runtime_data
+ coordinator = pynut_data.coordinator
+ status = coordinator.data
+
+ # Dynamically add outlet switch types
+ if (num_outlets := status.get("outlet.count")) is None:
+ return
+
+ data = pynut_data.data
+ unique_id = pynut_data.unique_id
+ user_available_commands = pynut_data.user_available_commands
+ switch_descriptions = [
+ SwitchEntityDescription(
+ key=f"outlet.{outlet_num!s}.load.poweronoff",
+ translation_key="outlet_number_load_poweronoff",
+ translation_placeholders={
+ "outlet_name": status.get(f"outlet.{outlet_num!s}.name")
+ or str(outlet_num)
+ },
+ device_class=SwitchDeviceClass.OUTLET,
+ entity_registry_enabled_default=True,
+ )
+ for outlet_num in range(1, int(num_outlets) + 1)
+ if (
+ status.get(f"outlet.{outlet_num!s}.switchable") == "yes"
+ and f"outlet.{outlet_num!s}.load.on" in user_available_commands
+ and f"outlet.{outlet_num!s}.load.off" in user_available_commands
+ )
+ ]
+
+ async_add_entities(
+ NUTSwitch(coordinator, description, data, unique_id)
+ for description in switch_descriptions
+ )
+
+
+class NUTSwitch(NUTBaseEntity, SwitchEntity):
+ """Representation of a switch entity for NUT status values."""
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return the state of the switch."""
+ status = self.coordinator.data
+ outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2]
+ if (state := status.get(f"{outlet}.{outlet_num_str}.status")) is None:
+ return None
+ return bool(state == "on")
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the device."""
+
+ outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2]
+ command_name = f"{outlet}.{outlet_num_str}.load.on"
+ await self.pynut_data.async_run_command(command_name)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the device."""
+
+ outlet, outlet_num_str = self.entity_description.key.split(".", 2)[:2]
+ command_name = f"{outlet}.{outlet_num_str}.load.off"
+ await self.pynut_data.async_run_command(command_name)
diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py
index d1992056d47..8a7631d8381 100644
--- a/homeassistant/components/nws/sensor.py
+++ b/homeassistant/components/nws/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
TimestampDataUpdateCoordinator,
@@ -114,6 +114,8 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = (
icon="mdi:compass-rose",
native_unit_of_measurement=DEGREE,
unit_convert=DEGREE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
),
NWSSensorEntityDescription(
key="barometricPressure",
@@ -148,7 +150,9 @@ SENSOR_TYPES: tuple[NWSSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: NWSConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: NWSConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the NWS weather platform."""
nws_data = entry.runtime_data
diff --git a/homeassistant/components/nws/strings.json b/homeassistant/components/nws/strings.json
index c9ee8349631..72b6a2c86b6 100644
--- a/homeassistant/components/nws/strings.json
+++ b/homeassistant/components/nws/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, an API Key can be anything. It is recommended to use a valid email address.",
+ "description": "If a METAR station code is not specified, the latitude and longitude will be used to find the closest station. For now, the API key can be anything. It is recommended to use a valid email address.",
"title": "Connect to the National Weather Service",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
@@ -30,12 +30,12 @@
},
"services": {
"get_forecasts_extra": {
- "name": "Get extra forecasts data.",
- "description": "Get extra data for weather forecasts.",
+ "name": "Get extra forecasts data",
+ "description": "Retrieves extra data for weather forecasts.",
"fields": {
"type": {
"name": "Forecast type",
- "description": "Forecast type: hourly or twice_daily."
+ "description": "The scope of the weather forecast."
}
}
}
diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py
index d34a5abe8af..c90c67edcb7 100644
--- a/homeassistant/components/nws/weather.py
+++ b/homeassistant/components/nws/weather.py
@@ -40,7 +40,7 @@ from homeassistant.core import (
callback,
)
from homeassistant.helpers import entity_platform, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
from homeassistant.util.json import JsonValueType
from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter
@@ -87,7 +87,9 @@ def convert_condition(time: str, weather: tuple[tuple[str, int | None], ...]) ->
async def async_setup_entry(
- hass: HomeAssistant, entry: NWSConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: NWSConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the NWS weather platform."""
entity_registry = er.async_get(hass)
diff --git a/homeassistant/components/nyt_games/sensor.py b/homeassistant/components/nyt_games/sensor.py
index 4191c888ae1..5009eafd85a 100644
--- a/homeassistant/components/nyt_games/sensor.py
+++ b/homeassistant/components/nyt_games/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import NYTGamesConfigEntry, NYTGamesCoordinator
@@ -146,7 +146,7 @@ CONNECTIONS_SENSORS: tuple[NYTGamesConnectionsSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: NYTGamesConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up NYT Games sensor entities based on a config entry."""
diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py
index f6a4e4cc973..2328bf453f0 100644
--- a/homeassistant/components/nzbget/sensor.py
+++ b/homeassistant/components/nzbget/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, UnitOfDataRate, UnitOfInformation
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
@@ -93,7 +93,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up NZBGet sensor based on a config entry."""
coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py
index 552a1854902..0796f628507 100644
--- a/homeassistant/components/nzbget/switch.py
+++ b/homeassistant/components/nzbget/switch.py
@@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_COORDINATOR, DOMAIN
from .coordinator import NZBGetDataUpdateCoordinator
@@ -18,7 +18,7 @@ from .entity import NZBGetEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up NZBGet sensor based on a config entry."""
coordinator: NZBGetDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
diff --git a/homeassistant/components/obihai/button.py b/homeassistant/components/obihai/button.py
index d1b924b4693..9cef92d3fce 100644
--- a/homeassistant/components/obihai/button.py
+++ b/homeassistant/components/obihai/button.py
@@ -27,7 +27,7 @@ BUTTON_DESCRIPTION = ButtonEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: entity_platform.AddEntitiesCallback,
+ async_add_entities: entity_platform.AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Obihai sensor entries."""
diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py
index c162bd6c559..ec29238201a 100644
--- a/homeassistant/components/obihai/sensor.py
+++ b/homeassistant/components/obihai/sensor.py
@@ -9,7 +9,7 @@ from requests.exceptions import RequestException
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .connectivity import ObihaiConnection
from .const import DOMAIN, LOGGER, OBIHAI
@@ -18,7 +18,9 @@ SCAN_INTERVAL = datetime.timedelta(seconds=5)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Obihai sensor entries."""
diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py
index 10a637e5a3b..a20738de150 100644
--- a/homeassistant/components/octoprint/binary_sensor.py
+++ b/homeassistant/components/octoprint/binary_sensor.py
@@ -9,7 +9,7 @@ from pyoctoprintapi import OctoprintPrinterInfo
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import OctoprintDataUpdateCoordinator
@@ -19,7 +19,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the available OctoPrint binary sensors."""
coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py
index 2a2e5015303..3a128fcd7aa 100644
--- a/homeassistant/components/octoprint/button.py
+++ b/homeassistant/components/octoprint/button.py
@@ -6,7 +6,7 @@ from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import OctoprintDataUpdateCoordinator
@@ -16,7 +16,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Octoprint control buttons."""
coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/octoprint/camera.py b/homeassistant/components/octoprint/camera.py
index e6430c55fa2..37347539d5b 100644
--- a/homeassistant/components/octoprint/camera.py
+++ b/homeassistant/components/octoprint/camera.py
@@ -8,7 +8,7 @@ from homeassistant.components.mjpeg import MjpegCamera
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_VERIFY_SSL
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import OctoprintDataUpdateCoordinator
@@ -18,7 +18,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the available OctoPrint camera."""
coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/octoprint/config_flow.py b/homeassistant/components/octoprint/config_flow.py
index 010b45e5a1c..e20eea0a61f 100644
--- a/homeassistant/components/octoprint/config_flow.py
+++ b/homeassistant/components/octoprint/config_flow.py
@@ -85,7 +85,8 @@ class OctoPrintConfigFlow(ConfigFlow, domain=DOMAIN):
raise err from None
except CannotConnect:
errors["base"] = "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if errors:
diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py
index fb5f292d669..71db1d804c5 100644
--- a/homeassistant/components/octoprint/sensor.py
+++ b/homeassistant/components/octoprint/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import OctoprintDataUpdateCoordinator
@@ -38,7 +38,7 @@ def _is_printer_printing(printer: OctoprintPrinterInfo) -> bool:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the available OctoPrint binary sensors."""
coordinator: OctoprintDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/ogemray/__init__.py b/homeassistant/components/ogemray/__init__.py
new file mode 100644
index 00000000000..94e19234a6b
--- /dev/null
+++ b/homeassistant/components/ogemray/__init__.py
@@ -0,0 +1 @@
+"""Ogemray virtual integration."""
diff --git a/homeassistant/components/ogemray/manifest.json b/homeassistant/components/ogemray/manifest.json
new file mode 100644
index 00000000000..6a8eb315c7a
--- /dev/null
+++ b/homeassistant/components/ogemray/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "ogemray",
+ "name": "Ogemray",
+ "integration_type": "virtual",
+ "supported_by": "shelly"
+}
diff --git a/homeassistant/components/ohme/__init__.py b/homeassistant/components/ohme/__init__.py
index e3e252cbf8b..c304bfdf72d 100644
--- a/homeassistant/components/ohme/__init__.py
+++ b/homeassistant/components/ohme/__init__.py
@@ -6,6 +6,7 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, PLATFORMS
@@ -31,7 +32,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool:
"""Set up Ohme from a config entry."""
- client = OhmeApiClient(entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD])
+ client = OhmeApiClient(
+ email=entry.data[CONF_EMAIL],
+ password=entry.data[CONF_PASSWORD],
+ session=async_get_clientsession(hass),
+ )
try:
await client.async_login()
diff --git a/homeassistant/components/ohme/button.py b/homeassistant/components/ohme/button.py
index dad4416a29c..41782ea4a2d 100644
--- a/homeassistant/components/ohme/button.py
+++ b/homeassistant/components/ohme/button.py
@@ -2,15 +2,16 @@
from __future__ import annotations
-from collections.abc import Awaitable, Callable
+from collections.abc import Callable, Coroutine
from dataclasses import dataclass
+from typing import Any
from ohme import ApiException, ChargerStatus, OhmeApiClient
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import OhmeConfigEntry
@@ -23,7 +24,7 @@ PARALLEL_UPDATES = 1
class OhmeButtonDescription(OhmeEntityDescription, ButtonEntityDescription):
"""Class describing Ohme button entities."""
- press_fn: Callable[[OhmeApiClient], Awaitable[None]]
+ press_fn: Callable[[OhmeApiClient], Coroutine[Any, Any, bool]]
BUTTON_DESCRIPTIONS = [
@@ -40,7 +41,7 @@ BUTTON_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OhmeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up buttons."""
coordinator = config_entry.runtime_data.charge_session_coordinator
diff --git a/homeassistant/components/ohme/config_flow.py b/homeassistant/components/ohme/config_flow.py
index 748ea558983..1037c3a7c8b 100644
--- a/homeassistant/components/ohme/config_flow.py
+++ b/homeassistant/components/ohme/config_flow.py
@@ -99,6 +99,29 @@ class OhmeConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle re-configuration."""
+ errors: dict[str, str] = {}
+ reconfigure_entry = self._get_reconfigure_entry()
+ if user_input:
+ errors = await self._validate_account(
+ reconfigure_entry.data[CONF_EMAIL],
+ user_input[CONF_PASSWORD],
+ )
+ if not errors:
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data_updates=user_input,
+ )
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=REAUTH_SCHEMA,
+ description_placeholders={"email": reconfigure_entry.data[CONF_EMAIL]},
+ errors=errors,
+ )
+
async def _validate_account(self, email: str, password: str) -> dict[str, str]:
"""Validate Ohme account and return dict of errors."""
errors: dict[str, str] = {}
diff --git a/homeassistant/components/ohme/diagnostics.py b/homeassistant/components/ohme/diagnostics.py
new file mode 100644
index 00000000000..a955b3b76e2
--- /dev/null
+++ b/homeassistant/components/ohme/diagnostics.py
@@ -0,0 +1,24 @@
+"""Provides diagnostics for Ohme."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from .coordinator import OhmeConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: OhmeConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for Ohme."""
+ coordinators = config_entry.runtime_data
+ client = coordinators.charge_session_coordinator.client
+
+ return {
+ "device_info": client.device_info,
+ "vehicles": client.vehicles,
+ "ct_connected": client.ct_connected,
+ "cap_available": client.cap_available,
+ }
diff --git a/homeassistant/components/ohme/icons.json b/homeassistant/components/ohme/icons.json
index 7a27156b2fe..8613f2542c4 100644
--- a/homeassistant/components/ohme/icons.json
+++ b/homeassistant/components/ohme/icons.json
@@ -6,6 +6,9 @@
}
},
"number": {
+ "preconditioning_duration": {
+ "default": "mdi:fan-clock"
+ },
"target_percentage": {
"default": "mdi:battery-heart"
}
@@ -13,6 +16,9 @@
"select": {
"charge_mode": {
"default": "mdi:play-box"
+ },
+ "vehicle": {
+ "default": "mdi:car"
}
},
"sensor": {
@@ -28,6 +34,9 @@
},
"ct_current": {
"default": "mdi:gauge"
+ },
+ "slot_list": {
+ "default": "mdi:calendar-clock"
}
},
"switch": {
@@ -45,6 +54,9 @@
"state": {
"off": "mdi:sleep-off"
}
+ },
+ "price_cap": {
+ "default": "mdi:car-speed-limiter"
}
},
"time": {
@@ -56,6 +68,9 @@
"services": {
"list_charge_slots": {
"service": "mdi:clock-start"
+ },
+ "set_price_cap": {
+ "service": "mdi:car-speed-limiter"
}
}
}
diff --git a/homeassistant/components/ohme/manifest.json b/homeassistant/components/ohme/manifest.json
index 100967f819f..786c615d68a 100644
--- a/homeassistant/components/ohme/manifest.json
+++ b/homeassistant/components/ohme/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/ohme/",
"integration_type": "device",
"iot_class": "cloud_polling",
- "quality_scale": "silver",
- "requirements": ["ohme==1.2.9"]
+ "quality_scale": "platinum",
+ "requirements": ["ohme==1.5.1"]
}
diff --git a/homeassistant/components/ohme/number.py b/homeassistant/components/ohme/number.py
index 875f8c93bb3..f412c658085 100644
--- a/homeassistant/components/ohme/number.py
+++ b/homeassistant/components/ohme/number.py
@@ -1,15 +1,16 @@
"""Platform for number."""
-from collections.abc import Awaitable, Callable
+from collections.abc import Callable, Coroutine
from dataclasses import dataclass
+from typing import Any
from ohme import ApiException, OhmeApiClient
from homeassistant.components.number import NumberEntity, NumberEntityDescription
-from homeassistant.const import PERCENTAGE
+from homeassistant.const import PERCENTAGE, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import OhmeConfigEntry
@@ -22,7 +23,7 @@ PARALLEL_UPDATES = 1
class OhmeNumberDescription(OhmeEntityDescription, NumberEntityDescription):
"""Class describing Ohme number entities."""
- set_fn: Callable[[OhmeApiClient, float], Awaitable[None]]
+ set_fn: Callable[[OhmeApiClient, float], Coroutine[Any, Any, bool]]
value_fn: Callable[[OhmeApiClient], float]
@@ -31,19 +32,31 @@ NUMBER_DESCRIPTION = [
key="target_percentage",
translation_key="target_percentage",
value_fn=lambda client: client.target_soc,
- set_fn=lambda client, value: client.async_set_target(target_percent=value),
+ set_fn=lambda client, value: client.async_set_target(target_percent=int(value)),
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
),
+ OhmeNumberDescription(
+ key="preconditioning_duration",
+ translation_key="preconditioning_duration",
+ value_fn=lambda client: client.preconditioning,
+ set_fn=lambda client, value: client.async_set_target(
+ pre_condition_length=int(value)
+ ),
+ native_min_value=0,
+ native_max_value=60,
+ native_step=5,
+ native_unit_of_measurement=UnitOfTime.MINUTES,
+ ),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OhmeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up numbers."""
coordinators = config_entry.runtime_data
diff --git a/homeassistant/components/ohme/quality_scale.yaml b/homeassistant/components/ohme/quality_scale.yaml
index 497d5ad32e5..2f7aece5bb6 100644
--- a/homeassistant/components/ohme/quality_scale.yaml
+++ b/homeassistant/components/ohme/quality_scale.yaml
@@ -39,7 +39,7 @@ rules:
# Gold
devices: done
- diagnostics: todo
+ diagnostics: done
discovery:
status: exempt
comment: |
@@ -48,27 +48,33 @@ rules:
status: exempt
comment: |
All supported devices are cloud connected over mobile data. Discovery is not possible.
- docs-data-update: todo
- docs-examples: todo
- docs-known-limitations: todo
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
docs-supported-devices: done
- docs-supported-functions: todo
- docs-troubleshooting: todo
- docs-use-cases: todo
- dynamic-devices: todo
- entity-category: todo
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: |
+ Not supported by the API. Accounts and devices have a one-to-one relationship.
+ entity-category: done
entity-device-class: done
- entity-disabled-by-default: todo
+ entity-disabled-by-default: done
entity-translations: done
exception-translations: done
icon-translations: done
- reconfiguration-flow: todo
+ reconfiguration-flow: done
repair-issues:
status: exempt
comment: |
This integration currently has no repairs.
- stale-devices: todo
+ stale-devices:
+ status: exempt
+ comment: |
+ Not supported by the API. Accounts and devices have a one-to-one relationship.
# Platinum
- async-dependency: todo
- inject-websession: todo
- strict-typing: todo
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/ohme/select.py b/homeassistant/components/ohme/select.py
index 311d27f4bbb..d8d9c52c3b6 100644
--- a/homeassistant/components/ohme/select.py
+++ b/homeassistant/components/ohme/select.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from collections.abc import Awaitable, Callable
+from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, Final
@@ -11,7 +11,7 @@ from ohme import ApiException, ChargerMode, OhmeApiClient
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import OhmeConfigEntry
@@ -24,11 +24,13 @@ PARALLEL_UPDATES = 1
class OhmeSelectDescription(OhmeEntityDescription, SelectEntityDescription):
"""Class to describe an Ohme select entity."""
- select_fn: Callable[[OhmeApiClient, Any], Awaitable[None]]
+ select_fn: Callable[[OhmeApiClient, Any], Coroutine[Any, Any, bool | None]]
+ options: list[str] | None = None
+ options_fn: Callable[[OhmeApiClient], list[str]] | None = None
current_option_fn: Callable[[OhmeApiClient], str | None]
-SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription(
+MODE_SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription(
key="charge_mode",
translation_key="charge_mode",
select_fn=lambda client, mode: client.async_set_mode(mode),
@@ -37,16 +39,30 @@ SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription(
available_fn=lambda client: client.mode is not None,
)
+VEHICLE_SELECT_DESCRIPTION: Final[OhmeSelectDescription] = OhmeSelectDescription(
+ key="vehicle",
+ translation_key="vehicle",
+ select_fn=lambda client, selection: client.async_set_vehicle(selection),
+ options_fn=lambda client: client.vehicles,
+ current_option_fn=lambda client: client.current_vehicle or None,
+)
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OhmeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ohme selects."""
- coordinator = config_entry.runtime_data.charge_session_coordinator
+ charge_sessions_coordinator = config_entry.runtime_data.charge_session_coordinator
+ device_info_coordinator = config_entry.runtime_data.device_info_coordinator
- async_add_entities([OhmeSelect(coordinator, SELECT_DESCRIPTION)])
+ async_add_entities(
+ [
+ OhmeSelect(charge_sessions_coordinator, MODE_SELECT_DESCRIPTION),
+ OhmeSelect(device_info_coordinator, VEHICLE_SELECT_DESCRIPTION),
+ ]
+ )
class OhmeSelect(OhmeEntity, SelectEntity):
@@ -64,6 +80,14 @@ class OhmeSelect(OhmeEntity, SelectEntity):
) from e
await self.coordinator.async_request_refresh()
+ @property
+ def options(self) -> list[str]:
+ """Return a set of selectable options."""
+ if self.entity_description.options_fn:
+ return self.entity_description.options_fn(self.coordinator.client)
+ assert self.entity_description.options
+ return self.entity_description.options
+
@property
def current_option(self) -> str | None:
"""Return the current selected option."""
diff --git a/homeassistant/components/ohme/sensor.py b/homeassistant/components/ohme/sensor.py
index 8085f55068f..7047e33749f 100644
--- a/homeassistant/components/ohme/sensor.py
+++ b/homeassistant/components/ohme/sensor.py
@@ -15,12 +15,14 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
PERCENTAGE,
+ STATE_UNKNOWN,
UnitOfElectricCurrent,
+ UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import OhmeConfigEntry
from .entity import OhmeEntity, OhmeEntityDescription
@@ -32,7 +34,7 @@ PARALLEL_UPDATES = 0
class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription):
"""Class describing Ohme sensor entities."""
- value_fn: Callable[[OhmeApiClient], str | int | float]
+ value_fn: Callable[[OhmeApiClient], str | int | float | None]
SENSOR_CHARGE_SESSION = [
@@ -66,6 +68,13 @@ SENSOR_CHARGE_SESSION = [
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda client: client.energy,
),
+ OhmeSensorDescription(
+ key="voltage",
+ device_class=SensorDeviceClass.VOLTAGE,
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=lambda client: client.power.volts,
+ ),
OhmeSensorDescription(
key="battery",
translation_key="vehicle_battery",
@@ -74,6 +83,12 @@ SENSOR_CHARGE_SESSION = [
suggested_display_precision=0,
value_fn=lambda client: client.battery,
),
+ OhmeSensorDescription(
+ key="slot_list",
+ translation_key="slot_list",
+ value_fn=lambda client: ", ".join(str(x) for x in client.slots)
+ or STATE_UNKNOWN,
+ ),
]
SENSOR_ADVANCED_SETTINGS = [
@@ -84,6 +99,7 @@ SENSOR_ADVANCED_SETTINGS = [
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda client: client.power.ct_amps,
is_supported_fn=lambda client: client.ct_connected,
+ entity_registry_enabled_default=False,
),
]
@@ -91,7 +107,7 @@ SENSOR_ADVANCED_SETTINGS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OhmeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors."""
coordinators = config_entry.runtime_data
@@ -114,6 +130,6 @@ class OhmeSensor(OhmeEntity, SensorEntity):
entity_description: OhmeSensorDescription
@property
- def native_value(self) -> str | int | float:
+ def native_value(self) -> str | int | float | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.client)
diff --git a/homeassistant/components/ohme/services.py b/homeassistant/components/ohme/services.py
index 7d06b909d88..8ed29aa373d 100644
--- a/homeassistant/components/ohme/services.py
+++ b/homeassistant/components/ohme/services.py
@@ -5,7 +5,7 @@ from typing import Final
from ohme import OhmeApiClient
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import (
HomeAssistant,
ServiceCall,
@@ -16,10 +16,13 @@ from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import selector
from .const import DOMAIN
+from .coordinator import OhmeConfigEntry
+
+ATTR_CONFIG_ENTRY: Final = "config_entry"
+ATTR_PRICE_CAP: Final = "price_cap"
SERVICE_LIST_CHARGE_SLOTS = "list_charge_slots"
-ATTR_CONFIG_ENTRY: Final = "config_entry"
-SERVICE_SCHEMA: Final = vol.Schema(
+SERVICE_LIST_CHARGE_SLOTS_SCHEMA: Final = vol.Schema(
{
vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector(
{
@@ -29,11 +32,23 @@ SERVICE_SCHEMA: Final = vol.Schema(
}
)
+SERVICE_SET_PRICE_CAP = "set_price_cap"
+SERVICE_SET_PRICE_CAP_SCHEMA: Final = vol.Schema(
+ {
+ vol.Required(ATTR_CONFIG_ENTRY): selector.ConfigEntrySelector(
+ {
+ "integration": DOMAIN,
+ }
+ ),
+ vol.Required(ATTR_PRICE_CAP): vol.Coerce(float),
+ }
+)
+
def __get_client(call: ServiceCall) -> OhmeApiClient:
"""Get the client from the config entry."""
entry_id: str = call.data[ATTR_CONFIG_ENTRY]
- entry: ConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id)
+ entry: OhmeConfigEntry | None = call.hass.config_entries.async_get_entry(entry_id)
if not entry:
raise ServiceValidationError(
@@ -64,12 +79,28 @@ def async_setup_services(hass: HomeAssistant) -> None:
"""List of charge slots."""
client = __get_client(service_call)
- return {"slots": client.slots}
+ return {"slots": [slot.to_dict() for slot in client.slots]}
+
+ async def set_price_cap(
+ service_call: ServiceCall,
+ ) -> None:
+ """List of charge slots."""
+ client = __get_client(service_call)
+ price_cap = service_call.data[ATTR_PRICE_CAP]
+ await client.async_change_price_cap(cap=price_cap)
hass.services.async_register(
DOMAIN,
SERVICE_LIST_CHARGE_SLOTS,
list_charge_slots,
- schema=SERVICE_SCHEMA,
+ schema=SERVICE_LIST_CHARGE_SLOTS_SCHEMA,
supports_response=SupportsResponse.ONLY,
)
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_SET_PRICE_CAP,
+ set_price_cap,
+ schema=SERVICE_SET_PRICE_CAP_SCHEMA,
+ supports_response=SupportsResponse.NONE,
+ )
diff --git a/homeassistant/components/ohme/services.yaml b/homeassistant/components/ohme/services.yaml
index c5c8ee18138..a45bc131511 100644
--- a/homeassistant/components/ohme/services.yaml
+++ b/homeassistant/components/ohme/services.yaml
@@ -5,3 +5,16 @@ list_charge_slots:
selector:
config_entry:
integration: ohme
+set_price_cap:
+ fields:
+ config_entry:
+ required: true
+ selector:
+ config_entry:
+ integration: ohme
+ price_cap:
+ required: true
+ selector:
+ number:
+ min: 0
+ mode: box
diff --git a/homeassistant/components/ohme/strings.json b/homeassistant/components/ohme/strings.json
index eb5bbffda52..bcd9cfd17fe 100644
--- a/homeassistant/components/ohme/strings.json
+++ b/homeassistant/components/ohme/strings.json
@@ -21,6 +21,16 @@
"data_description": {
"password": "Enter the password for your Ohme account"
}
+ },
+ "reconfigure": {
+ "description": "Update your password for {email}",
+ "title": "Reconfigure Ohme Account",
+ "data": {
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "Enter the password for your Ohme account"
+ }
}
},
"error": {
@@ -29,7 +39,8 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
}
},
"services": {
@@ -42,6 +53,20 @@
"description": "The Ohme config entry for which to return charge slots."
}
}
+ },
+ "set_price_cap": {
+ "name": "Set price cap",
+ "description": "Prevents charging when the electricity price exceeds a defined threshold.",
+ "fields": {
+ "config_entry": {
+ "name": "Ohme account",
+ "description": "The Ohme config entry for which to return charge slots."
+ },
+ "price_cap": {
+ "name": "Price cap",
+ "description": "Threshold in 1/100ths of your local currency."
+ }
+ }
}
},
"entity": {
@@ -51,6 +76,9 @@
}
},
"number": {
+ "preconditioning_duration": {
+ "name": "Preconditioning duration"
+ },
"target_percentage": {
"name": "Target percentage"
}
@@ -61,19 +89,23 @@
"state": {
"smart_charge": "Smart charge",
"max_charge": "Max charge",
- "paused": "Paused"
+ "paused": "[%key:common::state::paused%]"
}
+ },
+ "vehicle": {
+ "name": "Vehicle"
}
},
"sensor": {
"status": {
"name": "Status",
"state": {
- "unplugged": "Unplugged",
- "plugged_in": "Plugged in",
- "charging": "Charging",
+ "unplugged": "[%key:component::binary_sensor::entity_component::plug::state::off%]",
+ "plugged_in": "[%key:component::binary_sensor::entity_component::plug::state::on%]",
+ "charging": "[%key:common::state::charging%]",
"paused": "[%key:common::state::paused%]",
- "pending_approval": "Pending approval"
+ "pending_approval": "Pending approval",
+ "finished": "Finished charging"
}
},
"ct_current": {
@@ -81,6 +113,9 @@
},
"vehicle_battery": {
"name": "Vehicle battery"
+ },
+ "slot_list": {
+ "name": "Charge slots"
}
},
"switch": {
@@ -92,6 +127,9 @@
},
"sleep_when_inactive": {
"name": "Sleep when inactive"
+ },
+ "price_cap": {
+ "name": "Price cap"
}
},
"time": {
@@ -102,7 +140,7 @@
},
"exceptions": {
"auth_failed": {
- "message": "Unable to login to Ohme"
+ "message": "Unable to log in to Ohme"
},
"device_info_failed": {
"message": "Unable to get Ohme device information"
diff --git a/homeassistant/components/ohme/switch.py b/homeassistant/components/ohme/switch.py
index d8b9fb52595..47e3bf8a99d 100644
--- a/homeassistant/components/ohme/switch.py
+++ b/homeassistant/components/ohme/switch.py
@@ -1,15 +1,16 @@
"""Platform for switch."""
+from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
-from ohme import ApiException
+from ohme import ApiException, OhmeApiClient
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import OhmeConfigEntry
@@ -19,28 +20,37 @@ PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
-class OhmeSwitchDescription(OhmeEntityDescription, SwitchEntityDescription):
- """Class describing Ohme switch entities."""
+class OhmeConfigSwitchDescription(OhmeEntityDescription, SwitchEntityDescription):
+ """Class describing Ohme configuration switch entities."""
configuration_key: str
-SWITCH_DEVICE_INFO = [
- OhmeSwitchDescription(
+@dataclass(frozen=True, kw_only=True)
+class OhmeSwitchDescription(OhmeEntityDescription, SwitchEntityDescription):
+ """Class describing basic Ohme switch entities."""
+
+ is_on_fn: Callable[[OhmeApiClient], bool]
+ off_fn: Callable[[OhmeApiClient], Awaitable]
+ on_fn: Callable[[OhmeApiClient], Awaitable]
+
+
+SWITCH_CONFIG = [
+ OhmeConfigSwitchDescription(
key="lock_buttons",
translation_key="lock_buttons",
entity_category=EntityCategory.CONFIG,
is_supported_fn=lambda client: client.is_capable("buttonsLockable"),
configuration_key="buttonsLocked",
),
- OhmeSwitchDescription(
+ OhmeConfigSwitchDescription(
key="require_approval",
translation_key="require_approval",
entity_category=EntityCategory.CONFIG,
is_supported_fn=lambda client: client.is_capable("pluginsRequireApprovalMode"),
configuration_key="pluginsRequireApproval",
),
- OhmeSwitchDescription(
+ OhmeConfigSwitchDescription(
key="sleep_when_inactive",
translation_key="sleep_when_inactive",
entity_category=EntityCategory.CONFIG,
@@ -49,22 +59,35 @@ SWITCH_DEVICE_INFO = [
),
]
+SWITCH_DESCRIPTION = [
+ OhmeSwitchDescription(
+ key="price_cap",
+ translation_key="price_cap",
+ is_supported_fn=lambda client: client.cap_available,
+ is_on_fn=lambda client: client.cap_enabled,
+ on_fn=lambda client: client.async_change_price_cap(True),
+ off_fn=lambda client: client.async_change_price_cap(False),
+ ),
+]
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OhmeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches."""
- coordinators = config_entry.runtime_data
- coordinator_map = [
- (SWITCH_DEVICE_INFO, coordinators.device_info_coordinator),
- ]
+ coordinator = config_entry.runtime_data.device_info_coordinator
+
+ async_add_entities(
+ OhmeConfigSwitch(coordinator, description)
+ for description in SWITCH_CONFIG
+ if description.is_supported_fn(coordinator.client)
+ )
async_add_entities(
OhmeSwitch(coordinator, description)
- for entities, coordinator in coordinator_map
- for description in entities
+ for description in SWITCH_DESCRIPTION
if description.is_supported_fn(coordinator.client)
)
@@ -74,6 +97,27 @@ class OhmeSwitch(OhmeEntity, SwitchEntity):
entity_description: OhmeSwitchDescription
+ @property
+ def is_on(self) -> bool:
+ """Return True if entity is on."""
+ return self.entity_description.is_on_fn(self.coordinator.client)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the switch off."""
+ await self.entity_description.off_fn(self.coordinator.client)
+ await self.coordinator.async_request_refresh()
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the switch on."""
+ await self.entity_description.on_fn(self.coordinator.client)
+ await self.coordinator.async_request_refresh()
+
+
+class OhmeConfigSwitch(OhmeEntity, SwitchEntity):
+ """Configuration switch for Ohme."""
+
+ entity_description: OhmeConfigSwitchDescription
+
@property
def is_on(self) -> bool:
"""Return the entity value to represent the entity state."""
diff --git a/homeassistant/components/ohme/time.py b/homeassistant/components/ohme/time.py
index be3da84ed72..a0b1edb594a 100644
--- a/homeassistant/components/ohme/time.py
+++ b/homeassistant/components/ohme/time.py
@@ -1,15 +1,16 @@
"""Platform for time."""
-from collections.abc import Awaitable, Callable
+from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from datetime import time
+from typing import Any
from ohme import ApiException, OhmeApiClient
from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import OhmeConfigEntry
@@ -22,7 +23,7 @@ PARALLEL_UPDATES = 1
class OhmeTimeDescription(OhmeEntityDescription, TimeEntityDescription):
"""Class describing Ohme time entities."""
- set_fn: Callable[[OhmeApiClient, time], Awaitable[None]]
+ set_fn: Callable[[OhmeApiClient, time], Coroutine[Any, Any, bool]]
value_fn: Callable[[OhmeApiClient], time]
@@ -43,7 +44,7 @@ TIME_DESCRIPTION = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OhmeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up time entities."""
coordinators = config_entry.runtime_data
diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py
index 1024a824c25..7379ea17ba6 100644
--- a/homeassistant/components/ollama/config_flow.py
+++ b/homeassistant/components/ollama/config_flow.py
@@ -215,8 +215,6 @@ class OllamaOptionsFlow(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
- if user_input[CONF_LLM_HASS_API] == "none":
- user_input.pop(CONF_LLM_HASS_API)
return self.async_create_entry(
title=_get_title(self.model), data=user_input
)
@@ -234,18 +232,12 @@ def ollama_config_option_schema(
) -> dict:
"""Ollama options schema."""
hass_apis: list[SelectOptionDict] = [
- SelectOptionDict(
- label="No control",
- value="none",
- )
- ]
- hass_apis.extend(
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
- )
+ ]
return {
vol.Optional(
@@ -259,8 +251,7 @@ def ollama_config_option_schema(
vol.Optional(
CONF_LLM_HASS_API,
description={"suggested_value": options.get(CONF_LLM_HASS_API)},
- default="none",
- ): SelectSelector(SelectSelectorConfig(options=hass_apis)),
+ ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Optional(
CONF_NUM_CTX,
description={"suggested_value": options.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)},
diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py
index c0fbfae6444..ab9e05b5fbe 100644
--- a/homeassistant/components/ollama/conversation.py
+++ b/homeassistant/components/ollama/conversation.py
@@ -2,25 +2,21 @@
from __future__ import annotations
-from collections.abc import Callable
+from collections.abc import AsyncGenerator, Callable
import json
import logging
-import time
from typing import Any, Literal
import ollama
-import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import assist_pipeline, conversation
-from homeassistant.components.conversation import trace
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError, TemplateError
-from homeassistant.helpers import intent, llm, template
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import ulid as ulid_util
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import intent, llm
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_KEEP_ALIVE,
@@ -32,7 +28,6 @@ from .const import (
DEFAULT_MAX_HISTORY,
DEFAULT_NUM_CTX,
DOMAIN,
- MAX_HISTORY_SECONDS,
)
from .models import MessageHistory, MessageRole
@@ -45,7 +40,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up conversation entities."""
agent = OllamaConversationEntity(config_entry)
@@ -93,6 +88,84 @@ def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]:
return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v}
+def _convert_content(
+ chat_content: conversation.Content
+ | conversation.ToolResultContent
+ | conversation.AssistantContent,
+) -> ollama.Message:
+ """Create tool response content."""
+ if isinstance(chat_content, conversation.ToolResultContent):
+ return ollama.Message(
+ role=MessageRole.TOOL.value,
+ content=json.dumps(chat_content.tool_result),
+ )
+ if isinstance(chat_content, conversation.AssistantContent):
+ return ollama.Message(
+ role=MessageRole.ASSISTANT.value,
+ content=chat_content.content,
+ tool_calls=[
+ ollama.Message.ToolCall(
+ function=ollama.Message.ToolCall.Function(
+ name=tool_call.tool_name,
+ arguments=tool_call.tool_args,
+ )
+ )
+ for tool_call in chat_content.tool_calls or ()
+ ],
+ )
+ if isinstance(chat_content, conversation.UserContent):
+ return ollama.Message(
+ role=MessageRole.USER.value,
+ content=chat_content.content,
+ )
+ if isinstance(chat_content, conversation.SystemContent):
+ return ollama.Message(
+ role=MessageRole.SYSTEM.value,
+ content=chat_content.content,
+ )
+ raise TypeError(f"Unexpected content type: {type(chat_content)}")
+
+
+async def _transform_stream(
+ result: AsyncGenerator[ollama.Message],
+) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
+ """Transform the response stream into HA format.
+
+ An Ollama streaming response may come in chunks like this:
+
+ response: message=Message(role="assistant", content="Paris")
+ response: message=Message(role="assistant", content=".")
+ response: message=Message(role="assistant", content=""), done: True, done_reason: "stop"
+ response: message=Message(role="assistant", tool_calls=[...])
+ response: message=Message(role="assistant", content=""), done: True, done_reason: "stop"
+
+ This generator conforms to the chatlog delta stream expectations in that it
+ yields deltas, then the role only once the response is done.
+ """
+
+ new_msg = True
+ async for response in result:
+ _LOGGER.debug("Received response: %s", response)
+ response_message = response["message"]
+ chunk: conversation.AssistantContentDeltaDict = {}
+ if new_msg:
+ new_msg = False
+ chunk["role"] = "assistant"
+ if (tool_calls := response_message.get("tool_calls")) is not None:
+ chunk["tool_calls"] = [
+ llm.ToolInput(
+ tool_name=tool_call["function"]["name"],
+ tool_args=_parse_tool_args(tool_call["function"]["arguments"]),
+ )
+ for tool_call in tool_calls
+ ]
+ if (content := response_message.get("content")) is not None:
+ chunk["content"] = content
+ if response_message.get("done"):
+ new_msg = True
+ yield chunk
+
+
class OllamaConversationEntity(
conversation.ConversationEntity, conversation.AbstractConversationAgent
):
@@ -105,7 +178,6 @@ class OllamaConversationEntity(
self.entry = entry
# conversation id -> message history
- self._history: dict[str, MessageHistory] = {}
self._attr_name = entry.title
self._attr_unique_id = entry.entry_id
if self.entry.options.get(CONF_LLM_HASS_API):
@@ -134,212 +206,106 @@ class OllamaConversationEntity(
"""Return a list of supported languages."""
return MATCH_ALL
- async def async_process(
- self, user_input: conversation.ConversationInput
+ async def _async_handle_message(
+ self,
+ user_input: conversation.ConversationInput,
+ chat_log: conversation.ChatLog,
) -> conversation.ConversationResult:
- """Process a sentence."""
+ """Call the API."""
settings = {**self.entry.data, **self.entry.options}
client = self.hass.data[DOMAIN][self.entry.entry_id]
- conversation_id = user_input.conversation_id or ulid_util.ulid_now()
model = settings[CONF_MODEL]
- intent_response = intent.IntentResponse(language=user_input.language)
- llm_api: llm.APIInstance | None = None
- tools: list[dict[str, Any]] | None = None
- user_name: str | None = None
- llm_context = llm.LLMContext(
- platform=DOMAIN,
- context=user_input.context,
- user_prompt=user_input.text,
- language=user_input.language,
- assistant=conversation.DOMAIN,
- device_id=user_input.device_id,
- )
- if settings.get(CONF_LLM_HASS_API):
- try:
- llm_api = await llm.async_get_api(
- self.hass,
- settings[CONF_LLM_HASS_API],
- llm_context,
- )
- except HomeAssistantError as err:
- _LOGGER.error("Error getting LLM API: %s", err)
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- f"Error preparing LLM API: {err}",
- )
- return conversation.ConversationResult(
- response=intent_response, conversation_id=user_input.conversation_id
- )
+ try:
+ await chat_log.async_update_llm_data(
+ DOMAIN,
+ user_input,
+ settings.get(CONF_LLM_HASS_API),
+ settings.get(CONF_PROMPT),
+ )
+ except conversation.ConverseError as err:
+ return err.as_conversation_result()
+
+ tools: list[dict[str, Any]] | None = None
+ if chat_log.llm_api:
tools = [
- _format_tool(tool, llm_api.custom_serializer) for tool in llm_api.tools
+ _format_tool(tool, chat_log.llm_api.custom_serializer)
+ for tool in chat_log.llm_api.tools
]
- if (
- user_input.context
- and user_input.context.user_id
- and (
- user := await self.hass.auth.async_get_user(user_input.context.user_id)
- )
- ):
- user_name = user.name
-
- # Look up message history
- message_history: MessageHistory | None = None
- message_history = self._history.get(conversation_id)
- if message_history is None:
- # New history
- #
- # Render prompt and error out early if there's a problem
- try:
- prompt_parts = [
- template.Template(
- llm.BASE_PROMPT
- + settings.get(CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT),
- self.hass,
- ).async_render(
- {
- "ha_name": self.hass.config.location_name,
- "user_name": user_name,
- "llm_context": llm_context,
- },
- parse_result=False,
- )
- ]
-
- except TemplateError as err:
- _LOGGER.error("Error rendering prompt: %s", err)
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- f"Sorry, I had a problem generating my prompt: {err}",
- )
- return conversation.ConversationResult(
- response=intent_response, conversation_id=conversation_id
- )
-
- if llm_api:
- prompt_parts.append(llm_api.api_prompt)
-
- prompt = "\n".join(prompt_parts)
- _LOGGER.debug("Prompt: %s", prompt)
- _LOGGER.debug("Tools: %s", tools)
-
- message_history = MessageHistory(
- timestamp=time.monotonic(),
- messages=[
- ollama.Message(role=MessageRole.SYSTEM.value, content=prompt)
- ],
- )
- self._history[conversation_id] = message_history
- else:
- # Bump timestamp so this conversation won't get cleaned up
- message_history.timestamp = time.monotonic()
-
- # Clean up old histories
- self._prune_old_histories()
-
- # Trim this message history to keep a maximum number of *user* messages
+ message_history: MessageHistory = MessageHistory(
+ [_convert_content(content) for content in chat_log.content]
+ )
max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY))
self._trim_history(message_history, max_messages)
- # Add new user message
- message_history.messages.append(
- ollama.Message(role=MessageRole.USER.value, content=user_input.text)
- )
-
- trace.async_conversation_trace_append(
- trace.ConversationTraceEventType.AGENT_DETAIL,
- {"messages": message_history.messages},
- )
-
# Get response
# To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS):
try:
- response = await client.chat(
+ response_generator = await client.chat(
model=model,
# Make a copy of the messages because we mutate the list later
messages=list(message_history.messages),
tools=tools,
- stream=False,
+ stream=True,
# keep_alive requires specifying unit. In this case, seconds
keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s",
options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)},
)
except (ollama.RequestError, ollama.ResponseError) as err:
_LOGGER.error("Unexpected error talking to Ollama server: %s", err)
- intent_response.async_set_error(
- intent.IntentResponseErrorCode.UNKNOWN,
- f"Sorry, I had a problem talking to the Ollama server: {err}",
- )
- return conversation.ConversationResult(
- response=intent_response, conversation_id=conversation_id
- )
+ raise HomeAssistantError(
+ f"Sorry, I had a problem talking to the Ollama server: {err}"
+ ) from err
- response_message = response["message"]
- message_history.messages.append(
- ollama.Message(
- role=response_message["role"],
- content=response_message.get("content"),
- tool_calls=response_message.get("tool_calls"),
- )
+ message_history.messages.extend(
+ [
+ _convert_content(content)
+ async for content in chat_log.async_add_delta_content_stream(
+ user_input.agent_id, _transform_stream(response_generator)
+ )
+ ]
)
- tool_calls = response_message.get("tool_calls")
- if not tool_calls or not llm_api:
+ if not chat_log.unresponded_tool_results:
break
- for tool_call in tool_calls:
- tool_input = llm.ToolInput(
- tool_name=tool_call["function"]["name"],
- tool_args=_parse_tool_args(tool_call["function"]["arguments"]),
- )
- _LOGGER.debug(
- "Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args
- )
-
- try:
- tool_response = await llm_api.async_call_tool(tool_input)
- except (HomeAssistantError, vol.Invalid) as e:
- tool_response = {"error": type(e).__name__}
- if str(e):
- tool_response["error_text"] = str(e)
-
- _LOGGER.debug("Tool response: %s", tool_response)
- message_history.messages.append(
- ollama.Message(
- role=MessageRole.TOOL.value,
- content=json.dumps(tool_response),
- )
- )
-
# Create intent response
- intent_response.async_set_speech(response_message["content"])
+ intent_response = intent.IntentResponse(language=user_input.language)
+ if not isinstance(chat_log.content[-1], conversation.AssistantContent):
+ raise TypeError(
+ f"Unexpected last message type: {type(chat_log.content[-1])}"
+ )
+ intent_response.async_set_speech(chat_log.content[-1].content or "")
return conversation.ConversationResult(
- response=intent_response, conversation_id=conversation_id
+ response=intent_response,
+ conversation_id=chat_log.conversation_id,
+ continue_conversation=chat_log.continue_conversation,
)
- def _prune_old_histories(self) -> None:
- """Remove old message histories."""
- now = time.monotonic()
- self._history = {
- conversation_id: message_history
- for conversation_id, message_history in self._history.items()
- if (now - message_history.timestamp) <= MAX_HISTORY_SECONDS
- }
-
def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None:
- """Trims excess messages from a single history."""
+ """Trims excess messages from a single history.
+
+ This sets the max history to allow a configurable size history may take
+ up in the context window.
+
+ Note that some messages in the history may not be from ollama only, and
+ may come from other anents, so the assumptions here may not strictly hold,
+ but generally should be effective.
+ """
if max_messages < 1:
# Keep all messages
return
- if message_history.num_user_messages >= max_messages:
+ # Ignore the in progress user message
+ num_previous_rounds = message_history.num_user_messages - 1
+ if num_previous_rounds >= max_messages:
# Trim history but keep system prompt (first message).
# Every other message should be an assistant message, so keep 2x
- # message objects.
- num_keep = 2 * max_messages
+ # message objects. Also keep the last in progress user message
+ num_keep = 2 * max_messages + 1
drop_index = len(message_history.messages) - num_keep
message_history.messages = [
message_history.messages[0]
diff --git a/homeassistant/components/ollama/models.py b/homeassistant/components/ollama/models.py
index 3b6fc958587..fd268664919 100644
--- a/homeassistant/components/ollama/models.py
+++ b/homeassistant/components/ollama/models.py
@@ -19,9 +19,6 @@ class MessageRole(StrEnum):
class MessageHistory:
"""Chat message history."""
- timestamp: float
- """Timestamp of last use in seconds."""
-
messages: list[ollama.Message]
"""List of message history, including system prompt and assistant responses."""
diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py
index c87b589e1f6..d941eb3ae4d 100644
--- a/homeassistant/components/omnilogic/sensor.py
+++ b/homeassistant/components/omnilogic/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import check_guard
from .const import COORDINATOR, DEFAULT_PH_OFFSET, DOMAIN, PUMP_TYPES
@@ -22,7 +22,9 @@ from .entity import OmniLogicEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
diff --git a/homeassistant/components/omnilogic/strings.json b/homeassistant/components/omnilogic/strings.json
index 5b193b7f5ba..6f207337789 100644
--- a/homeassistant/components/omnilogic/strings.json
+++ b/homeassistant/components/omnilogic/strings.json
@@ -34,7 +34,7 @@
"fields": {
"speed": {
"name": "Speed",
- "description": "Speed for the VSP between min and max speed."
+ "description": "Speed for the pump between min and max speed."
}
}
}
diff --git a/homeassistant/components/omnilogic/switch.py b/homeassistant/components/omnilogic/switch.py
index eb57d03bc34..a9f8bc77d8a 100644
--- a/homeassistant/components/omnilogic/switch.py
+++ b/homeassistant/components/omnilogic/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import check_guard
from .const import COORDINATOR, DOMAIN, PUMP_TYPES
@@ -22,7 +22,9 @@ OMNILOGIC_SWITCH_OFF = 7
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the light platform."""
diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py
index c11bd79c377..097cddd6603 100644
--- a/homeassistant/components/onboarding/__init__.py
+++ b/homeassistant/components/onboarding/__init__.py
@@ -21,6 +21,7 @@ from .const import (
STEP_USER,
STEPS,
)
+from .views import BaseOnboardingView, NoAuthBaseOnboardingView # noqa: F401
STORAGE_KEY = DOMAIN
STORAGE_VERSION = 4
diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json
index 8e253d4bff9..e57857896e0 100644
--- a/homeassistant/components/onboarding/manifest.json
+++ b/homeassistant/components/onboarding/manifest.json
@@ -1,9 +1,8 @@
{
"domain": "onboarding",
"name": "Home Assistant Onboarding",
- "after_dependencies": ["backup", "hassio"],
"codeowners": ["@home-assistant/core"],
- "dependencies": ["auth", "http", "person"],
+ "dependencies": ["auth", "http"],
"documentation": "https://www.home-assistant.io/integrations/onboarding",
"integration_type": "system",
"quality_scale": "internal"
diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py
index 1e29860e3c5..bbe198f0d2f 100644
--- a/homeassistant/components/onboarding/views.py
+++ b/homeassistant/components/onboarding/views.py
@@ -3,10 +3,9 @@
from __future__ import annotations
import asyncio
-from collections.abc import Callable, Coroutine
-from functools import wraps
from http import HTTPStatus
-from typing import TYPE_CHECKING, Any, Concatenate, cast
+import logging
+from typing import TYPE_CHECKING, Any, Protocol, cast
from aiohttp import web
from aiohttp.web_exceptions import HTTPUnauthorized
@@ -16,24 +15,14 @@ from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.providers.homeassistant import HassAuthProvider
from homeassistant.components import person
from homeassistant.components.auth import indieauth
-from homeassistant.components.backup import (
- BackupManager,
- Folder,
- IncorrectPasswordError,
- async_get_manager as async_get_backup_manager,
- http as backup_http,
-)
from homeassistant.components.http import KEY_HASS, KEY_HASS_REFRESH_TOKEN_ID
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import area_registry as ar
-from homeassistant.helpers.hassio import is_hassio
+from homeassistant.helpers import area_registry as ar, integration_platform
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.helpers.translation import async_get_translations
-from homeassistant.setup import async_setup_component
-from homeassistant.util.async_ import create_eager_task
+from homeassistant.setup import async_setup_component, async_wait_component
if TYPE_CHECKING:
from . import OnboardingData, OnboardingStorage, OnboardingStoreData
@@ -48,33 +37,76 @@ from .const import (
STEPS,
)
+_LOGGER = logging.getLogger(__name__)
+
async def async_setup(
hass: HomeAssistant, data: OnboardingStoreData, store: OnboardingStorage
) -> None:
"""Set up the onboarding view."""
- hass.http.register_view(OnboardingView(data, store))
+ await async_process_onboarding_platforms(hass)
+ hass.http.register_view(OnboardingStatusView(data, store))
hass.http.register_view(InstallationTypeOnboardingView(data))
hass.http.register_view(UserOnboardingView(data, store))
hass.http.register_view(CoreConfigOnboardingView(data, store))
hass.http.register_view(IntegrationOnboardingView(data, store))
hass.http.register_view(AnalyticsOnboardingView(data, store))
- hass.http.register_view(BackupInfoView(data))
- hass.http.register_view(RestoreBackupView(data))
- hass.http.register_view(UploadBackupView(data))
+ hass.http.register_view(WaitIntegrationOnboardingView(data))
-class OnboardingView(HomeAssistantView):
- """Return the onboarding status."""
+class OnboardingPlatformProtocol(Protocol):
+ """Define the format of onboarding platforms."""
+
+ async def async_setup_views(
+ self, hass: HomeAssistant, data: OnboardingStoreData
+ ) -> None:
+ """Set up onboarding views."""
+
+
+async def async_process_onboarding_platforms(hass: HomeAssistant) -> None:
+ """Start processing onboarding platforms."""
+ await integration_platform.async_process_integration_platforms(
+ hass, DOMAIN, _register_onboarding_platform, wait_for_platforms=False
+ )
+
+
+async def _register_onboarding_platform(
+ hass: HomeAssistant, integration_domain: str, platform: OnboardingPlatformProtocol
+) -> None:
+ """Register a onboarding platform."""
+ if not hasattr(platform, "async_setup_views"):
+ _LOGGER.debug(
+ "'%s.onboarding' is not a valid onboarding platform",
+ integration_domain,
+ )
+ return
+ await platform.async_setup_views(hass, hass.data[DOMAIN].steps)
+
+
+class BaseOnboardingView(HomeAssistantView):
+ """Base class for onboarding views."""
+
+ def __init__(self, data: OnboardingStoreData) -> None:
+ """Initialize the onboarding view."""
+ self._data = data
+
+
+class NoAuthBaseOnboardingView(BaseOnboardingView):
+ """Base class for unauthenticated onboarding views."""
requires_auth = False
+
+
+class OnboardingStatusView(NoAuthBaseOnboardingView):
+ """Return the onboarding status."""
+
url = "/api/onboarding"
name = "api:onboarding"
def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None:
"""Initialize the onboarding view."""
+ super().__init__(data)
self._store = store
- self._data = data
async def get(self, request: web.Request) -> web.Response:
"""Return the onboarding status."""
@@ -83,17 +115,12 @@ class OnboardingView(HomeAssistantView):
)
-class InstallationTypeOnboardingView(HomeAssistantView):
+class InstallationTypeOnboardingView(NoAuthBaseOnboardingView):
"""Return the installation type during onboarding."""
- requires_auth = False
url = "/api/onboarding/installation_type"
name = "api:onboarding:installation_type"
- def __init__(self, data: OnboardingStoreData) -> None:
- """Initialize the onboarding installation type view."""
- self._data = data
-
async def get(self, request: web.Request) -> web.Response:
"""Return the onboarding status."""
if self._data["done"]:
@@ -104,15 +131,15 @@ class InstallationTypeOnboardingView(HomeAssistantView):
return self.json({"installation_type": info["installation_type"]})
-class _BaseOnboardingView(HomeAssistantView):
- """Base class for onboarding."""
+class _BaseOnboardingStepView(BaseOnboardingView):
+ """Base class for an onboarding step."""
step: str
def __init__(self, data: OnboardingStoreData, store: OnboardingStorage) -> None:
"""Initialize the onboarding view."""
+ super().__init__(data)
self._store = store
- self._data = data
self._lock = asyncio.Lock()
@callback
@@ -132,7 +159,7 @@ class _BaseOnboardingView(HomeAssistantView):
listener()
-class UserOnboardingView(_BaseOnboardingView):
+class UserOnboardingView(_BaseOnboardingStepView):
"""View to handle create user onboarding step."""
url = "/api/onboarding/users"
@@ -198,7 +225,7 @@ class UserOnboardingView(_BaseOnboardingView):
return self.json({"auth_code": auth_code})
-class CoreConfigOnboardingView(_BaseOnboardingView):
+class CoreConfigOnboardingView(_BaseOnboardingStepView):
"""View to finish core config onboarding step."""
url = "/api/onboarding/core_config"
@@ -225,37 +252,26 @@ class CoreConfigOnboardingView(_BaseOnboardingView):
"shopping_list",
]
- # pylint: disable-next=import-outside-toplevel
- from homeassistant.components import hassio
-
- if (
- is_hassio(hass)
- and (core_info := hassio.get_core_info(hass))
- and "raspberrypi" in core_info["machine"]
- ):
- onboard_integrations.append("rpi_power")
-
- coros: list[Coroutine[Any, Any, Any]] = [
- hass.config_entries.flow.async_init(
- domain, context={"source": "onboarding"}
+ for domain in onboard_integrations:
+ # Create tasks so onboarding isn't affected
+ # by errors in these integrations.
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ domain, context={"source": "onboarding"}
+ ),
+ f"onboarding_setup_{domain}",
)
- for domain in onboard_integrations
- ]
if "analytics" not in hass.config.components:
# If by some chance that analytics has not finished
# setting up, wait for it here so its ready for the
# next step.
- coros.append(async_setup_component(hass, "analytics", {}))
-
- # Set up integrations after onboarding and ensure
- # analytics is ready for the next step.
- await asyncio.gather(*(create_eager_task(coro) for coro in coros))
+ await async_setup_component(hass, "analytics", {})
return self.json({})
-class IntegrationOnboardingView(_BaseOnboardingView):
+class IntegrationOnboardingView(_BaseOnboardingStepView):
"""View to finish integration onboarding step."""
url = "/api/onboarding/integration"
@@ -302,7 +318,31 @@ class IntegrationOnboardingView(_BaseOnboardingView):
return self.json({"auth_code": auth_code})
-class AnalyticsOnboardingView(_BaseOnboardingView):
+class WaitIntegrationOnboardingView(NoAuthBaseOnboardingView):
+ """Get backup info view."""
+
+ url = "/api/onboarding/integration/wait"
+ name = "api:onboarding:integration:wait"
+
+ @RequestDataValidator(
+ vol.Schema(
+ {
+ vol.Required("domain"): str,
+ }
+ )
+ )
+ async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
+ """Handle wait for integration command."""
+ hass = request.app[KEY_HASS]
+ domain = data["domain"]
+ return self.json(
+ {
+ "integration_loaded": await async_wait_component(hass, domain),
+ }
+ )
+
+
+class AnalyticsOnboardingView(_BaseOnboardingStepView):
"""View to finish analytics onboarding step."""
url = "/api/onboarding/analytics"
@@ -324,119 +364,6 @@ class AnalyticsOnboardingView(_BaseOnboardingView):
return self.json({})
-class BackupOnboardingView(HomeAssistantView):
- """Backup onboarding view."""
-
- requires_auth = False
-
- def __init__(self, data: OnboardingStoreData) -> None:
- """Initialize the view."""
- self._data = data
-
-
-def with_backup_manager[_ViewT: BackupOnboardingView, **_P](
- func: Callable[
- Concatenate[_ViewT, BackupManager, web.Request, _P],
- Coroutine[Any, Any, web.Response],
- ],
-) -> Callable[Concatenate[_ViewT, web.Request, _P], Coroutine[Any, Any, web.Response]]:
- """Home Assistant API decorator to check onboarding and inject manager."""
-
- @wraps(func)
- async def with_backup(
- self: _ViewT,
- request: web.Request,
- *args: _P.args,
- **kwargs: _P.kwargs,
- ) -> web.Response:
- """Check admin and call function."""
- if self._data["done"]:
- raise HTTPUnauthorized
-
- try:
- manager = async_get_backup_manager(request.app[KEY_HASS])
- except HomeAssistantError:
- return self.json(
- {"error": "backup_disabled"},
- status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
- )
-
- return await func(self, manager, request, *args, **kwargs)
-
- return with_backup
-
-
-class BackupInfoView(BackupOnboardingView):
- """Get backup info view."""
-
- url = "/api/onboarding/backup/info"
- name = "api:onboarding:backup:info"
-
- @with_backup_manager
- async def get(self, manager: BackupManager, request: web.Request) -> web.Response:
- """Return backup info."""
- backups, _ = await manager.async_get_backups()
- return self.json(
- {
- "backups": list(backups.values()),
- "state": manager.state,
- "last_non_idle_event": manager.last_non_idle_event,
- }
- )
-
-
-class RestoreBackupView(BackupOnboardingView):
- """Restore backup view."""
-
- url = "/api/onboarding/backup/restore"
- name = "api:onboarding:backup:restore"
-
- @RequestDataValidator(
- vol.Schema(
- {
- vol.Required("backup_id"): str,
- vol.Required("agent_id"): str,
- vol.Optional("password"): str,
- vol.Optional("restore_addons"): [str],
- vol.Optional("restore_database", default=True): bool,
- vol.Optional("restore_folders"): [vol.Coerce(Folder)],
- }
- )
- )
- @with_backup_manager
- async def post(
- self, manager: BackupManager, request: web.Request, data: dict[str, Any]
- ) -> web.Response:
- """Restore a backup."""
- try:
- await manager.async_restore_backup(
- data["backup_id"],
- agent_id=data["agent_id"],
- password=data.get("password"),
- restore_addons=data.get("restore_addons"),
- restore_database=data["restore_database"],
- restore_folders=data.get("restore_folders"),
- restore_homeassistant=True,
- )
- except IncorrectPasswordError:
- return self.json(
- {"message": "incorrect_password"}, status_code=HTTPStatus.BAD_REQUEST
- )
- return web.Response(status=HTTPStatus.OK)
-
-
-class UploadBackupView(BackupOnboardingView, backup_http.UploadBackupView):
- """Upload backup view."""
-
- url = "/api/onboarding/backup/upload"
- name = "api:onboarding:backup:upload"
-
- @with_backup_manager
- async def post(self, manager: BackupManager, request: web.Request) -> web.Response:
- """Upload a backup file."""
- return await self._post(request)
-
-
@callback
def _async_get_hass_provider(hass: HomeAssistant) -> HassAuthProvider:
"""Get the Home Assistant auth provider."""
diff --git a/homeassistant/components/oncue/binary_sensor.py b/homeassistant/components/oncue/binary_sensor.py
index 961b082a5c5..8dc9ba1be6f 100644
--- a/homeassistant/components/oncue/binary_sensor.py
+++ b/homeassistant/components/oncue/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import OncueEntity
from .types import OncueConfigEntry
@@ -28,7 +28,7 @@ SENSOR_MAP = {description.key: description for description in SENSOR_TYPES}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OncueConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/oncue/sensor.py b/homeassistant/components/oncue/sensor.py
index a0f275ef692..669c34157d4 100644
--- a/homeassistant/components/oncue/sensor.py
+++ b/homeassistant/components/oncue/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .entity import OncueEntity
@@ -180,7 +180,7 @@ UNIT_MAPPINGS = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OncueConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/ondilo_ico/__init__.py b/homeassistant/components/ondilo_ico/__init__.py
index 9a1fac6aba4..93aadb5b6ea 100644
--- a/homeassistant/components/ondilo_ico/__init__.py
+++ b/homeassistant/components/ondilo_ico/__init__.py
@@ -1,34 +1,44 @@
"""The Ondilo ICO integration."""
+from homeassistant.components.application_credentials import (
+ ClientCredential,
+ async_import_client_credential,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_entry_oauth2_flow
+from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
+from homeassistant.helpers.typing import ConfigType
from .api import OndiloClient
-from .config_flow import OndiloIcoOAuth2FlowHandler
-from .const import DOMAIN
-from .coordinator import OndiloIcoCoordinator
-from .oauth_impl import OndiloOauth2Implementation
+from .const import DOMAIN, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET
+from .coordinator import OndiloIcoPoolsCoordinator
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.SENSOR]
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Ondilo ICO integration."""
+ # Import the default client credential.
+ await async_import_client_credential(
+ hass,
+ DOMAIN,
+ ClientCredential(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, name="Ondilo ICO"),
+ )
+
+ return True
+
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Ondilo ICO from a config entry."""
-
- OndiloIcoOAuth2FlowHandler.async_register_implementation(
- hass,
- OndiloOauth2Implementation(hass),
- )
-
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
- coordinator = OndiloIcoCoordinator(
+ coordinator = OndiloIcoPoolsCoordinator(
hass, entry, OndiloClient(hass, entry, implementation)
)
diff --git a/homeassistant/components/ondilo_ico/api.py b/homeassistant/components/ondilo_ico/api.py
index f6ab0baa576..696acf1b2d6 100644
--- a/homeassistant/components/ondilo_ico/api.py
+++ b/homeassistant/components/ondilo_ico/api.py
@@ -1,15 +1,12 @@
"""API for Ondilo ICO bound to Home Assistant OAuth."""
from asyncio import run_coroutine_threadsafe
-import logging
from ondilo import Ondilo
from homeassistant import config_entries, core
from homeassistant.helpers import config_entry_oauth2_flow
-_LOGGER = logging.getLogger(__name__)
-
class OndiloClient(Ondilo):
"""Provide Ondilo ICO authentication tied to an OAuth2 based config entry."""
diff --git a/homeassistant/components/ondilo_ico/application_credentials.py b/homeassistant/components/ondilo_ico/application_credentials.py
new file mode 100644
index 00000000000..5481a88bc1b
--- /dev/null
+++ b/homeassistant/components/ondilo_ico/application_credentials.py
@@ -0,0 +1,14 @@
+"""Application credentials platform for Ondilo ICO."""
+
+from homeassistant.components.application_credentials import AuthorizationServer
+from homeassistant.core import HomeAssistant
+
+from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
+
+
+async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
+ """Return authorization server."""
+ return AuthorizationServer(
+ authorize_url=OAUTH2_AUTHORIZE,
+ token_url=OAUTH2_TOKEN,
+ )
diff --git a/homeassistant/components/ondilo_ico/config_flow.py b/homeassistant/components/ondilo_ico/config_flow.py
index fe0b89e7258..6839d2089bf 100644
--- a/homeassistant/components/ondilo_ico/config_flow.py
+++ b/homeassistant/components/ondilo_ico/config_flow.py
@@ -3,11 +3,14 @@
import logging
from typing import Any
+from homeassistant.components.application_credentials import (
+ ClientCredential,
+ async_import_client_credential,
+)
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
-from .const import DOMAIN
-from .oauth_impl import OndiloOauth2Implementation
+from .const import DOMAIN, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET
class OndiloIcoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
@@ -18,14 +21,13 @@ class OndiloIcoOAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle a flow initialized by the user."""
- await self.async_set_unique_id(DOMAIN)
-
- self.async_register_implementation(
+ """Handle a flow start."""
+ # Import the default client credential.
+ await async_import_client_credential(
self.hass,
- OndiloOauth2Implementation(self.hass),
+ DOMAIN,
+ ClientCredential(OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET, name="Ondilo ICO"),
)
-
return await super().async_step_user(user_input)
@property
diff --git a/homeassistant/components/ondilo_ico/const.py b/homeassistant/components/ondilo_ico/const.py
index 3c947776857..8dec6072556 100644
--- a/homeassistant/components/ondilo_ico/const.py
+++ b/homeassistant/components/ondilo_ico/const.py
@@ -4,5 +4,5 @@ DOMAIN = "ondilo_ico"
OAUTH2_AUTHORIZE = "https://interop.ondilo.com/oauth2/authorize"
OAUTH2_TOKEN = "https://interop.ondilo.com/oauth2/token"
-OAUTH2_CLIENTID = "customer_api"
-OAUTH2_CLIENTSECRET = ""
+OAUTH2_CLIENT_ID = "customer_api"
+OAUTH2_CLIENT_SECRET = ""
diff --git a/homeassistant/components/ondilo_ico/coordinator.py b/homeassistant/components/ondilo_ico/coordinator.py
index 349dac7de72..7545f6d61e0 100644
--- a/homeassistant/components/ondilo_ico/coordinator.py
+++ b/homeassistant/components/ondilo_ico/coordinator.py
@@ -1,7 +1,10 @@
"""Define an object to coordinate fetching Ondilo ICO data."""
-from dataclasses import dataclass
-from datetime import timedelta
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass, field
+from datetime import datetime, timedelta
import logging
from typing import Any
@@ -9,25 +12,37 @@ from ondilo import OndiloError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util import dt as dt_util
from . import DOMAIN
from .api import OndiloClient
_LOGGER = logging.getLogger(__name__)
+TIME_TO_NEXT_UPDATE = timedelta(hours=1, minutes=5)
+UPDATE_LOCK = asyncio.Lock()
+
@dataclass
-class OndiloIcoData:
- """Class for storing the data."""
+class OndiloIcoPoolData:
+ """Store the pools the data."""
ico: dict[str, Any]
pool: dict[str, Any]
+ measures_coordinator: OndiloIcoMeasuresCoordinator = field(init=False)
+
+
+@dataclass
+class OndiloIcoMeasurementData:
+ """Store the measurement data for one pool."""
+
sensors: dict[str, Any]
-class OndiloIcoCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoData]]):
- """Class to manage fetching Ondilo ICO data from API."""
+class OndiloIcoPoolsCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoPoolData]]):
+ """Fetch Ondilo ICO pools data from API."""
config_entry: ConfigEntry
@@ -39,45 +54,138 @@ class OndiloIcoCoordinator(DataUpdateCoordinator[dict[str, OndiloIcoData]]):
hass,
logger=_LOGGER,
config_entry=config_entry,
- name=DOMAIN,
- update_interval=timedelta(hours=1),
+ name=f"{DOMAIN}_pools",
+ update_interval=timedelta(minutes=20),
)
self.api = api
+ self.config_entry = config_entry
+ self._device_registry = dr.async_get(self.hass)
- async def _async_update_data(self) -> dict[str, OndiloIcoData]:
- """Fetch data from API endpoint."""
+ async def _async_update_data(self) -> dict[str, OndiloIcoPoolData]:
+ """Fetch pools data from API endpoint and update devices."""
+ known_pools: set[str] = set(self.data) if self.data else set()
try:
- return await self.hass.async_add_executor_job(self._update_data)
+ async with UPDATE_LOCK:
+ data = await self.hass.async_add_executor_job(self._update_data)
except OndiloError as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err
- def _update_data(self) -> dict[str, OndiloIcoData]:
- """Fetch data from API endpoint."""
+ current_pools = set(data)
+
+ new_pools = current_pools - known_pools
+ for pool_id in new_pools:
+ pool_data = data[pool_id]
+ pool_data.measures_coordinator = OndiloIcoMeasuresCoordinator(
+ self.hass, self.config_entry, self.api, pool_id
+ )
+ self._device_registry.async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ identifiers={(DOMAIN, pool_data.ico["serial_number"])},
+ manufacturer="Ondilo",
+ model="ICO",
+ name=pool_data.pool["name"],
+ sw_version=pool_data.ico["sw_version"],
+ )
+
+ removed_pools = known_pools - current_pools
+ for pool_id in removed_pools:
+ pool_data = self.data.pop(pool_id)
+ await pool_data.measures_coordinator.async_shutdown()
+ device_entry = self._device_registry.async_get_device(
+ identifiers={(DOMAIN, pool_data.ico["serial_number"])}
+ )
+ if device_entry:
+ self._device_registry.async_update_device(
+ device_id=device_entry.id,
+ remove_config_entry_id=self.config_entry.entry_id,
+ )
+
+ for pool_id in current_pools:
+ pool_data = data[pool_id]
+ measures_coordinator = pool_data.measures_coordinator
+ measures_coordinator.set_next_refresh(pool_data)
+ if not measures_coordinator.data:
+ await measures_coordinator.async_refresh()
+
+ return data
+
+ def _update_data(self) -> dict[str, OndiloIcoPoolData]:
+ """Fetch pools data from API endpoint."""
res = {}
pools = self.api.get_pools()
_LOGGER.debug("Pools: %s", pools)
error: OndiloError | None = None
for pool in pools:
pool_id = pool["id"]
+ if (data := self.data) and pool_id in data:
+ pool_data = res[pool_id] = data[pool_id]
+ pool_data.pool = pool
+ # Skip requesting new ICO data for known pools
+ # to avoid unnecessary API calls.
+ continue
try:
ico = self.api.get_ICO_details(pool_id)
- if not ico:
- _LOGGER.debug(
- "The pool id %s does not have any ICO attached", pool_id
- )
- continue
- sensors = self.api.get_last_pool_measures(pool_id)
except OndiloError as err:
error = err
_LOGGER.debug("Error communicating with API for %s: %s", pool_id, err)
continue
- res[pool_id] = OndiloIcoData(
- ico=ico,
- pool=pool,
- sensors={sensor["data_type"]: sensor["value"] for sensor in sensors},
- )
+
+ if not ico:
+ _LOGGER.debug("The pool id %s does not have any ICO attached", pool_id)
+ continue
+
+ res[pool_id] = OndiloIcoPoolData(ico=ico, pool=pool)
if not res:
if error:
raise UpdateFailed(f"Error communicating with API: {error}") from error
- raise UpdateFailed("No data available")
return res
+
+
+class OndiloIcoMeasuresCoordinator(DataUpdateCoordinator[OndiloIcoMeasurementData]):
+ """Fetch Ondilo ICO measurement data for one pool from API."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ api: OndiloClient,
+ pool_id: str,
+ ) -> None:
+ """Initialize."""
+ super().__init__(
+ hass,
+ config_entry=config_entry,
+ logger=_LOGGER,
+ name=f"{DOMAIN}_measures_{pool_id}",
+ )
+ self.api = api
+ self._next_refresh: datetime | None = None
+ self._pool_id = pool_id
+
+ async def _async_update_data(self) -> OndiloIcoMeasurementData:
+ """Fetch measurement data from API endpoint."""
+ async with UPDATE_LOCK:
+ data = await self.hass.async_add_executor_job(self._update_data)
+ if next_refresh := self._next_refresh:
+ now = dt_util.utcnow()
+ # If we've missed the next refresh, schedule a refresh in one hour.
+ if next_refresh <= now:
+ next_refresh = now + timedelta(hours=1)
+ self.update_interval = next_refresh - now
+
+ return data
+
+ def _update_data(self) -> OndiloIcoMeasurementData:
+ """Fetch measurement data from API endpoint."""
+ try:
+ sensors = self.api.get_last_pool_measures(self._pool_id)
+ except OndiloError as err:
+ raise UpdateFailed(f"Error communicating with API: {err}") from err
+ return OndiloIcoMeasurementData(
+ sensors={sensor["data_type"]: sensor["value"] for sensor in sensors},
+ )
+
+ def set_next_refresh(self, pool_data: OndiloIcoPoolData) -> None:
+ """Set next refresh of this coordinator."""
+ last_update = datetime.fromisoformat(pool_data.pool["updated_at"])
+ self._next_refresh = last_update + TIME_TO_NEXT_UPDATE
diff --git a/homeassistant/components/ondilo_ico/manifest.json b/homeassistant/components/ondilo_ico/manifest.json
index 84862a89fbb..3553797b9cd 100644
--- a/homeassistant/components/ondilo_ico/manifest.json
+++ b/homeassistant/components/ondilo_ico/manifest.json
@@ -3,7 +3,7 @@
"name": "Ondilo ICO",
"codeowners": ["@JeromeHXP"],
"config_flow": true,
- "dependencies": ["auth"],
+ "dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/ondilo_ico",
"integration_type": "hub",
"iot_class": "cloud_polling",
diff --git a/homeassistant/components/ondilo_ico/oauth_impl.py b/homeassistant/components/ondilo_ico/oauth_impl.py
deleted file mode 100644
index e1c6e6fdb90..00000000000
--- a/homeassistant/components/ondilo_ico/oauth_impl.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""Local implementation of OAuth2 specific to Ondilo to hard code client id and secret and return a proper name."""
-
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.config_entry_oauth2_flow import LocalOAuth2Implementation
-
-from .const import (
- DOMAIN,
- OAUTH2_AUTHORIZE,
- OAUTH2_CLIENTID,
- OAUTH2_CLIENTSECRET,
- OAUTH2_TOKEN,
-)
-
-
-class OndiloOauth2Implementation(LocalOAuth2Implementation):
- """Local implementation of OAuth2 specific to Ondilo to hard code client id and secret and return a proper name."""
-
- def __init__(self, hass: HomeAssistant) -> None:
- """Just init default class with default values."""
- super().__init__(
- hass,
- DOMAIN,
- OAUTH2_CLIENTID,
- OAUTH2_CLIENTSECRET,
- OAUTH2_AUTHORIZE,
- OAUTH2_TOKEN,
- )
-
- @property
- def name(self) -> str:
- """Name of the implementation."""
- return "Ondilo"
diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py
index 66b07335663..da5ccae11a5 100644
--- a/homeassistant/components/ondilo_ico/sensor.py
+++ b/homeassistant/components/ondilo_ico/sensor.py
@@ -12,17 +12,22 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
+ EntityCategory,
UnitOfElectricPotential,
UnitOfTemperature,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
-from .coordinator import OndiloIcoCoordinator, OndiloIcoData
+from .coordinator import (
+ OndiloIcoMeasuresCoordinator,
+ OndiloIcoPoolData,
+ OndiloIcoPoolsCoordinator,
+)
SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@@ -52,12 +57,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
key="battery",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="rssi",
translation_key="rssi",
native_unit_of_measurement=PERCENTAGE,
+ entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
@@ -70,53 +77,72 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ondilo ICO sensors."""
+ pools_coordinator: OndiloIcoPoolsCoordinator = hass.data[DOMAIN][entry.entry_id]
+ known_entities: set[str] = set()
- coordinator: OndiloIcoCoordinator = hass.data[DOMAIN][entry.entry_id]
+ async_add_entities(get_new_entities(pools_coordinator, known_entities))
- async_add_entities(
- OndiloICO(coordinator, pool_id, description)
- for pool_id, pool in coordinator.data.items()
- for description in SENSOR_TYPES
- if description.key in pool.sensors
- )
+ @callback
+ def add_new_entities():
+ """Add any new entities after update of the pools coordinator."""
+ async_add_entities(get_new_entities(pools_coordinator, known_entities))
+
+ entry.async_on_unload(pools_coordinator.async_add_listener(add_new_entities))
-class OndiloICO(CoordinatorEntity[OndiloIcoCoordinator], SensorEntity):
+@callback
+def get_new_entities(
+ pools_coordinator: OndiloIcoPoolsCoordinator,
+ known_entities: set[str],
+) -> list[OndiloICO]:
+ """Return new Ondilo ICO sensor entities."""
+ entities = []
+ for pool_id, pool_data in pools_coordinator.data.items():
+ for description in SENSOR_TYPES:
+ measurement_id = f"{pool_id}-{description.key}"
+ if (
+ measurement_id in known_entities
+ or (data := pool_data.measures_coordinator.data) is None
+ or description.key not in data.sensors
+ ):
+ continue
+ known_entities.add(measurement_id)
+ entities.append(
+ OndiloICO(
+ pool_data.measures_coordinator, description, pool_id, pool_data
+ )
+ )
+
+ return entities
+
+
+class OndiloICO(CoordinatorEntity[OndiloIcoMeasuresCoordinator], SensorEntity):
"""Representation of a Sensor."""
_attr_has_entity_name = True
def __init__(
self,
- coordinator: OndiloIcoCoordinator,
- pool_id: str,
+ coordinator: OndiloIcoMeasuresCoordinator,
description: SensorEntityDescription,
+ pool_id: str,
+ pool_data: OndiloIcoPoolData,
) -> None:
"""Initialize sensor entity with data from coordinator."""
super().__init__(coordinator)
self.entity_description = description
-
self._pool_id = pool_id
-
- data = self.pool_data
- self._attr_unique_id = f"{data.ico['serial_number']}-{description.key}"
+ self._attr_unique_id = f"{pool_data.ico['serial_number']}-{description.key}"
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, data.ico["serial_number"])},
- manufacturer="Ondilo",
- model="ICO",
- name=data.pool["name"],
- sw_version=data.ico["sw_version"],
+ identifiers={(DOMAIN, pool_data.ico["serial_number"])},
)
- @property
- def pool_data(self) -> OndiloIcoData:
- """Get pool data."""
- return self.coordinator.data[self._pool_id]
-
@property
def native_value(self) -> StateType:
"""Last value of the sensor."""
- return self.pool_data.sensors[self.entity_description.key]
+ return self.coordinator.data.sensors[self.entity_description.key]
diff --git a/homeassistant/components/onedrive/__init__.py b/homeassistant/components/onedrive/__init__.py
index 8355cddb0b5..f5d841683d5 100644
--- a/homeassistant/components/onedrive/__init__.py
+++ b/homeassistant/components/onedrive/__init__.py
@@ -3,7 +3,6 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
-from dataclasses import dataclass
from html import unescape
from json import dumps, loads
import logging
@@ -12,83 +11,89 @@ from typing import cast
from onedrive_personal_sdk import OneDriveClient
from onedrive_personal_sdk.exceptions import (
AuthenticationError,
- HttpRequestException,
+ NotFoundError,
OneDriveException,
)
-from onedrive_personal_sdk.models.items import ItemUpdate
+from onedrive_personal_sdk.models.items import Item, ItemUpdate
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_ACCESS_TOKEN
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.const import CONF_ACCESS_TOKEN, Platform
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
+from homeassistant.helpers.typing import ConfigType
-from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+from .coordinator import (
+ OneDriveConfigEntry,
+ OneDriveRuntimeData,
+ OneDriveUpdateCoordinator,
+)
+from .services import async_register_services
-
-@dataclass
-class OneDriveRuntimeData:
- """Runtime data for the OneDrive integration."""
-
- client: OneDriveClient
- token_function: Callable[[], Awaitable[str]]
- backup_folder_id: str
-
-
-type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+PLATFORMS = [Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the OneDrive integration."""
+ async_register_services(hass)
+ return True
+
+
async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
"""Set up OneDrive from a config entry."""
- implementation = await async_get_config_entry_implementation(hass, entry)
- session = OAuth2Session(hass, entry, implementation)
-
- async def get_access_token() -> str:
- await session.async_ensure_token_valid()
- return cast(str, session.token[CONF_ACCESS_TOKEN])
-
- client = OneDriveClient(get_access_token, async_get_clientsession(hass))
+ client, get_access_token = await _get_onedrive_client(hass, entry)
# get approot, will be created automatically if it does not exist
- try:
- approot = await client.get_approot()
- except AuthenticationError as err:
- raise ConfigEntryAuthFailed(
- translation_domain=DOMAIN, translation_key="authentication_failed"
- ) from err
- except (HttpRequestException, OneDriveException, TimeoutError) as err:
- _LOGGER.debug("Failed to get approot", exc_info=True)
- raise ConfigEntryNotReady(
- translation_domain=DOMAIN,
- translation_key="failed_to_get_folder",
- translation_placeholders={"folder": "approot"},
- ) from err
+ approot = await _handle_item_operation(client.get_approot, "approot")
+ folder_name = entry.data[CONF_FOLDER_NAME]
- instance_id = await async_get_instance_id(hass)
- backup_folder_name = f"backups_{instance_id[:8]}"
try:
- backup_folder = await client.create_folder(
- parent_id=approot.id, name=backup_folder_name
+ backup_folder = await _handle_item_operation(
+ lambda: client.get_drive_item(path_or_id=entry.data[CONF_FOLDER_ID]),
+ folder_name,
)
- except (HttpRequestException, OneDriveException, TimeoutError) as err:
- _LOGGER.debug("Failed to create backup folder", exc_info=True)
- raise ConfigEntryNotReady(
- translation_domain=DOMAIN,
- translation_key="failed_to_get_folder",
- translation_placeholders={"folder": backup_folder_name},
- ) from err
+ except NotFoundError:
+ _LOGGER.debug("Creating backup folder %s", folder_name)
+ backup_folder = await _handle_item_operation(
+ lambda: client.create_folder(parent_id=approot.id, name=folder_name),
+ folder_name,
+ )
+ hass.config_entries.async_update_entry(
+ entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id}
+ )
+
+ # write instance id to description
+ if backup_folder.description != (instance_id := await async_get_instance_id(hass)):
+ await _handle_item_operation(
+ lambda: client.update_drive_item(
+ backup_folder.id, ItemUpdate(description=instance_id)
+ ),
+ folder_name,
+ )
+
+ # update in case folder was renamed manually inside OneDrive
+ if backup_folder.name != entry.data[CONF_FOLDER_NAME]:
+ hass.config_entries.async_update_entry(
+ entry, data={**entry.data, CONF_FOLDER_NAME: backup_folder.name}
+ )
+
+ coordinator = OneDriveUpdateCoordinator(hass, entry, client)
+ await coordinator.async_config_entry_first_refresh()
entry.runtime_data = OneDriveRuntimeData(
client=client,
token_function=get_access_token,
backup_folder_id=backup_folder.id,
+ coordinator=coordinator,
)
try:
@@ -99,25 +104,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
translation_key="failed_to_migrate_files",
) from err
- _async_notify_backup_listeners_soon(hass)
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ def async_notify_backup_listeners() -> None:
+ for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
+ listener()
+
+ entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
return True
async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
"""Unload a OneDrive config entry."""
- _async_notify_backup_listeners_soon(hass)
- return True
-
-
-def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
- for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
- listener()
-
-
-@callback
-def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
- hass.loop.call_soon(_async_notify_backup_listeners, hass)
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -> None:
@@ -149,3 +149,74 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -
data=ItemUpdate(description=""),
)
_LOGGER.debug("Migrated backup file %s", file.name)
+
+
+async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
+ """Migrate old entry."""
+ if entry.version > 1:
+ # This means the user has downgraded from a future version
+ return False
+
+ if (version := entry.version) == 1 and (minor_version := entry.minor_version) == 1:
+ _LOGGER.debug(
+ "Migrating OneDrive config entry from version %s.%s", version, minor_version
+ )
+ client, _ = await _get_onedrive_client(hass, entry)
+ instance_id = await async_get_instance_id(hass)
+ try:
+ approot = await client.get_approot()
+ folder = await client.get_drive_item(
+ f"{approot.id}:/backups_{instance_id[:8]}:"
+ )
+ except OneDriveException:
+ _LOGGER.exception("Migration to version 1.2 failed")
+ return False
+
+ hass.config_entries.async_update_entry(
+ entry,
+ data={
+ **entry.data,
+ CONF_FOLDER_ID: folder.id,
+ CONF_FOLDER_NAME: f"backups_{instance_id[:8]}",
+ },
+ minor_version=2,
+ )
+ _LOGGER.debug("Migration to version 1.2 successful")
+ return True
+
+
+async def _get_onedrive_client(
+ hass: HomeAssistant, entry: OneDriveConfigEntry
+) -> tuple[OneDriveClient, Callable[[], Awaitable[str]]]:
+ """Get OneDrive client."""
+ implementation = await async_get_config_entry_implementation(hass, entry)
+ session = OAuth2Session(hass, entry, implementation)
+
+ async def get_access_token() -> str:
+ await session.async_ensure_token_valid()
+ return cast(str, session.token[CONF_ACCESS_TOKEN])
+
+ return (
+ OneDriveClient(get_access_token, async_get_clientsession(hass)),
+ get_access_token,
+ )
+
+
+async def _handle_item_operation(
+ func: Callable[[], Awaitable[Item]], folder: str
+) -> Item:
+ try:
+ return await func()
+ except NotFoundError:
+ raise
+ except AuthenticationError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN, translation_key="authentication_failed"
+ ) from err
+ except (OneDriveException, TimeoutError) as err:
+ _LOGGER.debug("Failed to get approot", exc_info=True)
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="failed_to_get_folder",
+ translation_placeholders={"folder": folder},
+ ) from err
diff --git a/homeassistant/components/onedrive/backup.py b/homeassistant/components/onedrive/backup.py
index 9926bd9cbc7..41a244506ea 100644
--- a/homeassistant/components/onedrive/backup.py
+++ b/homeassistant/components/onedrive/backup.py
@@ -3,10 +3,12 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Coroutine
+from dataclasses import dataclass
from functools import wraps
from html import unescape
from json import dumps, loads
import logging
+from time import time
from typing import Any, Concatenate
from aiohttp import ClientTimeout
@@ -16,25 +18,27 @@ from onedrive_personal_sdk.exceptions import (
HashMismatchError,
OneDriveException,
)
-from onedrive_personal_sdk.models.items import File, Folder, ItemUpdate
+from onedrive_personal_sdk.models.items import ItemUpdate
from onedrive_personal_sdk.models.upload import FileInfo
from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupAgentError,
+ BackupNotFound,
suggested_filename,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from . import OneDriveConfigEntry
-from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+from .const import CONF_DELETE_PERMANENTLY, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+from .coordinator import OneDriveConfigEntry
_LOGGER = logging.getLogger(__name__)
UPLOAD_CHUNK_SIZE = 16 * 320 * 1024 # 5.2MB
TIMEOUT = ClientTimeout(connect=10, total=43200) # 12 hours
METADATA_VERSION = 2
+CACHE_TTL = 300
async def async_get_backup_agents(
@@ -70,7 +74,7 @@ def async_register_backup_agents_listener(
def handle_backup_errors[_R, **P](
func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]:
- """Handle backup errors with a specific translation key."""
+ """Handle backup errors."""
@wraps(func)
async def wrapper(
@@ -99,6 +103,15 @@ def handle_backup_errors[_R, **P](
return wrapper
+@dataclass(kw_only=True)
+class OneDriveBackup:
+ """Define a OneDrive backup."""
+
+ backup: AgentBackup
+ backup_file_id: str
+ metadata_file_id: str
+
+
class OneDriveBackupAgent(BackupAgent):
"""OneDrive backup agent."""
@@ -115,24 +128,20 @@ class OneDriveBackupAgent(BackupAgent):
self.name = entry.title
assert entry.unique_id
self.unique_id = entry.unique_id
+ self._backup_cache: dict[str, OneDriveBackup] = {}
+ self._cache_expiration = time()
@handle_backup_errors
async def async_download_backup(
self, backup_id: str, **kwargs: Any
) -> AsyncIterator[bytes]:
"""Download a backup file."""
- metadata_item = await self._find_item_by_backup_id(backup_id)
- if (
- metadata_item is None
- or metadata_item.description is None
- or "backup_file_id" not in metadata_item.description
- ):
- raise BackupAgentError("Backup not found")
-
- metadata_info = loads(unescape(metadata_item.description))
+ backups = await self._list_cached_backups()
+ if backup_id not in backups:
+ raise BackupNotFound(f"Backup {backup_id} not found")
stream = await self._client.download_drive_item(
- metadata_info["backup_file_id"], timeout=TIMEOUT
+ backups[backup_id].backup_file_id, timeout=TIMEOUT
)
return stream.iter_chunked(1024)
@@ -181,6 +190,7 @@ class OneDriveBackupAgent(BackupAgent):
path_or_id=metadata_file.id,
data=ItemUpdate(description=dumps(metadata_description)),
)
+ self._cache_expiration = time()
@handle_backup_errors
async def async_delete_backup(
@@ -189,56 +199,60 @@ class OneDriveBackupAgent(BackupAgent):
**kwargs: Any,
) -> None:
"""Delete a backup file."""
- metadata_item = await self._find_item_by_backup_id(backup_id)
- if (
- metadata_item is None
- or metadata_item.description is None
- or "backup_file_id" not in metadata_item.description
- ):
- return
- metadata_info = loads(unescape(metadata_item.description))
+ backups = await self._list_cached_backups()
+ if backup_id not in backups:
+ raise BackupNotFound(f"Backup {backup_id} not found")
- await self._client.delete_drive_item(metadata_info["backup_file_id"])
- await self._client.delete_drive_item(metadata_item.id)
+ backup = backups[backup_id]
+
+ delete_permanently = self._entry.options.get(CONF_DELETE_PERMANENTLY, False)
+
+ await self._client.delete_drive_item(backup.backup_file_id, delete_permanently)
+ await self._client.delete_drive_item(
+ backup.metadata_file_id, delete_permanently
+ )
+ self._cache_expiration = time()
@handle_backup_errors
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
"""List backups."""
- items = await self._client.list_drive_items(self._folder_id)
return [
- await self._download_backup_metadata(item.id)
- for item in items
- if item.description
- and "backup_id" in item.description
- and f'"metadata_version": {METADATA_VERSION}' in unescape(item.description)
+ backup.backup for backup in (await self._list_cached_backups()).values()
]
@handle_backup_errors
- async def async_get_backup(
- self, backup_id: str, **kwargs: Any
- ) -> AgentBackup | None:
+ async def async_get_backup(self, backup_id: str, **kwargs: Any) -> AgentBackup:
"""Return a backup."""
- metadata_file = await self._find_item_by_backup_id(backup_id)
- if metadata_file is None or metadata_file.description is None:
- return None
+ backups = await self._list_cached_backups()
+ if backup_id not in backups:
+ raise BackupNotFound(f"Backup {backup_id} not found")
+ return backups[backup_id].backup
- return await self._download_backup_metadata(metadata_file.id)
+ async def _list_cached_backups(self) -> dict[str, OneDriveBackup]:
+ """List backups with a cache."""
+ if time() <= self._cache_expiration:
+ return self._backup_cache
- async def _find_item_by_backup_id(self, backup_id: str) -> File | Folder | None:
- """Find an item by backup ID."""
- return next(
- (
- item
- for item in await self._client.list_drive_items(self._folder_id)
- if item.description
- and backup_id in item.description
- and f'"metadata_version": {METADATA_VERSION}'
- in unescape(item.description)
- ),
- None,
- )
+ items = await self._client.list_drive_items(self._folder_id)
- async def _download_backup_metadata(self, item_id: str) -> AgentBackup:
- metadata_stream = await self._client.download_drive_item(item_id)
- metadata_json = loads(await metadata_stream.read())
- return AgentBackup.from_dict(metadata_json)
+ async def download_backup_metadata(item_id: str) -> AgentBackup:
+ metadata_stream = await self._client.download_drive_item(item_id)
+ metadata_json = loads(await metadata_stream.read())
+ return AgentBackup.from_dict(metadata_json)
+
+ backups: dict[str, OneDriveBackup] = {}
+ for item in items:
+ if item.description and f'"metadata_version": {METADATA_VERSION}' in (
+ metadata_description_json := unescape(item.description)
+ ):
+ backup = await download_backup_metadata(item.id)
+ metadata_description = loads(metadata_description_json)
+ backups[backup.backup_id] = OneDriveBackup(
+ backup=backup,
+ backup_file_id=metadata_description["backup_file_id"],
+ metadata_file_id=item.id,
+ )
+
+ self._cache_expiration = time() + CACHE_TTL
+ self._backup_cache = backups
+ return backups
diff --git a/homeassistant/components/onedrive/config_flow.py b/homeassistant/components/onedrive/config_flow.py
index 900db0177d9..3374c0369ee 100644
--- a/homeassistant/components/onedrive/config_flow.py
+++ b/homeassistant/components/onedrive/config_flow.py
@@ -1,24 +1,54 @@
"""Config flow for OneDrive."""
+from __future__ import annotations
+
from collections.abc import Mapping
import logging
from typing import Any, cast
from onedrive_personal_sdk.clients.client import OneDriveClient
from onedrive_personal_sdk.exceptions import OneDriveException
+from onedrive_personal_sdk.models.items import AppRoot, ItemUpdate
+import voluptuous as vol
-from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
+from homeassistant.config_entries import (
+ SOURCE_REAUTH,
+ SOURCE_RECONFIGURE,
+ SOURCE_USER,
+ ConfigFlowResult,
+ OptionsFlow,
+)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
+from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
+from homeassistant.helpers.instance_id import async_get as async_get_instance_id
-from .const import DOMAIN, OAUTH_SCOPES
+from .const import (
+ CONF_DELETE_PERMANENTLY,
+ CONF_FOLDER_ID,
+ CONF_FOLDER_NAME,
+ DOMAIN,
+ OAUTH_SCOPES,
+)
+from .coordinator import OneDriveConfigEntry
+
+FOLDER_NAME_SCHEMA = vol.Schema({vol.Required(CONF_FOLDER_NAME): str})
class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle OneDrive OAuth2 authentication."""
DOMAIN = DOMAIN
+ MINOR_VERSION = 2
+
+ client: OneDriveClient
+ approot: AppRoot
+
+ def __init__(self) -> None:
+ """Initialize the OneDrive config flow."""
+ super().__init__()
+ self.step_data: dict[str, Any] = {}
@property
def logger(self) -> logging.Logger:
@@ -30,6 +60,15 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Extra data that needs to be appended to the authorize url."""
return {"scope": " ".join(OAUTH_SCOPES)}
+ @property
+ def apps_folder(self) -> str:
+ """Return the name of the Apps folder (translated)."""
+ return (
+ path.split("/")[-1]
+ if (path := self.approot.parent_reference.path)
+ else "Apps"
+ )
+
async def async_oauth_create_entry(
self,
data: dict[str, Any],
@@ -39,12 +78,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
async def get_access_token() -> str:
return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
- graph_client = OneDriveClient(
+ self.client = OneDriveClient(
get_access_token, async_get_clientsession(self.hass)
)
try:
- approot = await graph_client.get_approot()
+ self.approot = await self.client.get_approot()
except OneDriveException:
self.logger.exception("Failed to connect to OneDrive")
return self.async_abort(reason="connection_error")
@@ -52,26 +91,118 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
self.logger.exception("Unknown error")
return self.async_abort(reason="unknown")
- await self.async_set_unique_id(approot.parent_reference.drive_id)
+ await self.async_set_unique_id(self.approot.parent_reference.drive_id)
- if self.source == SOURCE_REAUTH:
- reauth_entry = self._get_reauth_entry()
+ if self.source != SOURCE_USER:
self._abort_if_unique_id_mismatch(
reason="wrong_drive",
)
+
+ if self.source == SOURCE_REAUTH:
+ reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
entry=reauth_entry,
data=data,
)
- self._abort_if_unique_id_configured()
+ if self.source != SOURCE_RECONFIGURE:
+ self._abort_if_unique_id_configured()
- title = (
- f"{approot.created_by.user.display_name}'s OneDrive"
- if approot.created_by.user and approot.created_by.user.display_name
- else "OneDrive"
+ self.step_data = data
+
+ if self.source == SOURCE_RECONFIGURE:
+ return await self.async_step_reconfigure_folder()
+
+ return await self.async_step_folder_name()
+
+ async def async_step_folder_name(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Step to ask for the folder name."""
+ errors: dict[str, str] = {}
+ instance_id = await async_get_instance_id(self.hass)
+ if user_input is not None:
+ try:
+ folder = await self.client.create_folder(
+ self.approot.id, user_input[CONF_FOLDER_NAME]
+ )
+ except OneDriveException:
+ self.logger.debug("Failed to create folder", exc_info=True)
+ errors["base"] = "folder_creation_error"
+ else:
+ if folder.description and folder.description != instance_id:
+ errors[CONF_FOLDER_NAME] = "folder_already_in_use"
+ if not errors:
+ title = (
+ f"{self.approot.created_by.user.display_name}'s OneDrive"
+ if self.approot.created_by.user
+ and self.approot.created_by.user.display_name
+ else "OneDrive"
+ )
+ return self.async_create_entry(
+ title=title,
+ data={
+ **self.step_data,
+ CONF_FOLDER_ID: folder.id,
+ CONF_FOLDER_NAME: user_input[CONF_FOLDER_NAME],
+ },
+ )
+
+ default_folder_name = (
+ f"backups_{instance_id[:8]}"
+ if user_input is None
+ else user_input[CONF_FOLDER_NAME]
+ )
+
+ return self.async_show_form(
+ step_id="folder_name",
+ data_schema=self.add_suggested_values_to_schema(
+ FOLDER_NAME_SCHEMA, {CONF_FOLDER_NAME: default_folder_name}
+ ),
+ description_placeholders={
+ "apps_folder": self.apps_folder,
+ "approot": self.approot.name,
+ },
+ errors=errors,
+ )
+
+ async def async_step_reconfigure_folder(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Reconfigure the folder name."""
+ errors: dict[str, str] = {}
+ reconfigure_entry = self._get_reconfigure_entry()
+
+ if user_input is not None:
+ if (
+ new_folder_name := user_input[CONF_FOLDER_NAME]
+ ) != reconfigure_entry.data[CONF_FOLDER_NAME]:
+ try:
+ await self.client.update_drive_item(
+ reconfigure_entry.data[CONF_FOLDER_ID],
+ ItemUpdate(name=new_folder_name),
+ )
+ except OneDriveException:
+ self.logger.debug("Failed to update folder", exc_info=True)
+ errors["base"] = "folder_rename_error"
+ if not errors:
+ return self.async_update_reload_and_abort(
+ reconfigure_entry,
+ data={**reconfigure_entry.data, CONF_FOLDER_NAME: new_folder_name},
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure_folder",
+ data_schema=self.add_suggested_values_to_schema(
+ FOLDER_NAME_SCHEMA,
+ {CONF_FOLDER_NAME: reconfigure_entry.data[CONF_FOLDER_NAME]},
+ ),
+ description_placeholders={
+ "apps_folder": self.apps_folder,
+ "approot": self.approot.name,
+ },
+ errors=errors,
)
- return self.async_create_entry(title=title, data=data)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
@@ -86,3 +217,44 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
+
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Reconfigure the entry."""
+ return await self.async_step_user()
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: OneDriveConfigEntry,
+ ) -> OneDriveOptionsFlowHandler:
+ """Create the options flow."""
+ return OneDriveOptionsFlowHandler()
+
+
+class OneDriveOptionsFlowHandler(OptionsFlow):
+ """Handles options flow for the component."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Manage the options for OneDrive."""
+ if user_input:
+ return self.async_create_entry(title="", data=user_input)
+
+ options_schema = vol.Schema(
+ {
+ vol.Optional(
+ CONF_DELETE_PERMANENTLY,
+ default=self.config_entry.options.get(
+ CONF_DELETE_PERMANENTLY, False
+ ),
+ ): bool,
+ }
+ )
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=options_schema,
+ )
diff --git a/homeassistant/components/onedrive/const.py b/homeassistant/components/onedrive/const.py
index f9d49b141e5..fd21d84369c 100644
--- a/homeassistant/components/onedrive/const.py
+++ b/homeassistant/components/onedrive/const.py
@@ -6,6 +6,10 @@ from typing import Final
from homeassistant.util.hass_dict import HassKey
DOMAIN: Final = "onedrive"
+CONF_FOLDER_NAME: Final = "folder_name"
+CONF_FOLDER_ID: Final = "folder_id"
+
+CONF_DELETE_PERMANENTLY: Final = "delete_permanently"
# replace "consumers" with "common", when adding SharePoint or OneDrive for Business support
OAUTH2_AUTHORIZE: Final = (
diff --git a/homeassistant/components/onedrive/coordinator.py b/homeassistant/components/onedrive/coordinator.py
new file mode 100644
index 00000000000..3eb7d762712
--- /dev/null
+++ b/homeassistant/components/onedrive/coordinator.py
@@ -0,0 +1,95 @@
+"""Coordinator for OneDrive."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from datetime import timedelta
+import logging
+
+from onedrive_personal_sdk import OneDriveClient
+from onedrive_personal_sdk.const import DriveState
+from onedrive_personal_sdk.exceptions import AuthenticationError, OneDriveException
+from onedrive_personal_sdk.models.items import Drive
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import issue_registry as ir
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@dataclass
+class OneDriveRuntimeData:
+ """Runtime data for the OneDrive integration."""
+
+ client: OneDriveClient
+ token_function: Callable[[], Awaitable[str]]
+ backup_folder_id: str
+ coordinator: OneDriveUpdateCoordinator
+
+
+type OneDriveConfigEntry = ConfigEntry[OneDriveRuntimeData]
+
+
+class OneDriveUpdateCoordinator(DataUpdateCoordinator[Drive]):
+ """Class to handle fetching data from the Graph API centrally."""
+
+ config_entry: OneDriveConfigEntry
+
+ def __init__(
+ self, hass: HomeAssistant, entry: OneDriveConfigEntry, client: OneDriveClient
+ ) -> None:
+ """Initialize coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ config_entry=entry,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ )
+ self._client = client
+
+ async def _async_update_data(self) -> Drive:
+ """Fetch data from API endpoint."""
+
+ try:
+ drive = await self._client.get_drive()
+ except AuthenticationError as err:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN, translation_key="authentication_failed"
+ ) from err
+ except OneDriveException as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN, translation_key="update_failed"
+ ) from err
+
+ # create an issue if the drive is almost full
+ if drive.quota and (state := drive.quota.state) in (
+ DriveState.CRITICAL,
+ DriveState.EXCEEDED,
+ ):
+ key = "drive_full" if state is DriveState.EXCEEDED else "drive_almost_full"
+ ir.async_create_issue(
+ self.hass,
+ DOMAIN,
+ key,
+ is_fixable=False,
+ severity=(
+ ir.IssueSeverity.ERROR
+ if state is DriveState.EXCEEDED
+ else ir.IssueSeverity.WARNING
+ ),
+ translation_key=key,
+ translation_placeholders={
+ "total": f"{drive.quota.total / (1024**3):.2f}",
+ "used": f"{drive.quota.used / (1024**3):.2f}",
+ },
+ )
+ return drive
diff --git a/homeassistant/components/onedrive/diagnostics.py b/homeassistant/components/onedrive/diagnostics.py
new file mode 100644
index 00000000000..0e1ed94e155
--- /dev/null
+++ b/homeassistant/components/onedrive/diagnostics.py
@@ -0,0 +1,33 @@
+"""Diagnostics support for OneDrive."""
+
+from __future__ import annotations
+
+from dataclasses import asdict
+from typing import Any
+
+from homeassistant.components.diagnostics import async_redact_data
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
+from homeassistant.core import HomeAssistant
+
+from .coordinator import OneDriveConfigEntry
+
+TO_REDACT = {"display_name", "email", CONF_ACCESS_TOKEN, CONF_TOKEN}
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant,
+ entry: OneDriveConfigEntry,
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ coordinator = entry.runtime_data.coordinator
+
+ data = {
+ "drive": asdict(coordinator.data),
+ "config": {
+ **entry.data,
+ **entry.options,
+ },
+ }
+
+ return async_redact_data(data, TO_REDACT)
diff --git a/homeassistant/components/onedrive/icons.json b/homeassistant/components/onedrive/icons.json
new file mode 100644
index 00000000000..2ac4921439c
--- /dev/null
+++ b/homeassistant/components/onedrive/icons.json
@@ -0,0 +1,29 @@
+{
+ "entity": {
+ "sensor": {
+ "total_size": {
+ "default": "mdi:database"
+ },
+ "used_size": {
+ "default": "mdi:database"
+ },
+ "remaining_size": {
+ "default": "mdi:database"
+ },
+ "drive_state": {
+ "default": "mdi:harddisk",
+ "state": {
+ "normal": "mdi:harddisk",
+ "nearing": "mdi:alert-circle-outline",
+ "critical": "mdi:alert",
+ "exceeded": "mdi:alert-octagon"
+ }
+ }
+ }
+ },
+ "services": {
+ "upload": {
+ "service": "mdi:cloud-upload"
+ }
+ }
+}
diff --git a/homeassistant/components/onedrive/manifest.json b/homeassistant/components/onedrive/manifest.json
index fcc922b3e46..c3d98200b03 100644
--- a/homeassistant/components/onedrive/manifest.json
+++ b/homeassistant/components/onedrive/manifest.json
@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
- "quality_scale": "bronze",
- "requirements": ["onedrive-personal-sdk==0.0.9"]
+ "quality_scale": "platinum",
+ "requirements": ["onedrive-personal-sdk==0.0.13"]
}
diff --git a/homeassistant/components/onedrive/quality_scale.yaml b/homeassistant/components/onedrive/quality_scale.yaml
index f0d58d89c9a..1632c2670e0 100644
--- a/homeassistant/components/onedrive/quality_scale.yaml
+++ b/homeassistant/components/onedrive/quality_scale.yaml
@@ -1,21 +1,13 @@
rules:
# Bronze
- action-setup:
- status: exempt
- comment: Integration does not register custom actions.
- appropriate-polling:
- status: exempt
- comment: |
- This integration does not poll.
+ action-setup: done
+ appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
- docs-actions:
- status: exempt
- comment: |
- This integration does not have any custom actions.
+ docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
@@ -23,14 +15,8 @@ rules:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
- entity-unique-id:
- status: exempt
- comment: |
- This integration does not have entities.
- has-entity-name:
- status: exempt
- comment: |
- This integration does not have entities.
+ entity-unique-id: done
+ has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
@@ -39,36 +25,18 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
- docs-configuration-parameters:
- status: exempt
- comment: |
- No Options flow.
+ docs-configuration-parameters: done
docs-installation-parameters: done
- entity-unavailable:
- status: exempt
- comment: |
- This integration does not have entities.
+ entity-unavailable: done
integration-owner: done
- log-when-unavailable:
- status: exempt
- comment: |
- This integration does not have entities.
- parallel-updates:
- status: exempt
- comment: |
- This integration does not have platforms.
+ log-when-unavailable: done
+ parallel-updates: done
reauthentication-flow: done
- test-coverage: todo
+ test-coverage: done
# Gold
- devices:
- status: exempt
- comment: |
- This integration connects to a single service.
- diagnostics:
- status: exempt
- comment: |
- There is no data to diagnose.
+ devices: done
+ diagnostics: done
discovery-update-info:
status: exempt
comment: |
@@ -77,57 +45,27 @@ rules:
status: exempt
comment: |
This integration is a cloud service and does not support discovery.
- docs-data-update:
- status: exempt
- comment: |
- This integration does not poll or push.
- docs-examples:
- status: exempt
- comment: |
- This integration only serves backup.
+ docs-data-update: done
+ docs-examples: done
docs-known-limitations: done
docs-supported-devices:
status: exempt
comment: |
This integration is a cloud service.
- docs-supported-functions:
- status: exempt
- comment: |
- This integration does not have entities.
- docs-troubleshooting:
- status: exempt
- comment: |
- No issues known to troubleshoot.
+ docs-supported-functions: done
+ docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
This integration connects to a single service.
- entity-category:
- status: exempt
- comment: |
- This integration does not have entities.
- entity-device-class:
- status: exempt
- comment: |
- This integration does not have entities.
- entity-disabled-by-default:
- status: exempt
- comment: |
- This integration does not have entities.
- entity-translations:
- status: exempt
- comment: |
- This integration does not have entities.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
exception-translations: done
- icon-translations:
- status: exempt
- comment: |
- This integration does not have entities.
- reconfiguration-flow:
- status: exempt
- comment: |
- Nothing to reconfigure.
+ icon-translations: done
+ reconfiguration-flow: done
repair-issues: done
stale-devices:
status: exempt
diff --git a/homeassistant/components/onedrive/sensor.py b/homeassistant/components/onedrive/sensor.py
new file mode 100644
index 00000000000..fa7c0b125fe
--- /dev/null
+++ b/homeassistant/components/onedrive/sensor.py
@@ -0,0 +1,122 @@
+"""Sensors for OneDrive."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from onedrive_personal_sdk.const import DriveState
+from onedrive_personal_sdk.models.items import DriveQuota
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+)
+from homeassistant.const import EntityCategory, UnitOfInformation
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import OneDriveConfigEntry, OneDriveUpdateCoordinator
+
+PARALLEL_UPDATES = 0
+
+
+@dataclass(kw_only=True, frozen=True)
+class OneDriveSensorEntityDescription(SensorEntityDescription):
+ """Describes OneDrive sensor entity."""
+
+ value_fn: Callable[[DriveQuota], StateType]
+
+
+DRIVE_STATE_ENTITIES: tuple[OneDriveSensorEntityDescription, ...] = (
+ OneDriveSensorEntityDescription(
+ key="total_size",
+ value_fn=lambda quota: quota.total,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+ suggested_display_precision=0,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ entity_registry_enabled_default=False,
+ ),
+ OneDriveSensorEntityDescription(
+ key="used_size",
+ value_fn=lambda quota: quota.used,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ OneDriveSensorEntityDescription(
+ key="remaining_size",
+ value_fn=lambda quota: quota.remaining,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
+ suggested_display_precision=2,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ OneDriveSensorEntityDescription(
+ key="drive_state",
+ value_fn=lambda quota: quota.state.value,
+ options=[state.value for state in DriveState],
+ device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: OneDriveConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up OneDrive sensors based on a config entry."""
+ coordinator = entry.runtime_data.coordinator
+ async_add_entities(
+ OneDriveDriveStateSensor(coordinator, description)
+ for description in DRIVE_STATE_ENTITIES
+ )
+
+
+class OneDriveDriveStateSensor(
+ CoordinatorEntity[OneDriveUpdateCoordinator], SensorEntity
+):
+ """Define a OneDrive sensor."""
+
+ entity_description: OneDriveSensorEntityDescription
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: OneDriveUpdateCoordinator,
+ description: OneDriveSensorEntityDescription,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator)
+ self.entity_description = description
+ self._attr_translation_key = description.key
+ self._attr_unique_id = f"{coordinator.data.id}_{description.key}"
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ name=coordinator.data.name or coordinator.config_entry.title,
+ identifiers={(DOMAIN, coordinator.data.id)},
+ manufacturer="Microsoft",
+ model=f"OneDrive {coordinator.data.drive_type.value.capitalize()}",
+ configuration_url=f"https://onedrive.live.com/?id=root&cid={coordinator.data.id}",
+ )
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ assert self.coordinator.data.quota
+ return self.entity_description.value_fn(self.coordinator.data.quota)
+
+ @property
+ def available(self) -> bool:
+ """Availability of the sensor."""
+ return super().available and self.coordinator.data.quota is not None
diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py
new file mode 100644
index 00000000000..1f1afe1507c
--- /dev/null
+++ b/homeassistant/components/onedrive/services.py
@@ -0,0 +1,131 @@
+"""OneDrive services."""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import asdict
+from pathlib import Path
+from typing import cast
+
+from onedrive_personal_sdk.exceptions import OneDriveException
+import voluptuous as vol
+
+from homeassistant.const import CONF_FILENAME
+from homeassistant.core import (
+ HomeAssistant,
+ ServiceCall,
+ ServiceResponse,
+ SupportsResponse,
+)
+from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
+from homeassistant.helpers import config_validation as cv
+
+from .const import DOMAIN
+from .coordinator import OneDriveConfigEntry
+
+CONF_CONFIG_ENTRY_ID = "config_entry_id"
+CONF_DESTINATION_FOLDER = "destination_folder"
+
+UPLOAD_SERVICE = "upload"
+UPLOAD_SERVICE_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_CONFIG_ENTRY_ID): cv.string,
+ vol.Required(CONF_FILENAME): vol.All(cv.ensure_list, [cv.string]),
+ vol.Required(CONF_DESTINATION_FOLDER): cv.string,
+ }
+)
+CONTENT_SIZE_LIMIT = 250 * 1024 * 1024
+
+
+def _read_file_contents(
+ hass: HomeAssistant, filenames: list[str]
+) -> list[tuple[str, bytes]]:
+ """Return the mime types and file contents for each file."""
+ results = []
+ for filename in filenames:
+ if not hass.config.is_allowed_path(filename):
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="no_access_to_path",
+ translation_placeholders={"filename": filename},
+ )
+ filename_path = Path(filename)
+ if not filename_path.exists():
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="filename_does_not_exist",
+ translation_placeholders={"filename": filename},
+ )
+ if filename_path.stat().st_size > CONTENT_SIZE_LIMIT:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="file_too_large",
+ translation_placeholders={
+ "filename": filename,
+ "size": str(filename_path.stat().st_size),
+ "limit": str(CONTENT_SIZE_LIMIT),
+ },
+ )
+ results.append((filename_path.name, filename_path.read_bytes()))
+ return results
+
+
+def async_register_services(hass: HomeAssistant) -> None:
+ """Register OneDrive services."""
+
+ async def async_handle_upload(call: ServiceCall) -> ServiceResponse:
+ """Generate content from text and optionally images."""
+ config_entry: OneDriveConfigEntry | None = hass.config_entries.async_get_entry(
+ call.data[CONF_CONFIG_ENTRY_ID]
+ )
+ if not config_entry:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="integration_not_found",
+ translation_placeholders={"target": DOMAIN},
+ )
+ client = config_entry.runtime_data.client
+ upload_tasks = []
+ file_results = await hass.async_add_executor_job(
+ _read_file_contents, hass, call.data[CONF_FILENAME]
+ )
+
+ # make sure the destination folder exists
+ try:
+ folder_id = (await client.get_approot()).id
+ for folder in (
+ cast(str, call.data[CONF_DESTINATION_FOLDER]).strip("/").split("/")
+ ):
+ folder_id = (await client.create_folder(folder_id, folder)).id
+ except OneDriveException as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="create_folder_error",
+ translation_placeholders={"message": str(err)},
+ ) from err
+
+ upload_tasks = [
+ client.upload_file(folder_id, file_name, content)
+ for file_name, content in file_results
+ ]
+ try:
+ upload_results = await asyncio.gather(*upload_tasks)
+ except OneDriveException as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="upload_error",
+ translation_placeholders={"message": str(err)},
+ ) from err
+
+ if call.return_response:
+ return {"files": [asdict(item_result) for item_result in upload_results]}
+ return None
+
+ if not hass.services.has_service(DOMAIN, UPLOAD_SERVICE):
+ hass.services.async_register(
+ DOMAIN,
+ UPLOAD_SERVICE,
+ async_handle_upload,
+ schema=UPLOAD_SERVICE_SCHEMA,
+ supports_response=SupportsResponse.OPTIONAL,
+ )
diff --git a/homeassistant/components/onedrive/services.yaml b/homeassistant/components/onedrive/services.yaml
new file mode 100644
index 00000000000..0cf0faf6b60
--- /dev/null
+++ b/homeassistant/components/onedrive/services.yaml
@@ -0,0 +1,15 @@
+upload:
+ fields:
+ config_entry_id:
+ required: true
+ selector:
+ config_entry:
+ integration: onedrive
+ filename:
+ required: false
+ selector:
+ object:
+ destination_folder:
+ required: true
+ selector:
+ text:
diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json
index ebc46d3eb12..b8fa7f8189d 100644
--- a/homeassistant/components/onedrive/strings.json
+++ b/homeassistant/components/onedrive/strings.json
@@ -7,6 +7,26 @@
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The OneDrive integration needs to re-authenticate your account"
+ },
+ "folder_name": {
+ "title": "Pick a folder name",
+ "description": "This name will be used to create a folder that is specific for this Home Assistant instance. This folder will be created inside `{apps_folder}/{approot}`",
+ "data": {
+ "folder_name": "Folder name"
+ },
+ "data_description": {
+ "folder_name": "Name of the folder"
+ }
+ },
+ "reconfigure_folder": {
+ "title": "Change the folder name",
+ "description": "Rename the instance specific folder inside `{apps_folder}/{approot}`. This will only rename the folder (and does not select another folder), so make sure the new name is not already in use.",
+ "data": {
+ "folder_name": "[%key:component::onedrive::config::step::folder_name::data::folder_name%]"
+ },
+ "data_description": {
+ "folder_name": "[%key:component::onedrive::config::step::folder_name::data_description::folder_name%]"
+ }
}
},
"abort": {
@@ -23,10 +43,39 @@
"connection_error": "Failed to connect to OneDrive.",
"wrong_drive": "New account does not contain previously configured OneDrive.",
"unknown": "[%key:common::config_flow::error::unknown%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
+ },
+ "error": {
+ "folder_rename_error": "Failed to rename folder",
+ "folder_creation_error": "Failed to create folder",
+ "folder_already_in_use": "Folder already used for backups from another Home Assistant instance"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "description": "By default, files are put into the Recycle Bin when deleted, where they remain available for another 30 days. If you enable this option, files will be deleted immediately when they are cleaned up by the backup system.",
+ "data": {
+ "delete_permanently": "Delete files permanently"
+ },
+ "data_description": {
+ "delete_permanently": "Delete files without moving them to the Recycle Bin"
+ }
+ }
+ }
+ },
+ "issues": {
+ "drive_full": {
+ "title": "OneDrive data cap exceeded",
+ "description": "Your OneDrive has exceeded your quota limit. This means your next backup will fail. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB."
+ },
+ "drive_almost_full": {
+ "title": "OneDrive near data cap",
+ "description": "Your OneDrive is near your quota limit. If you go over this limit your drive will be temporarily frozen and your backups will start failing. Please free up some space or upgrade your OneDrive plan. Currently using {used} GiB of {total} GiB."
}
},
"exceptions": {
@@ -38,6 +87,71 @@
},
"failed_to_migrate_files": {
"message": "Failed to migrate metadata to separate files"
+ },
+ "update_failed": {
+ "message": "Failed to update drive state"
+ },
+ "integration_not_found": {
+ "message": "Integration \"{target}\" not found in registry."
+ },
+ "no_access_to_path": {
+ "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
+ },
+ "filename_does_not_exist": {
+ "message": "`{filename}` does not exist"
+ },
+ "file_too_large": {
+ "message": "`{filename}` is too large ({size} > {limit})"
+ },
+ "upload_error": {
+ "message": "Failed to upload content: {message}"
+ },
+ "create_folder_error": {
+ "message": "Failed to create folder: {message}"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "total_size": {
+ "name": "Total available storage"
+ },
+ "used_size": {
+ "name": "Used storage"
+ },
+ "remaining_size": {
+ "name": "Remaining storage"
+ },
+ "drive_state": {
+ "name": "Drive state",
+ "state": {
+ "normal": "[%key:common::state::normal%]",
+ "nearing": "Nearing limit",
+ "critical": "Critical",
+ "exceeded": "Exceeded"
+ }
+ }
+ }
+ },
+ "services": {
+ "upload": {
+ "name": "Upload file",
+ "description": "Uploads files to OneDrive.",
+ "fields": {
+ "config_entry_id": {
+ "name": "Config entry ID",
+ "description": "The config entry representing the OneDrive you want to upload to."
+ },
+ "filename": {
+ "name": "Filename",
+ "description": "Path to the file to upload.",
+ "example": "/config/www/image.jpg"
+ },
+ "destination_folder": {
+ "name": "Destination folder",
+ "description": "Folder inside the Home Assistant app folder (Apps/Home Assistant) you want to upload the file to. Will be created if it does not exist.",
+ "example": "photos/snapshots"
+ }
+ }
}
}
}
diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py
index 60a1d165b15..2bb393e48a8 100644
--- a/homeassistant/components/onewire/binary_sensor.py
+++ b/homeassistant/components/onewire/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL
from .entity import OneWireEntity, OneWireEntityDescription
@@ -101,7 +101,7 @@ def get_sensor_types(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OneWireConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
diff --git a/homeassistant/components/onewire/config_flow.py b/homeassistant/components/onewire/config_flow.py
index 8a5623772f7..2099d9aabb5 100644
--- a/homeassistant/components/onewire/config_flow.py
+++ b/homeassistant/components/onewire/config_flow.py
@@ -234,12 +234,7 @@ class OnewireOptionsFlowHandler(OptionsFlow):
INPUT_ENTRY_DEVICE_SELECTION,
default=self._get_current_configured_sensors(),
description="Multiselect with list of devices to choose from",
- ): cv.multi_select(
- {
- friendly_name: False
- for friendly_name in self.configurable_devices
- }
- ),
+ ): cv.multi_select(dict.fromkeys(self.configurable_devices, False)),
}
),
errors=errors,
diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py
index d65d7a90950..dc894a4242e 100644
--- a/homeassistant/components/onewire/onewirehub.py
+++ b/homeassistant/components/onewire/onewirehub.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import contextlib
from datetime import datetime, timedelta
import logging
import os
@@ -58,7 +59,7 @@ class OneWireHub:
owproxy: protocol._Proxy
devices: list[OWDeviceDescription]
- _version: str
+ _version: str | None = None
def __init__(self, hass: HomeAssistant, config_entry: OneWireConfigEntry) -> None:
"""Initialize."""
@@ -74,7 +75,9 @@ class OneWireHub:
port = self._config_entry.data[CONF_PORT]
_LOGGER.debug("Initializing connection to %s:%s", host, port)
self.owproxy = protocol.proxy(host, port)
- self._version = self.owproxy.read(protocol.PTH_VERSION).decode()
+ with contextlib.suppress(protocol.OwnetError):
+ # Version is not available on all servers
+ self._version = self.owproxy.read(protocol.PTH_VERSION).decode()
self.devices = _discover_devices(self.owproxy)
async def initialize(self) -> None:
diff --git a/homeassistant/components/onewire/select.py b/homeassistant/components/onewire/select.py
index 7a26ecdbb31..7f4111243aa 100644
--- a/homeassistant/components/onewire/select.py
+++ b/homeassistant/components/onewire/select.py
@@ -10,7 +10,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import READ_MODE_INT
from .entity import OneWireEntity, OneWireEntityDescription
@@ -48,7 +48,7 @@ ENTITY_DESCRIPTIONS: dict[str, tuple[OneWireEntityDescription, ...]] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OneWireConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py
index 04141f87847..5e1c7d35bd6 100644
--- a/homeassistant/components/onewire/sensor.py
+++ b/homeassistant/components/onewire/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
@@ -388,7 +388,7 @@ def get_sensor_types(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OneWireConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
diff --git a/homeassistant/components/onewire/strings.json b/homeassistant/components/onewire/strings.json
index 46f41503d97..5e7719673b1 100644
--- a/homeassistant/components/onewire/strings.json
+++ b/homeassistant/components/onewire/strings.json
@@ -140,14 +140,14 @@
"device_selection": "[%key:component::onewire::options::error::device_not_selected%]"
},
"description": "Select what configuration steps to process",
- "title": "OneWire Device Options"
+ "title": "1-Wire device options"
},
"configure_device": {
"data": {
- "precision": "Sensor Precision"
+ "precision": "Sensor precision"
},
"description": "Select sensor precision for {sensor_id}",
- "title": "OneWire Sensor Precision"
+ "title": "1-Wire sensor precision"
}
}
}
diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py
index 7215b1ec020..d2cc3b80185 100644
--- a/homeassistant/components/onewire/switch.py
+++ b/homeassistant/components/onewire/switch.py
@@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL
from .entity import OneWireEntity, OneWireEntityDescription
@@ -161,7 +161,7 @@ def get_sensor_types(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OneWireConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up 1-Wire platform."""
diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py
index fd5c0ba634a..2ebe86da561 100644
--- a/homeassistant/components/onkyo/__init__.py
+++ b/homeassistant/components/onkyo/__init__.py
@@ -1,6 +1,7 @@
"""The onkyo component."""
from dataclasses import dataclass
+import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
@@ -9,10 +10,18 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
-from .const import DOMAIN, OPTION_INPUT_SOURCES, InputSource
+from .const import (
+ DOMAIN,
+ OPTION_INPUT_SOURCES,
+ OPTION_LISTENING_MODES,
+ InputSource,
+ ListeningMode,
+)
from .receiver import Receiver, async_interview
from .services import DATA_MP_ENTITIES, async_register_services
+_LOGGER = logging.getLogger(__name__)
+
PLATFORMS = [Platform.MEDIA_PLAYER]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@@ -24,6 +33,7 @@ class OnkyoData:
receiver: Receiver
sources: dict[InputSource, str]
+ sound_modes: dict[ListeningMode, str]
type OnkyoConfigEntry = ConfigEntry[OnkyoData]
@@ -50,7 +60,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo
sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES]
sources = {InputSource(k): v for k, v in sources_store.items()}
- entry.runtime_data = OnkyoData(receiver, sources)
+ sound_modes_store: dict[str, str] = entry.options.get(OPTION_LISTENING_MODES, {})
+ sound_modes = {ListeningMode(k): v for k, v in sound_modes_store.items()}
+
+ entry.runtime_data = OnkyoData(receiver, sources, sound_modes)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py
index 228748d5257..85ff0de3251 100644
--- a/homeassistant/components/onkyo/config_flow.py
+++ b/homeassistant/components/onkyo/config_flow.py
@@ -1,5 +1,6 @@
"""Config flow for Onkyo."""
+from collections.abc import Mapping
import logging
from typing import Any
@@ -13,7 +14,7 @@ from homeassistant.config_entries import (
ConfigFlowResult,
OptionsFlow,
)
-from homeassistant.const import CONF_HOST, CONF_NAME
+from homeassistant.const import CONF_HOST
from homeassistant.core import callback
from homeassistant.data_entry_flow import section
from homeassistant.helpers.selector import (
@@ -29,16 +30,16 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from .const import (
- CONF_RECEIVER_MAX_VOLUME,
- CONF_SOURCES,
DOMAIN,
OPTION_INPUT_SOURCES,
+ OPTION_LISTENING_MODES,
OPTION_MAX_VOLUME,
OPTION_MAX_VOLUME_DEFAULT,
OPTION_VOLUME_RESOLUTION,
OPTION_VOLUME_RESOLUTION_DEFAULT,
VOLUME_RESOLUTION_ALLOWED,
InputSource,
+ ListeningMode,
)
from .receiver import ReceiverInfo, async_discover, async_interview
@@ -46,9 +47,14 @@ _LOGGER = logging.getLogger(__name__)
CONF_DEVICE = "device"
-INPUT_SOURCES_ALL_MEANINGS = [
- input_source.value_meaning for input_source in InputSource
-]
+INPUT_SOURCES_DEFAULT: dict[str, str] = {}
+LISTENING_MODES_DEFAULT: dict[str, str] = {}
+INPUT_SOURCES_ALL_MEANINGS = {
+ input_source.value_meaning: input_source for input_source in InputSource
+}
+LISTENING_MODES_ALL_MEANINGS = {
+ listening_mode.value_meaning: listening_mode for listening_mode in ListeningMode
+}
STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
STEP_RECONFIGURE_SCHEMA = vol.Schema(
{
@@ -59,7 +65,14 @@ STEP_CONFIGURE_SCHEMA = STEP_RECONFIGURE_SCHEMA.extend(
{
vol.Required(OPTION_INPUT_SOURCES): SelectSelector(
SelectSelectorConfig(
- options=INPUT_SOURCES_ALL_MEANINGS,
+ options=list(INPUT_SOURCES_ALL_MEANINGS),
+ multiple=True,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ ),
+ vol.Required(OPTION_LISTENING_MODES): SelectSelector(
+ SelectSelectorConfig(
+ options=list(LISTENING_MODES_ALL_MEANINGS),
multiple=True,
mode=SelectSelectorMode.DROPDOWN,
)
@@ -238,9 +251,8 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: self._receiver_info.host,
},
options={
+ **entry_options,
OPTION_VOLUME_RESOLUTION: volume_resolution,
- OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME],
- OPTION_INPUT_SOURCES: entry_options[OPTION_INPUT_SOURCES],
},
)
@@ -250,12 +262,24 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES]
if not input_source_meanings:
errors[OPTION_INPUT_SOURCES] = "empty_input_source_list"
- else:
+
+ listening_modes: list[str] = user_input[OPTION_LISTENING_MODES]
+ if not listening_modes:
+ errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list"
+
+ if not errors:
input_sources_store: dict[str, str] = {}
for input_source_meaning in input_source_meanings:
- input_source = InputSource.from_meaning(input_source_meaning)
+ input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning]
input_sources_store[input_source.value] = input_source_meaning
+ listening_modes_store: dict[str, str] = {}
+ for listening_mode_meaning in listening_modes:
+ listening_mode = LISTENING_MODES_ALL_MEANINGS[
+ listening_mode_meaning
+ ]
+ listening_modes_store[listening_mode.value] = listening_mode_meaning
+
result = self.async_create_entry(
title=self._receiver_info.model_name,
data={
@@ -265,6 +289,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
OPTION_VOLUME_RESOLUTION: volume_resolution,
OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT,
OPTION_INPUT_SOURCES: input_sources_store,
+ OPTION_LISTENING_MODES: listening_modes_store,
},
)
@@ -278,16 +303,13 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
if reconfigure_entry is None:
suggested_values = {
OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT,
- OPTION_INPUT_SOURCES: [],
+ OPTION_INPUT_SOURCES: INPUT_SOURCES_DEFAULT,
+ OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT,
}
else:
entry_options = reconfigure_entry.options
suggested_values = {
OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION],
- OPTION_INPUT_SOURCES: [
- InputSource(input_source).value_meaning
- for input_source in entry_options[OPTION_INPUT_SOURCES]
- ],
}
return self.async_show_form(
@@ -305,60 +327,6 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle reconfiguration of the receiver."""
return await self.async_step_manual()
- async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
- """Import the yaml config."""
- _LOGGER.debug("Import flow user input: %s", user_input)
-
- host: str = user_input[CONF_HOST]
- name: str | None = user_input.get(CONF_NAME)
- user_max_volume: int = user_input[OPTION_MAX_VOLUME]
- user_volume_resolution: int = user_input[CONF_RECEIVER_MAX_VOLUME]
- user_sources: dict[InputSource, str] = user_input[CONF_SOURCES]
-
- info: ReceiverInfo | None = user_input.get("info")
- if info is None:
- try:
- info = await async_interview(host)
- except Exception:
- _LOGGER.exception("Import flow interview error for host %s", host)
- return self.async_abort(reason="cannot_connect")
-
- if info is None:
- _LOGGER.error("Import flow interview error for host %s", host)
- return self.async_abort(reason="cannot_connect")
-
- unique_id = info.identifier
- await self.async_set_unique_id(unique_id)
- self._abort_if_unique_id_configured()
-
- name = name or info.model_name
-
- volume_resolution = VOLUME_RESOLUTION_ALLOWED[-1]
- for volume_resolution_allowed in VOLUME_RESOLUTION_ALLOWED:
- if user_volume_resolution <= volume_resolution_allowed:
- volume_resolution = volume_resolution_allowed
- break
-
- max_volume = min(
- 100, user_max_volume * user_volume_resolution / volume_resolution
- )
-
- sources_store: dict[str, str] = {}
- for source, source_name in user_sources.items():
- sources_store[source.value] = source_name
-
- return self.async_create_entry(
- title=name,
- data={
- CONF_HOST: host,
- },
- options={
- OPTION_VOLUME_RESOLUTION: volume_resolution,
- OPTION_MAX_VOLUME: max_volume,
- OPTION_INPUT_SOURCES: sources_store,
- },
- )
-
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
@@ -373,7 +341,14 @@ OPTIONS_STEP_INIT_SCHEMA = vol.Schema(
),
vol.Required(OPTION_INPUT_SOURCES): SelectSelector(
SelectSelectorConfig(
- options=INPUT_SOURCES_ALL_MEANINGS,
+ options=list(INPUT_SOURCES_ALL_MEANINGS),
+ multiple=True,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ ),
+ vol.Required(OPTION_LISTENING_MODES): SelectSelector(
+ SelectSelectorConfig(
+ options=list(LISTENING_MODES_ALL_MEANINGS),
multiple=True,
mode=SelectSelectorMode.DROPDOWN,
)
@@ -387,6 +362,7 @@ class OnkyoOptionsFlowHandler(OptionsFlow):
_data: dict[str, Any]
_input_sources: dict[InputSource, str]
+ _listening_modes: dict[ListeningMode, str]
async def async_step_init(
self, user_input: dict[str, Any] | None = None
@@ -394,20 +370,40 @@ class OnkyoOptionsFlowHandler(OptionsFlow):
"""Manage the options."""
errors = {}
- entry_options = self.config_entry.options
+ entry_options: Mapping[str, Any] = self.config_entry.options
+ entry_options = {
+ OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT,
+ **entry_options,
+ }
if user_input is not None:
- self._input_sources = {}
- for input_source_meaning in user_input[OPTION_INPUT_SOURCES]:
- input_source = InputSource.from_meaning(input_source_meaning)
- input_source_name = entry_options[OPTION_INPUT_SOURCES].get(
- input_source.value, input_source_meaning
- )
- self._input_sources[input_source] = input_source_name
-
- if not self._input_sources:
+ input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES]
+ if not input_source_meanings:
errors[OPTION_INPUT_SOURCES] = "empty_input_source_list"
- else:
+
+ listening_mode_meanings: list[str] = user_input[OPTION_LISTENING_MODES]
+ if not listening_mode_meanings:
+ errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list"
+
+ if not errors:
+ self._input_sources = {}
+ for input_source_meaning in input_source_meanings:
+ input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning]
+ input_source_name = entry_options[OPTION_INPUT_SOURCES].get(
+ input_source.value, input_source_meaning
+ )
+ self._input_sources[input_source] = input_source_name
+
+ self._listening_modes = {}
+ for listening_mode_meaning in listening_mode_meanings:
+ listening_mode = LISTENING_MODES_ALL_MEANINGS[
+ listening_mode_meaning
+ ]
+ listening_mode_name = entry_options[OPTION_LISTENING_MODES].get(
+ listening_mode.value, listening_mode_meaning
+ )
+ self._listening_modes[listening_mode] = listening_mode_name
+
self._data = {
OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION],
OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME],
@@ -423,6 +419,10 @@ class OnkyoOptionsFlowHandler(OptionsFlow):
InputSource(input_source).value_meaning
for input_source in entry_options[OPTION_INPUT_SOURCES]
],
+ OPTION_LISTENING_MODES: [
+ ListeningMode(listening_mode).value_meaning
+ for listening_mode in entry_options[OPTION_LISTENING_MODES]
+ ],
}
return self.async_show_form(
@@ -440,28 +440,48 @@ class OnkyoOptionsFlowHandler(OptionsFlow):
if user_input is not None:
input_sources_store: dict[str, str] = {}
for input_source_meaning, input_source_name in user_input[
- "input_sources"
+ OPTION_INPUT_SOURCES
].items():
- input_source = InputSource.from_meaning(input_source_meaning)
+ input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning]
input_sources_store[input_source.value] = input_source_name
+ listening_modes_store: dict[str, str] = {}
+ for listening_mode_meaning, listening_mode_name in user_input[
+ OPTION_LISTENING_MODES
+ ].items():
+ listening_mode = LISTENING_MODES_ALL_MEANINGS[listening_mode_meaning]
+ listening_modes_store[listening_mode.value] = listening_mode_name
+
return self.async_create_entry(
data={
**self._data,
OPTION_INPUT_SOURCES: input_sources_store,
+ OPTION_LISTENING_MODES: listening_modes_store,
}
)
- schema_dict: dict[Any, Selector] = {}
-
+ input_sources_schema_dict: dict[Any, Selector] = {}
for input_source, input_source_name in self._input_sources.items():
- schema_dict[
+ input_sources_schema_dict[
vol.Required(input_source.value_meaning, default=input_source_name)
] = TextSelector()
+ listening_modes_schema_dict: dict[Any, Selector] = {}
+ for listening_mode, listening_mode_name in self._listening_modes.items():
+ listening_modes_schema_dict[
+ vol.Required(listening_mode.value_meaning, default=listening_mode_name)
+ ] = TextSelector()
+
return self.async_show_form(
step_id="names",
data_schema=vol.Schema(
- {vol.Required("input_sources"): section(vol.Schema(schema_dict))}
+ {
+ vol.Required(OPTION_INPUT_SOURCES): section(
+ vol.Schema(input_sources_schema_dict)
+ ),
+ vol.Required(OPTION_LISTENING_MODES): section(
+ vol.Schema(listening_modes_schema_dict)
+ ),
+ }
),
)
diff --git a/homeassistant/components/onkyo/const.py b/homeassistant/components/onkyo/const.py
index bd4fe98ae7d..851d80c5100 100644
--- a/homeassistant/components/onkyo/const.py
+++ b/homeassistant/components/onkyo/const.py
@@ -2,7 +2,7 @@
from enum import Enum
import typing
-from typing import ClassVar, Literal, Self
+from typing import Literal, Self
import pyeiscp
@@ -11,9 +11,6 @@ DOMAIN = "onkyo"
DEVICE_INTERVIEW_TIMEOUT = 5
DEVICE_DISCOVERY_TIMEOUT = 5
-CONF_SOURCES = "sources"
-CONF_RECEIVER_MAX_VOLUME = "receiver_max_volume"
-
type VolumeResolution = Literal[50, 80, 100, 200]
OPTION_VOLUME_RESOLUTION = "volume_resolution"
OPTION_VOLUME_RESOLUTION_DEFAULT: VolumeResolution = 50
@@ -24,7 +21,27 @@ VOLUME_RESOLUTION_ALLOWED: tuple[VolumeResolution, ...] = typing.get_args(
OPTION_MAX_VOLUME = "max_volume"
OPTION_MAX_VOLUME_DEFAULT = 100.0
+
+class EnumWithMeaning(Enum):
+ """Enum with meaning."""
+
+ value_meaning: str
+
+ def __new__(cls, value: str) -> Self:
+ """Create enum."""
+ obj = object.__new__(cls)
+ obj._value_ = value
+ obj.value_meaning = cls._get_meanings()[value]
+
+ return obj
+
+ @staticmethod
+ def _get_meanings() -> dict[str, str]:
+ raise NotImplementedError
+
+
OPTION_INPUT_SOURCES = "input_sources"
+OPTION_LISTENING_MODES = "listening_modes"
_INPUT_SOURCE_MEANINGS = {
"00": "VIDEO1 ··· VCR/DVR ··· STB/DVR",
@@ -71,7 +88,7 @@ _INPUT_SOURCE_MEANINGS = {
}
-class InputSource(Enum):
+class InputSource(EnumWithMeaning):
"""Receiver input source."""
DVR = "00"
@@ -116,24 +133,100 @@ class InputSource(Enum):
HDMI_7 = "57"
MAIN_SOURCE = "80"
- __meaning_mapping: ClassVar[dict[str, Self]] = {} # type: ignore[misc]
+ @staticmethod
+ def _get_meanings() -> dict[str, str]:
+ return _INPUT_SOURCE_MEANINGS
- value_meaning: str
- def __new__(cls, value: str) -> Self:
- """Create InputSource enum."""
- obj = object.__new__(cls)
- obj._value_ = value
- obj.value_meaning = _INPUT_SOURCE_MEANINGS[value]
+_LISTENING_MODE_MEANINGS = {
+ "00": "STEREO",
+ "01": "DIRECT",
+ "02": "SURROUND",
+ "03": "FILM ··· GAME RPG ··· ADVANCED GAME",
+ "04": "THX",
+ "05": "ACTION ··· GAME ACTION",
+ "06": "MUSICAL ··· GAME ROCK ··· ROCK/POP",
+ "07": "MONO MOVIE",
+ "08": "ORCHESTRA ··· CLASSICAL",
+ "09": "UNPLUGGED",
+ "0A": "STUDIO MIX ··· ENTERTAINMENT SHOW",
+ "0B": "TV LOGIC ··· DRAMA",
+ "0C": "ALL CH STEREO ··· EXTENDED STEREO",
+ "0D": "THEATER DIMENSIONAL ··· FRONT STAGE SURROUND",
+ "0E": "ENHANCED 7/ENHANCE ··· GAME SPORTS ··· SPORTS",
+ "0F": "MONO",
+ "11": "PURE AUDIO ··· PURE DIRECT",
+ "12": "MULTIPLEX",
+ "13": "FULL MONO ··· MONO MUSIC",
+ "14": "DOLBY VIRTUAL/SURROUND ENHANCER",
+ "15": "DTS SURROUND SENSATION",
+ "16": "AUDYSSEY DSX",
+ "17": "DTS VIRTUAL:X",
+ "1F": "WHOLE HOUSE MODE ··· MULTI ZONE MUSIC",
+ "23": "STAGE (JAPAN GENRE CONTROL)",
+ "25": "ACTION (JAPAN GENRE CONTROL)",
+ "26": "MUSIC (JAPAN GENRE CONTROL)",
+ "2E": "SPORTS (JAPAN GENRE CONTROL)",
+ "40": "STRAIGHT DECODE ··· 5.1 CH SURROUND",
+ "41": "DOLBY EX/DTS ES",
+ "42": "THX CINEMA",
+ "43": "THX SURROUND EX",
+ "44": "THX MUSIC",
+ "45": "THX GAMES",
+ "50": "THX U(2)/S(2)/I/S CINEMA",
+ "51": "THX U(2)/S(2)/I/S MUSIC",
+ "52": "THX U(2)/S(2)/I/S GAMES",
+ "80": "DOLBY ATMOS/DOLBY SURROUND ··· PLII/PLIIx MOVIE",
+ "81": "PLII/PLIIx MUSIC",
+ "82": "DTS:X/NEURAL:X ··· NEO:6/NEO:X CINEMA",
+ "83": "NEO:6/NEO:X MUSIC",
+ "84": "DOLBY SURROUND THX CINEMA ··· PLII/PLIIx THX CINEMA",
+ "85": "DTS NEURAL:X THX CINEMA ··· NEO:6/NEO:X THX CINEMA",
+ "86": "PLII/PLIIx GAME",
+ "87": "NEURAL SURR",
+ "88": "NEURAL THX/NEURAL SURROUND",
+ "89": "DOLBY SURROUND THX GAMES ··· PLII/PLIIx THX GAMES",
+ "8A": "DTS NEURAL:X THX GAMES ··· NEO:6/NEO:X THX GAMES",
+ "8B": "DOLBY SURROUND THX MUSIC ··· PLII/PLIIx THX MUSIC",
+ "8C": "DTS NEURAL:X THX MUSIC ··· NEO:6/NEO:X THX MUSIC",
+ "8D": "NEURAL THX CINEMA",
+ "8E": "NEURAL THX MUSIC",
+ "8F": "NEURAL THX GAMES",
+ "90": "PLIIz HEIGHT",
+ "91": "NEO:6 CINEMA DTS SURROUND SENSATION",
+ "92": "NEO:6 MUSIC DTS SURROUND SENSATION",
+ "93": "NEURAL DIGITAL MUSIC",
+ "94": "PLIIz HEIGHT + THX CINEMA",
+ "95": "PLIIz HEIGHT + THX MUSIC",
+ "96": "PLIIz HEIGHT + THX GAMES",
+ "97": "PLIIz HEIGHT + THX U2/S2 CINEMA",
+ "98": "PLIIz HEIGHT + THX U2/S2 MUSIC",
+ "99": "PLIIz HEIGHT + THX U2/S2 GAMES",
+ "9A": "NEO:X GAME",
+ "A0": "PLIIx/PLII Movie + AUDYSSEY DSX",
+ "A1": "PLIIx/PLII MUSIC + AUDYSSEY DSX",
+ "A2": "PLIIx/PLII GAME + AUDYSSEY DSX",
+ "A3": "NEO:6 CINEMA + AUDYSSEY DSX",
+ "A4": "NEO:6 MUSIC + AUDYSSEY DSX",
+ "A5": "NEURAL SURROUND + AUDYSSEY DSX",
+ "A6": "NEURAL DIGITAL MUSIC + AUDYSSEY DSX",
+ "A7": "DOLBY EX + AUDYSSEY DSX",
+ "FF": "AUTO SURROUND",
+}
- cls.__meaning_mapping[obj.value_meaning] = obj
- return obj
+class ListeningMode(EnumWithMeaning):
+ """Receiver listening mode."""
- @classmethod
- def from_meaning(cls, meaning: str) -> Self:
- """Get InputSource enum from its meaning."""
- return cls.__meaning_mapping[meaning]
+ _ignore_ = "ListeningMode _k _v _meaning"
+
+ ListeningMode = vars()
+ for _k in _LISTENING_MODE_MEANINGS:
+ ListeningMode["I" + _k] = _k
+
+ @staticmethod
+ def _get_meanings() -> dict[str, str]:
+ return _LISTENING_MODE_MEANINGS
ZONES = {"main": "Main", "zone2": "Zone 2", "zone3": "Zone 3", "zone4": "Zone 4"}
diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py
index acb57e594b8..aed7c51af80 100644
--- a/homeassistant/components/onkyo/media_player.py
+++ b/homeassistant/components/onkyo/media_player.py
@@ -3,96 +3,54 @@
from __future__ import annotations
import asyncio
+from enum import Enum
from functools import cache
import logging
from typing import Any, Literal
-import voluptuous as vol
-
from homeassistant.components.media_player import (
- PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
-from homeassistant.config_entries import SOURCE_IMPORT
-from homeassistant.const import CONF_HOST, CONF_NAME
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
-from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers import config_validation as cv, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OnkyoConfigEntry
from .const import (
- CONF_RECEIVER_MAX_VOLUME,
- CONF_SOURCES,
DOMAIN,
OPTION_MAX_VOLUME,
OPTION_VOLUME_RESOLUTION,
PYEISCP_COMMANDS,
ZONES,
InputSource,
+ ListeningMode,
VolumeResolution,
)
-from .receiver import Receiver, async_discover
+from .receiver import Receiver
from .services import DATA_MP_ENTITIES
_LOGGER = logging.getLogger(__name__)
-CONF_MAX_VOLUME_DEFAULT = 100
-CONF_RECEIVER_MAX_VOLUME_DEFAULT = 80
-CONF_SOURCES_DEFAULT = {
- "tv": "TV",
- "bd": "Bluray",
- "game": "Game",
- "aux1": "Aux1",
- "video1": "Video 1",
- "video2": "Video 2",
- "video3": "Video 3",
- "video4": "Video 4",
- "video5": "Video 5",
- "video6": "Video 6",
- "video7": "Video 7",
- "fm": "Radio",
-}
-PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
- {
- vol.Optional(CONF_HOST): cv.string,
- vol.Optional(CONF_NAME): cv.string,
- vol.Optional(OPTION_MAX_VOLUME, default=CONF_MAX_VOLUME_DEFAULT): vol.All(
- vol.Coerce(int), vol.Range(min=1, max=100)
- ),
- vol.Optional(
- CONF_RECEIVER_MAX_VOLUME, default=CONF_RECEIVER_MAX_VOLUME_DEFAULT
- ): cv.positive_int,
- vol.Optional(CONF_SOURCES, default=CONF_SOURCES_DEFAULT): {
- cv.string: cv.string
- },
- }
-)
-
-SUPPORT_ONKYO_WO_VOLUME = (
+SUPPORTED_FEATURES_BASE = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY_MEDIA
)
-SUPPORT_ONKYO = (
- SUPPORT_ONKYO_WO_VOLUME
- | MediaPlayerEntityFeature.VOLUME_SET
+SUPPORTED_FEATURES_VOLUME = (
+ MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_STEP
)
-DEFAULT_PLAYABLE_SOURCES = (
- InputSource.from_meaning("FM"),
- InputSource.from_meaning("AM"),
- InputSource.from_meaning("DAB"),
+PLAYABLE_SOURCES = (
+ InputSource.FM,
+ InputSource.AM,
+ InputSource.DAB,
)
ATTR_PRESET = "preset"
@@ -115,7 +73,6 @@ AUDIO_INFORMATION_MAPPING = [
"auto_phase_control_phase",
"upmix_mode",
]
-
VIDEO_INFORMATION_MAPPING = [
"video_input_port",
"input_resolution",
@@ -128,7 +85,6 @@ VIDEO_INFORMATION_MAPPING = [
"picture_mode",
"input_hdr",
]
-ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo"
type LibValue = str | tuple[str, ...]
@@ -136,7 +92,19 @@ type LibValue = str | tuple[str, ...]
def _get_single_lib_value(value: LibValue) -> str:
if isinstance(value, str):
return value
- return value[0]
+ return value[-1]
+
+
+def _get_lib_mapping[T: Enum](cmds: Any, cls: type[T]) -> dict[T, LibValue]:
+ result: dict[T, LibValue] = {}
+ for k, v in cmds["values"].items():
+ try:
+ key = cls(k)
+ except ValueError:
+ continue
+ result[key] = v["name"]
+
+ return result
@cache
@@ -151,15 +119,7 @@ def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]:
case "zone4":
cmds = PYEISCP_COMMANDS["zone4"]["SL4"]
- result: dict[InputSource, LibValue] = {}
- for k, v in cmds["values"].items():
- try:
- source = InputSource(k)
- except ValueError:
- continue
- result[source] = v["name"]
-
- return result
+ return _get_lib_mapping(cmds, InputSource)
@cache
@@ -167,126 +127,28 @@ def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]:
return {value: key for key, value in _input_source_lib_mappings(zone).items()}
-async def async_setup_platform(
- hass: HomeAssistant,
- config: ConfigType,
- async_add_entities: AddEntitiesCallback,
- discovery_info: DiscoveryInfoType | None = None,
-) -> None:
- """Import config from yaml."""
- host = config.get(CONF_HOST)
+@cache
+def _listening_mode_lib_mappings(zone: str) -> dict[ListeningMode, LibValue]:
+ match zone:
+ case "main":
+ cmds = PYEISCP_COMMANDS["main"]["LMD"]
+ case "zone2":
+ cmds = PYEISCP_COMMANDS["zone2"]["LMZ"]
+ case _:
+ return {}
- source_mapping: dict[str, InputSource] = {}
- for zone in ZONES:
- for source, source_lib in _input_source_lib_mappings(zone).items():
- if isinstance(source_lib, str):
- source_mapping.setdefault(source_lib, source)
- else:
- for source_lib_single in source_lib:
- source_mapping.setdefault(source_lib_single, source)
+ return _get_lib_mapping(cmds, ListeningMode)
- sources: dict[InputSource, str] = {}
- for source_lib_single, source_name in config[CONF_SOURCES].items():
- user_source = source_mapping.get(source_lib_single.lower())
- if user_source is not None:
- sources[user_source] = source_name
- config[CONF_SOURCES] = sources
-
- results = []
- if host is not None:
- _LOGGER.debug("Importing yaml single: %s", host)
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=config
- )
- results.append((host, result))
- else:
- for info in await async_discover():
- host = info.host
-
- # Migrate legacy entities.
- registry = er.async_get(hass)
- old_unique_id = f"{info.model_name}_{info.identifier}"
- new_unique_id = f"{info.identifier}_main"
- entity_id = registry.async_get_entity_id(
- "media_player", DOMAIN, old_unique_id
- )
- if entity_id is not None:
- _LOGGER.debug(
- "Migrating unique_id from [%s] to [%s] for entity %s",
- old_unique_id,
- new_unique_id,
- entity_id,
- )
- registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
-
- _LOGGER.debug("Importing yaml discover: %s", info.host)
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- context={"source": SOURCE_IMPORT},
- data=config | {CONF_HOST: info.host} | {"info": info},
- )
- results.append((host, result))
-
- _LOGGER.debug("Importing yaml results: %s", results)
- if not results:
- async_create_issue(
- hass,
- DOMAIN,
- "deprecated_yaml_import_issue_no_discover",
- breaks_in_ha_version="2025.5.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml_import_issue_no_discover",
- translation_placeholders={"url": ISSUE_URL_PLACEHOLDER},
- )
-
- all_successful = True
- for host, result in results:
- if (
- result.get("type") == FlowResultType.CREATE_ENTRY
- or result.get("reason") == "already_configured"
- ):
- continue
- if error := result.get("reason"):
- all_successful = False
- async_create_issue(
- hass,
- DOMAIN,
- f"deprecated_yaml_import_issue_{host}_{error}",
- breaks_in_ha_version="2025.5.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key=f"deprecated_yaml_import_issue_{error}",
- translation_placeholders={
- "host": host,
- "url": ISSUE_URL_PLACEHOLDER,
- },
- )
-
- if all_successful:
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- is_fixable=False,
- issue_domain=DOMAIN,
- breaks_in_ha_version="2025.5.0",
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "onkyo",
- },
- )
+@cache
+def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode]:
+ return {value: key for key, value in _listening_mode_lib_mappings(zone).items()}
async def async_setup_entry(
hass: HomeAssistant,
entry: OnkyoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MediaPlayer for config entry."""
data = entry.runtime_data
@@ -300,6 +162,7 @@ async def async_setup_entry(
volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION]
max_volume: float = entry.options[OPTION_MAX_VOLUME]
sources = data.sources
+ sound_modes = data.sound_modes
def connect_callback(receiver: Receiver) -> None:
if not receiver.first_connect:
@@ -328,6 +191,7 @@ async def async_setup_entry(
volume_resolution=volume_resolution,
max_volume=max_volume,
sources=sources,
+ sound_modes=sound_modes,
)
entities[zone] = zone_entity
async_add_entities([zone_entity])
@@ -342,6 +206,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
_attr_should_poll = False
_supports_volume: bool = False
+ _supports_sound_mode: bool = False
_supports_audio_info: bool = False
_supports_video_info: bool = False
_query_timer: asyncio.TimerHandle | None = None
@@ -354,6 +219,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
volume_resolution: VolumeResolution,
max_volume: float,
sources: dict[InputSource, str],
+ sound_modes: dict[ListeningMode, str],
) -> None:
"""Initialize the Onkyo Receiver."""
self._receiver = receiver
@@ -367,6 +233,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._volume_resolution = volume_resolution
self._max_volume = max_volume
+ self._options_sources = sources
self._source_lib_mapping = _input_source_lib_mappings(zone)
self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone)
self._source_mapping = {
@@ -378,7 +245,28 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
value: key for key, value in self._source_mapping.items()
}
+ self._options_sound_modes = sound_modes
+ self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone)
+ self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone)
+ self._sound_mode_mapping = {
+ key: value
+ for key, value in sound_modes.items()
+ if key in self._sound_mode_lib_mapping
+ }
+ self._rev_sound_mode_mapping = {
+ value: key for key, value in self._sound_mode_mapping.items()
+ }
+
self._attr_source_list = list(self._rev_source_mapping)
+ self._attr_sound_mode_list = list(self._rev_sound_mode_mapping)
+
+ self._attr_supported_features = SUPPORTED_FEATURES_BASE
+ if zone == "main":
+ self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME
+ self._supports_volume = True
+ self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
+ self._supports_sound_mode = True
+
self._attr_extra_state_attributes = {}
async def async_added_to_hass(self) -> None:
@@ -391,13 +279,6 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._query_timer.cancel()
self._query_timer = None
- @property
- def supported_features(self) -> MediaPlayerEntityFeature:
- """Return media player features that are supported."""
- if self._supports_volume:
- return SUPPORT_ONKYO
- return SUPPORT_ONKYO_WO_VOLUME
-
@callback
def _update_receiver(self, propname: str, value: Any) -> None:
"""Update a property in the receiver."""
@@ -463,6 +344,24 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
"input-selector" if self._zone == "main" else "selector", source_lib_single
)
+ async def async_select_sound_mode(self, sound_mode: str) -> None:
+ """Select listening sound mode."""
+ if not self.sound_mode_list or sound_mode not in self.sound_mode_list:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_sound_mode",
+ translation_placeholders={
+ "invalid_sound_mode": sound_mode,
+ "entity_id": self.entity_id,
+ },
+ )
+
+ sound_mode_lib = self._sound_mode_lib_mapping[
+ self._rev_sound_mode_mapping[sound_mode]
+ ]
+ sound_mode_lib_single = _get_single_lib_value(sound_mode_lib)
+ self._update_receiver("listening-mode", sound_mode_lib_single)
+
async def async_select_output(self, hdmi_output: str) -> None:
"""Set hdmi-out."""
self._update_receiver("hdmi-output-selector", hdmi_output)
@@ -473,7 +372,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
"""Play radio station by preset number."""
if self.source is not None:
source = self._rev_source_mapping[self.source]
- if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES:
+ if media_type.lower() == "radio" and source in PLAYABLE_SOURCES:
self._update_receiver("preset", media_id)
@callback
@@ -514,7 +413,9 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._attr_extra_state_attributes.pop(ATTR_PRESET, None)
self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None)
elif command in ["volume", "master-volume"] and value != "N/A":
- self._supports_volume = True
+ if not self._supports_volume:
+ self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME
+ self._supports_volume = True
# AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100))
volume_level: float = value / (
self._volume_resolution * self._max_volume / 100
@@ -522,7 +423,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._attr_volume_level = min(1, volume_level)
elif command in ["muting", "audio-muting"]:
self._attr_is_volume_muted = bool(value == "on")
- elif command in ["selector", "input-selector"]:
+ elif command in ["selector", "input-selector"] and value != "N/A":
self._parse_source(value)
self._query_av_info_delayed()
elif command == "hdmi-output-selector":
@@ -532,6 +433,14 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._attr_extra_state_attributes[ATTR_PRESET] = value
elif ATTR_PRESET in self._attr_extra_state_attributes:
del self._attr_extra_state_attributes[ATTR_PRESET]
+ elif command == "listening-mode" and value != "N/A":
+ if not self._supports_sound_mode:
+ self._attr_supported_features |= (
+ MediaPlayerEntityFeature.SELECT_SOUND_MODE
+ )
+ self._supports_sound_mode = True
+ self._parse_sound_mode(value)
+ self._query_av_info_delayed()
elif command == "audio-information":
self._supports_audio_info = True
self._parse_audio_information(value)
@@ -551,13 +460,46 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
return
source_meaning = source.value_meaning
- _LOGGER.error(
- 'Input source "%s" is invalid for entity: %s',
- source_meaning,
- self.entity_id,
- )
+
+ if source not in self._options_sources:
+ _LOGGER.warning(
+ 'Input source "%s" for entity: %s is not in the list. Check integration options',
+ source_meaning,
+ self.entity_id,
+ )
+ else:
+ _LOGGER.error(
+ 'Input source "%s" is invalid for entity: %s',
+ source_meaning,
+ self.entity_id,
+ )
+
self._attr_source = source_meaning
+ @callback
+ def _parse_sound_mode(self, mode_lib: LibValue) -> None:
+ sound_mode = self._rev_sound_mode_lib_mapping[mode_lib]
+ if sound_mode in self._sound_mode_mapping:
+ self._attr_sound_mode = self._sound_mode_mapping[sound_mode]
+ return
+
+ sound_mode_meaning = sound_mode.value_meaning
+
+ if sound_mode not in self._options_sound_modes:
+ _LOGGER.warning(
+ 'Listening mode "%s" for entity: %s is not in the list. Check integration options',
+ sound_mode_meaning,
+ self.entity_id,
+ )
+ else:
+ _LOGGER.error(
+ 'Listening mode "%s" is invalid for entity: %s',
+ sound_mode_meaning,
+ self.entity_id,
+ )
+
+ self._attr_sound_mode = sound_mode_meaning
+
@callback
def _parse_audio_information(
self, audio_information: tuple[str] | Literal["N/A"]
diff --git a/homeassistant/components/onkyo/strings.json b/homeassistant/components/onkyo/strings.json
index b3b14efec44..3e5520c79f7 100644
--- a/homeassistant/components/onkyo/strings.json
+++ b/homeassistant/components/onkyo/strings.json
@@ -27,17 +27,20 @@
"description": "Configure {name}",
"data": {
"volume_resolution": "Volume resolution",
- "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]"
+ "input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]",
+ "listening_modes": "[%key:component::onkyo::options::step::init::data::listening_modes%]"
},
"data_description": {
"volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume.",
- "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]"
+ "input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]",
+ "listening_modes": "[%key:component::onkyo::options::step::init::data_description::listening_modes%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"empty_input_source_list": "[%key:component::onkyo::options::error::empty_input_source_list%]",
+ "empty_listening_mode_list": "[%key:component::onkyo::options::error::empty_listening_mode_list%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
@@ -53,11 +56,13 @@
"init": {
"data": {
"max_volume": "Maximum volume limit (%)",
- "input_sources": "Input sources"
+ "input_sources": "Input sources",
+ "listening_modes": "Listening modes"
},
"data_description": {
"max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value.",
- "input_sources": "List of input sources supported by the receiver."
+ "input_sources": "List of input sources supported by the receiver.",
+ "listening_modes": "List of listening modes supported by the receiver."
}
},
"names": {
@@ -65,25 +70,23 @@
"input_sources": {
"name": "Input source names",
"description": "Mappings of receiver's input sources to their names."
+ },
+ "listening_modes": {
+ "name": "Listening mode names",
+ "description": "Mappings of receiver's listening modes to their names."
}
}
}
},
"error": {
- "empty_input_source_list": "Input source list cannot be empty"
- }
- },
- "issues": {
- "deprecated_yaml_import_issue_no_discover": {
- "title": "The Onkyo YAML configuration import failed",
- "description": "Configuring Onkyo using YAML is being removed but no receivers were discovered when importing your YAML configuration.\n\nEnsure the connection to the receiver works and restart Home Assistant to try again or remove the Onkyo YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
- },
- "deprecated_yaml_import_issue_cannot_connect": {
- "title": "The Onkyo YAML configuration import failed",
- "description": "Configuring Onkyo using YAML is being removed but there was a connection error when importing your YAML configuration for host {host}.\n\nEnsure the connection to the receiver works and restart Home Assistant to try again or remove the Onkyo YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
+ "empty_input_source_list": "Input source list cannot be empty",
+ "empty_listening_mode_list": "Listening mode list cannot be empty"
}
},
"exceptions": {
+ "invalid_sound_mode": {
+ "message": "Cannot select sound mode \"{invalid_sound_mode}\" for entity: {entity_id}."
+ },
"invalid_source": {
"message": "Cannot select input source \"{invalid_source}\" for entity: {entity_id}."
}
diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py
index 02e7e28ea18..09a4aba52bf 100644
--- a/homeassistant/components/onvif/__init__.py
+++ b/homeassistant/components/onvif/__init__.py
@@ -19,8 +19,9 @@ from homeassistant.const import (
HTTP_DIGEST_AUTHENTICATION,
Platform,
)
-from homeassistant.core import HomeAssistant
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import entity_registry as er
from .const import (
CONF_ENABLE_WEBHOOKS,
@@ -99,6 +100,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if device.capabilities.imaging:
device.platforms += [Platform.SWITCH]
+ _async_migrate_camera_entities_unique_ids(hass, entry, device)
+
await hass.config_entries.async_forward_entry_setups(entry, device.platforms)
entry.async_on_unload(
@@ -155,3 +158,58 @@ async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> Non
}
hass.config_entries.async_update_entry(entry, options=options)
+
+
+@callback
+def _async_migrate_camera_entities_unique_ids(
+ hass: HomeAssistant, config_entry: ConfigEntry, device: ONVIFDevice
+) -> None:
+ """Migrate unique ids of camera entities from profile index to profile token."""
+ entity_reg = er.async_get(hass)
+ entities: list[er.RegistryEntry] = er.async_entries_for_config_entry(
+ entity_reg, config_entry.entry_id
+ )
+
+ mac_or_serial = device.info.mac or device.info.serial_number
+ old_uid_start = f"{mac_or_serial}_"
+ new_uid_start = f"{mac_or_serial}#"
+
+ for entity in entities:
+ if entity.domain != Platform.CAMERA:
+ continue
+
+ if (
+ not entity.unique_id.startswith(old_uid_start)
+ and entity.unique_id != mac_or_serial
+ ):
+ continue
+
+ index = 0
+ if entity.unique_id.startswith(old_uid_start):
+ try:
+ index = int(entity.unique_id[len(old_uid_start) :])
+ except ValueError:
+ LOGGER.error(
+ "Failed to migrate unique id for '%s' as the ONVIF profile index could not be parsed from unique id '%s'",
+ entity.entity_id,
+ entity.unique_id,
+ )
+ continue
+ try:
+ token = device.profiles[index].token
+ except IndexError:
+ LOGGER.error(
+ "Failed to migrate unique id for '%s' as the ONVIF profile index '%d' parsed from unique id '%s' could not be found",
+ entity.entity_id,
+ index,
+ entity.unique_id,
+ )
+ continue
+ new_uid = f"{new_uid_start}{token}"
+ LOGGER.debug(
+ "Migrating unique id for '%s' from '%s' to '%s'",
+ entity.entity_id,
+ entity.unique_id,
+ new_uid,
+ )
+ entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_uid)
diff --git a/homeassistant/components/onvif/binary_sensor.py b/homeassistant/components/onvif/binary_sensor.py
index 92c5ab45129..d29f732ef67 100644
--- a/homeassistant/components/onvif/binary_sensor.py
+++ b/homeassistant/components/onvif/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util.enum import try_parse_enum
@@ -22,7 +22,7 @@ from .entity import ONVIFBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a ONVIF binary sensor."""
device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id]
diff --git a/homeassistant/components/onvif/button.py b/homeassistant/components/onvif/button.py
index 644a7c942f7..8e92cb07a8c 100644
--- a/homeassistant/components/onvif/button.py
+++ b/homeassistant/components/onvif/button.py
@@ -4,7 +4,7 @@ from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .device import ONVIFDevice
@@ -14,7 +14,7 @@ from .entity import ONVIFBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ONVIF button based on a config entry."""
device = hass.data[DOMAIN][config_entry.unique_id]
diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py
index 8c0fd027b95..fc17e912fcc 100644
--- a/homeassistant/components/onvif/camera.py
+++ b/homeassistant/components/onvif/camera.py
@@ -22,7 +22,7 @@ from homeassistant.const import HTTP_BASIC_AUTHENTICATION
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ABSOLUTE_MOVE,
@@ -57,7 +57,7 @@ from .models import Profile
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ONVIF camera video stream."""
platform = entity_platform.async_get_current_platform()
@@ -117,10 +117,7 @@ class ONVIFCameraEntity(ONVIFBaseEntity, Camera):
self._attr_entity_registry_enabled_default = (
device.max_resolution == profile.video.resolution.width
)
- if profile.index:
- self._attr_unique_id = f"{self.mac_or_serial}_{profile.index}"
- else:
- self._attr_unique_id = self.mac_or_serial
+ self._attr_unique_id = f"{self.mac_or_serial}#{profile.token}"
self._attr_name = f"{device.name} {profile.name}"
@property
diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py
index 6d1a340fc7b..3f37ba42397 100644
--- a/homeassistant/components/onvif/device.py
+++ b/homeassistant/components/onvif/device.py
@@ -235,7 +235,7 @@ class ONVIFDevice:
LOGGER.debug("%s: Retrieving current device date/time", self.name)
try:
device_time = await device_mgmt.GetSystemDateAndTime()
- except RequestError as err:
+ except (RequestError, Fault) as err:
LOGGER.warning(
"Couldn't get device '%s' date/time. Error: %s", self.name, err
)
diff --git a/homeassistant/components/onvif/entity.py b/homeassistant/components/onvif/entity.py
index c9900106256..783df743e86 100644
--- a/homeassistant/components/onvif/entity.py
+++ b/homeassistant/components/onvif/entity.py
@@ -17,7 +17,7 @@ class ONVIFBaseEntity(Entity):
self.device: ONVIFDevice = device
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if device is available."""
return self.device.available
diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py
index b7b34f7be9f..d1b93304ccc 100644
--- a/homeassistant/components/onvif/event.py
+++ b/homeassistant/components/onvif/event.py
@@ -174,11 +174,20 @@ class EventManager:
UNHANDLED_TOPICS.add(topic)
continue
- event = await parser(unique_id, msg)
+ try:
+ event = await parser(unique_id, msg)
+ error = None
+ except (AttributeError, KeyError) as e:
+ event = None
+ error = e
if not event:
LOGGER.warning(
- "%s: Unable to parse event from %s: %s", self.name, unique_id, msg
+ "%s: Unable to parse event from %s: %s: %s",
+ self.name,
+ unique_id,
+ error,
+ msg,
)
return
diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py
index 6eb1d001796..e5a731c73f6 100644
--- a/homeassistant/components/onvif/parsers.py
+++ b/homeassistant/components/onvif/parsers.py
@@ -49,24 +49,22 @@ def local_datetime_or_none(value: str) -> datetime.datetime | None:
@PARSERS.register("tns1:VideoSource/MotionAlarm")
+@PARSERS.register("tns1:Device/Trigger/tnshik:AlarmIn")
async def async_parse_motion_alarm(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:VideoSource/MotionAlarm
"""
- try:
- topic, payload = extract_message(msg)
- source = payload.Source.SimpleItem[0].Value
- return Event(
- f"{uid}_{topic}_{source}",
- "Motion Alarm",
- "binary_sensor",
- "motion",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ source = payload.Source.SimpleItem[0].Value
+ return Event(
+ f"{uid}_{topic}_{source}",
+ "Motion Alarm",
+ "binary_sensor",
+ "motion",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
@PARSERS.register("tns1:VideoSource/ImageTooBlurry/AnalyticsService")
@@ -77,20 +75,17 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None:
Topic: tns1:VideoSource/ImageTooBlurry/*
"""
- try:
- topic, payload = extract_message(msg)
- source = payload.Source.SimpleItem[0].Value
- return Event(
- f"{uid}_{topic}_{source}",
- "Image Too Blurry",
- "binary_sensor",
- "problem",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- EntityCategory.DIAGNOSTIC,
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ source = payload.Source.SimpleItem[0].Value
+ return Event(
+ f"{uid}_{topic}_{source}",
+ "Image Too Blurry",
+ "binary_sensor",
+ "problem",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ EntityCategory.DIAGNOSTIC,
+ )
@PARSERS.register("tns1:VideoSource/ImageTooDark/AnalyticsService")
@@ -101,20 +96,17 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None:
Topic: tns1:VideoSource/ImageTooDark/*
"""
- try:
- topic, payload = extract_message(msg)
- source = payload.Source.SimpleItem[0].Value
- return Event(
- f"{uid}_{topic}_{source}",
- "Image Too Dark",
- "binary_sensor",
- "problem",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- EntityCategory.DIAGNOSTIC,
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ source = payload.Source.SimpleItem[0].Value
+ return Event(
+ f"{uid}_{topic}_{source}",
+ "Image Too Dark",
+ "binary_sensor",
+ "problem",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ EntityCategory.DIAGNOSTIC,
+ )
@PARSERS.register("tns1:VideoSource/ImageTooBright/AnalyticsService")
@@ -125,20 +117,17 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None:
Topic: tns1:VideoSource/ImageTooBright/*
"""
- try:
- topic, payload = extract_message(msg)
- source = payload.Source.SimpleItem[0].Value
- return Event(
- f"{uid}_{topic}_{source}",
- "Image Too Bright",
- "binary_sensor",
- "problem",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- EntityCategory.DIAGNOSTIC,
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ source = payload.Source.SimpleItem[0].Value
+ return Event(
+ f"{uid}_{topic}_{source}",
+ "Image Too Bright",
+ "binary_sensor",
+ "problem",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ EntityCategory.DIAGNOSTIC,
+ )
@PARSERS.register("tns1:VideoSource/GlobalSceneChange/AnalyticsService")
@@ -149,19 +138,16 @@ async def async_parse_scene_change(uid: str, msg) -> Event | None:
Topic: tns1:VideoSource/GlobalSceneChange/*
"""
- try:
- topic, payload = extract_message(msg)
- source = payload.Source.SimpleItem[0].Value
- return Event(
- f"{uid}_{topic}_{source}",
- "Global Scene Change",
- "binary_sensor",
- "problem",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ source = payload.Source.SimpleItem[0].Value
+ return Event(
+ f"{uid}_{topic}_{source}",
+ "Global Scene Change",
+ "binary_sensor",
+ "problem",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
@PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound")
@@ -170,29 +156,26 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None:
Topic: tns1:AudioAnalytics/Audio/DetectedSound
"""
- try:
- audio_source = ""
- audio_analytics = ""
- rule = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "AudioSourceConfigurationToken":
- audio_source = source.Value
- if source.Name == "AudioAnalyticsConfigurationToken":
- audio_analytics = source.Value
- if source.Name == "Rule":
- rule = source.Value
+ audio_source = ""
+ audio_analytics = ""
+ rule = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "AudioSourceConfigurationToken":
+ audio_source = source.Value
+ if source.Name == "AudioAnalyticsConfigurationToken":
+ audio_analytics = source.Value
+ if source.Name == "Rule":
+ rule = source.Value
- return Event(
- f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}",
- "Detected Sound",
- "binary_sensor",
- "sound",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}",
+ "Detected Sound",
+ "binary_sensor",
+ "sound",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
@PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside")
@@ -201,30 +184,26 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/FieldDetector/ObjectsInside
"""
- try:
- video_source = ""
- video_analytics = ""
- rule = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "VideoSourceConfigurationToken":
- video_source = _normalize_video_source(source.Value)
- if source.Name == "VideoAnalyticsConfigurationToken":
- video_analytics = source.Value
- if source.Name == "Rule":
- rule = source.Value
+ video_source = ""
+ video_analytics = ""
+ rule = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "VideoSourceConfigurationToken":
+ video_source = _normalize_video_source(source.Value)
+ if source.Name == "VideoAnalyticsConfigurationToken":
+ video_analytics = source.Value
+ if source.Name == "Rule":
+ rule = source.Value
- evt = Event(
- f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
- "Field Detection",
- "binary_sensor",
- "motion",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
- return evt
+ return Event(
+ f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
+ "Field Detection",
+ "binary_sensor",
+ "motion",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion")
@@ -233,29 +212,26 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/CellMotionDetector/Motion
"""
- try:
- video_source = ""
- video_analytics = ""
- rule = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "VideoSourceConfigurationToken":
- video_source = _normalize_video_source(source.Value)
- if source.Name == "VideoAnalyticsConfigurationToken":
- video_analytics = source.Value
- if source.Name == "Rule":
- rule = source.Value
+ video_source = ""
+ video_analytics = ""
+ rule = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "VideoSourceConfigurationToken":
+ video_source = _normalize_video_source(source.Value)
+ if source.Name == "VideoAnalyticsConfigurationToken":
+ video_analytics = source.Value
+ if source.Name == "Rule":
+ rule = source.Value
- return Event(
- f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
- "Cell Motion Detection",
- "binary_sensor",
- "motion",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
+ "Cell Motion Detection",
+ "binary_sensor",
+ "motion",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
@PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion")
@@ -264,29 +240,26 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/MotionRegionDetector/Motion
"""
- try:
- video_source = ""
- video_analytics = ""
- rule = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "VideoSourceConfigurationToken":
- video_source = _normalize_video_source(source.Value)
- if source.Name == "VideoAnalyticsConfigurationToken":
- video_analytics = source.Value
- if source.Name == "Rule":
- rule = source.Value
+ video_source = ""
+ video_analytics = ""
+ rule = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "VideoSourceConfigurationToken":
+ video_source = _normalize_video_source(source.Value)
+ if source.Name == "VideoAnalyticsConfigurationToken":
+ video_analytics = source.Value
+ if source.Name == "Rule":
+ rule = source.Value
- return Event(
- f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
- "Motion Region Detection",
- "binary_sensor",
- "motion",
- None,
- payload.Data.SimpleItem[0].Value in ["1", "true"],
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
+ "Motion Region Detection",
+ "binary_sensor",
+ "motion",
+ None,
+ payload.Data.SimpleItem[0].Value in ["1", "true"],
+ )
@PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper")
@@ -295,30 +268,27 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/TamperDetector/Tamper
"""
- try:
- video_source = ""
- video_analytics = ""
- rule = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "VideoSourceConfigurationToken":
- video_source = _normalize_video_source(source.Value)
- if source.Name == "VideoAnalyticsConfigurationToken":
- video_analytics = source.Value
- if source.Name == "Rule":
- rule = source.Value
+ video_source = ""
+ video_analytics = ""
+ rule = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "VideoSourceConfigurationToken":
+ video_source = _normalize_video_source(source.Value)
+ if source.Name == "VideoAnalyticsConfigurationToken":
+ video_analytics = source.Value
+ if source.Name == "Rule":
+ rule = source.Value
- return Event(
- f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
- "Tamper Detection",
- "binary_sensor",
- "problem",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- EntityCategory.DIAGNOSTIC,
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
+ "Tamper Detection",
+ "binary_sensor",
+ "problem",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ EntityCategory.DIAGNOSTIC,
+ )
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect")
@@ -327,23 +297,20 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/MyRuleDetector/DogCatDetect
"""
- try:
- video_source = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "Source":
- video_source = _normalize_video_source(source.Value)
+ video_source = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "Source":
+ video_source = _normalize_video_source(source.Value)
- return Event(
- f"{uid}_{topic}_{video_source}",
- "Pet Detection",
- "binary_sensor",
- "motion",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{video_source}",
+ "Pet Detection",
+ "binary_sensor",
+ "motion",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect")
@@ -352,23 +319,20 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/MyRuleDetector/VehicleDetect
"""
- try:
- video_source = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "Source":
- video_source = _normalize_video_source(source.Value)
+ video_source = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "Source":
+ video_source = _normalize_video_source(source.Value)
- return Event(
- f"{uid}_{topic}_{video_source}",
- "Vehicle Detection",
- "binary_sensor",
- "motion",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{video_source}",
+ "Vehicle Detection",
+ "binary_sensor",
+ "motion",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
_TAPO_EVENT_TEMPLATES: dict[str, Event] = {
@@ -420,32 +384,28 @@ async def async_parse_tplink_detector(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/PeopleDetector/People
Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent
"""
- try:
- video_source = ""
- video_analytics = ""
- rule = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "VideoSourceConfigurationToken":
- video_source = _normalize_video_source(source.Value)
- if source.Name == "VideoAnalyticsConfigurationToken":
- video_analytics = source.Value
- if source.Name == "Rule":
- rule = source.Value
+ video_source = ""
+ video_analytics = ""
+ rule = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "VideoSourceConfigurationToken":
+ video_source = _normalize_video_source(source.Value)
+ if source.Name == "VideoAnalyticsConfigurationToken":
+ video_analytics = source.Value
+ if source.Name == "Rule":
+ rule = source.Value
- for item in payload.Data.SimpleItem:
- event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None)
- if event_template is None:
- continue
+ for item in payload.Data.SimpleItem:
+ event_template = _TAPO_EVENT_TEMPLATES.get(item.Name, None)
+ if event_template is None:
+ continue
- return dataclasses.replace(
- event_template,
- uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
- value=item.Value == "true",
- )
-
- except (AttributeError, KeyError):
- return None
+ return dataclasses.replace(
+ event_template,
+ uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
+ value=item.Value == "true",
+ )
return None
@@ -456,23 +416,20 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/MyRuleDetector/PeopleDetect
"""
- try:
- video_source = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "Source":
- video_source = _normalize_video_source(source.Value)
+ video_source = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "Source":
+ video_source = _normalize_video_source(source.Value)
- return Event(
- f"{uid}_{topic}_{video_source}",
- "Person Detection",
- "binary_sensor",
- "motion",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{video_source}",
+ "Person Detection",
+ "binary_sensor",
+ "motion",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect")
@@ -481,23 +438,20 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/MyRuleDetector/FaceDetect
"""
- try:
- video_source = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "Source":
- video_source = _normalize_video_source(source.Value)
+ video_source = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "Source":
+ video_source = _normalize_video_source(source.Value)
- return Event(
- f"{uid}_{topic}_{video_source}",
- "Face Detection",
- "binary_sensor",
- "motion",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{video_source}",
+ "Face Detection",
+ "binary_sensor",
+ "motion",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor")
@@ -506,23 +460,42 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/MyRuleDetector/Visitor
"""
- try:
- video_source = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "Source":
- video_source = _normalize_video_source(source.Value)
+ video_source = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "Source":
+ video_source = _normalize_video_source(source.Value)
- return Event(
- f"{uid}_{topic}_{video_source}",
- "Visitor Detection",
- "binary_sensor",
- "occupancy",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{video_source}",
+ "Visitor Detection",
+ "binary_sensor",
+ "occupancy",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
+
+
+@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Package")
+async def async_parse_package_detector(uid: str, msg) -> Event | None:
+ """Handle parsing event message.
+
+ Topic: tns1:RuleEngine/MyRuleDetector/Package
+ """
+ video_source = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "Source":
+ video_source = _normalize_video_source(source.Value)
+
+ return Event(
+ f"{uid}_{topic}_{video_source}",
+ "Package Detection",
+ "binary_sensor",
+ "occupancy",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
@PARSERS.register("tns1:Device/Trigger/DigitalInput")
@@ -531,19 +504,16 @@ async def async_parse_digital_input(uid: str, msg) -> Event | None:
Topic: tns1:Device/Trigger/DigitalInput
"""
- try:
- topic, payload = extract_message(msg)
- source = payload.Source.SimpleItem[0].Value
- return Event(
- f"{uid}_{topic}_{source}",
- "Digital Input",
- "binary_sensor",
- None,
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ source = payload.Source.SimpleItem[0].Value
+ return Event(
+ f"{uid}_{topic}_{source}",
+ "Digital Input",
+ "binary_sensor",
+ None,
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
@PARSERS.register("tns1:Device/Trigger/Relay")
@@ -552,19 +522,16 @@ async def async_parse_relay(uid: str, msg) -> Event | None:
Topic: tns1:Device/Trigger/Relay
"""
- try:
- topic, payload = extract_message(msg)
- source = payload.Source.SimpleItem[0].Value
- return Event(
- f"{uid}_{topic}_{source}",
- "Relay Triggered",
- "binary_sensor",
- None,
- None,
- payload.Data.SimpleItem[0].Value == "active",
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ source = payload.Source.SimpleItem[0].Value
+ return Event(
+ f"{uid}_{topic}_{source}",
+ "Relay Triggered",
+ "binary_sensor",
+ None,
+ None,
+ payload.Data.SimpleItem[0].Value == "active",
+ )
@PARSERS.register("tns1:Device/HardwareFailure/StorageFailure")
@@ -573,20 +540,17 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None:
Topic: tns1:Device/HardwareFailure/StorageFailure
"""
- try:
- topic, payload = extract_message(msg)
- source = payload.Source.SimpleItem[0].Value
- return Event(
- f"{uid}_{topic}_{source}",
- "Storage Failure",
- "binary_sensor",
- "problem",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- EntityCategory.DIAGNOSTIC,
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ source = payload.Source.SimpleItem[0].Value
+ return Event(
+ f"{uid}_{topic}_{source}",
+ "Storage Failure",
+ "binary_sensor",
+ "problem",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ EntityCategory.DIAGNOSTIC,
+ )
@PARSERS.register("tns1:Monitoring/ProcessorUsage")
@@ -595,23 +559,20 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None:
Topic: tns1:Monitoring/ProcessorUsage
"""
- try:
- topic, payload = extract_message(msg)
- usage = float(payload.Data.SimpleItem[0].Value)
- if usage <= 1:
- usage *= 100
+ topic, payload = extract_message(msg)
+ usage = float(payload.Data.SimpleItem[0].Value)
+ if usage <= 1:
+ usage *= 100
- return Event(
- f"{uid}_{topic}",
- "Processor Usage",
- "sensor",
- None,
- "percent",
- int(usage),
- EntityCategory.DIAGNOSTIC,
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}",
+ "Processor Usage",
+ "sensor",
+ None,
+ "percent",
+ int(usage),
+ EntityCategory.DIAGNOSTIC,
+ )
@PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot")
@@ -620,20 +581,17 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None:
Topic: tns1:Monitoring/OperatingTime/LastReboot
"""
- try:
- topic, payload = extract_message(msg)
- date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
- return Event(
- f"{uid}_{topic}",
- "Last Reboot",
- "sensor",
- "timestamp",
- None,
- date_time,
- EntityCategory.DIAGNOSTIC,
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
+ return Event(
+ f"{uid}_{topic}",
+ "Last Reboot",
+ "sensor",
+ "timestamp",
+ None,
+ date_time,
+ EntityCategory.DIAGNOSTIC,
+ )
@PARSERS.register("tns1:Monitoring/OperatingTime/LastReset")
@@ -642,21 +600,18 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None:
Topic: tns1:Monitoring/OperatingTime/LastReset
"""
- try:
- topic, payload = extract_message(msg)
- date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
- return Event(
- f"{uid}_{topic}",
- "Last Reset",
- "sensor",
- "timestamp",
- None,
- date_time,
- EntityCategory.DIAGNOSTIC,
- entity_enabled=False,
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
+ return Event(
+ f"{uid}_{topic}",
+ "Last Reset",
+ "sensor",
+ "timestamp",
+ None,
+ date_time,
+ EntityCategory.DIAGNOSTIC,
+ entity_enabled=False,
+ )
@PARSERS.register("tns1:Monitoring/Backup/Last")
@@ -665,22 +620,18 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None:
Topic: tns1:Monitoring/Backup/Last
"""
-
- try:
- topic, payload = extract_message(msg)
- date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
- return Event(
- f"{uid}_{topic}",
- "Last Backup",
- "sensor",
- "timestamp",
- None,
- date_time,
- EntityCategory.DIAGNOSTIC,
- entity_enabled=False,
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
+ return Event(
+ f"{uid}_{topic}",
+ "Last Backup",
+ "sensor",
+ "timestamp",
+ None,
+ date_time,
+ EntityCategory.DIAGNOSTIC,
+ entity_enabled=False,
+ )
@PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization")
@@ -689,21 +640,18 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None:
Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization
"""
- try:
- topic, payload = extract_message(msg)
- date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
- return Event(
- f"{uid}_{topic}",
- "Last Clock Synchronization",
- "sensor",
- "timestamp",
- None,
- date_time,
- EntityCategory.DIAGNOSTIC,
- entity_enabled=False,
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
+ return Event(
+ f"{uid}_{topic}",
+ "Last Clock Synchronization",
+ "sensor",
+ "timestamp",
+ None,
+ date_time,
+ EntityCategory.DIAGNOSTIC,
+ entity_enabled=False,
+ )
@PARSERS.register("tns1:RecordingConfig/JobState")
@@ -713,20 +661,17 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None:
Topic: tns1:RecordingConfig/JobState
"""
- try:
- topic, payload = extract_message(msg)
- source = payload.Source.SimpleItem[0].Value
- return Event(
- f"{uid}_{topic}_{source}",
- "Recording Job State",
- "binary_sensor",
- None,
- None,
- payload.Data.SimpleItem[0].Value == "Active",
- EntityCategory.DIAGNOSTIC,
- )
- except (AttributeError, KeyError):
- return None
+ topic, payload = extract_message(msg)
+ source = payload.Source.SimpleItem[0].Value
+ return Event(
+ f"{uid}_{topic}_{source}",
+ "Recording Job State",
+ "binary_sensor",
+ None,
+ None,
+ payload.Data.SimpleItem[0].Value == "Active",
+ EntityCategory.DIAGNOSTIC,
+ )
@PARSERS.register("tns1:RuleEngine/LineDetector/Crossed")
@@ -735,30 +680,27 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/LineDetector/Crossed
"""
- try:
- video_source = ""
- video_analytics = ""
- rule = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "VideoSourceConfigurationToken":
- video_source = source.Value
- if source.Name == "VideoAnalyticsConfigurationToken":
- video_analytics = source.Value
- if source.Name == "Rule":
- rule = source.Value
+ video_source = ""
+ video_analytics = ""
+ rule = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "VideoSourceConfigurationToken":
+ video_source = source.Value
+ if source.Name == "VideoAnalyticsConfigurationToken":
+ video_analytics = source.Value
+ if source.Name == "Rule":
+ rule = source.Value
- return Event(
- f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
- "Line Detector Crossed",
- "sensor",
- None,
- None,
- payload.Data.SimpleItem[0].Value,
- EntityCategory.DIAGNOSTIC,
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
+ "Line Detector Crossed",
+ "sensor",
+ None,
+ None,
+ payload.Data.SimpleItem[0].Value,
+ EntityCategory.DIAGNOSTIC,
+ )
@PARSERS.register("tns1:RuleEngine/CountAggregation/Counter")
@@ -767,30 +709,27 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None:
Topic: tns1:RuleEngine/CountAggregation/Counter
"""
- try:
- video_source = ""
- video_analytics = ""
- rule = ""
- topic, payload = extract_message(msg)
- for source in payload.Source.SimpleItem:
- if source.Name == "VideoSourceConfigurationToken":
- video_source = _normalize_video_source(source.Value)
- if source.Name == "VideoAnalyticsConfigurationToken":
- video_analytics = source.Value
- if source.Name == "Rule":
- rule = source.Value
+ video_source = ""
+ video_analytics = ""
+ rule = ""
+ topic, payload = extract_message(msg)
+ for source in payload.Source.SimpleItem:
+ if source.Name == "VideoSourceConfigurationToken":
+ video_source = _normalize_video_source(source.Value)
+ if source.Name == "VideoAnalyticsConfigurationToken":
+ video_analytics = source.Value
+ if source.Name == "Rule":
+ rule = source.Value
- return Event(
- f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
- "Count Aggregation Counter",
- "sensor",
- None,
- None,
- payload.Data.SimpleItem[0].Value,
- EntityCategory.DIAGNOSTIC,
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
+ "Count Aggregation Counter",
+ "sensor",
+ None,
+ None,
+ payload.Data.SimpleItem[0].Value,
+ EntityCategory.DIAGNOSTIC,
+ )
@PARSERS.register("tns1:UserAlarm/IVA/HumanShapeDetect")
@@ -799,21 +738,18 @@ async def async_parse_human_shape_detect(uid: str, msg) -> Event | None:
Topic: tns1:UserAlarm/IVA/HumanShapeDetect
"""
- try:
- topic, payload = extract_message(msg)
- video_source = ""
- for source in payload.Source.SimpleItem:
- if source.Name == "VideoSourceConfigurationToken":
- video_source = _normalize_video_source(source.Value)
- break
+ topic, payload = extract_message(msg)
+ video_source = ""
+ for source in payload.Source.SimpleItem:
+ if source.Name == "VideoSourceConfigurationToken":
+ video_source = _normalize_video_source(source.Value)
+ break
- return Event(
- f"{uid}_{topic}_{video_source}",
- "Human Shape Detect",
- "binary_sensor",
- "motion",
- None,
- payload.Data.SimpleItem[0].Value == "true",
- )
- except (AttributeError, KeyError):
- return None
+ return Event(
+ f"{uid}_{topic}_{video_source}",
+ "Human Shape Detect",
+ "binary_sensor",
+ "motion",
+ None,
+ payload.Data.SimpleItem[0].Value == "true",
+ )
diff --git a/homeassistant/components/onvif/sensor.py b/homeassistant/components/onvif/sensor.py
index 46db26361bc..a0162a05f76 100644
--- a/homeassistant/components/onvif/sensor.py
+++ b/homeassistant/components/onvif/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import RestoreSensor, SensorDeviceClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.enum import try_parse_enum
@@ -21,7 +21,7 @@ from .entity import ONVIFBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a ONVIF binary sensor."""
device: ONVIFDevice = hass.data[DOMAIN][config_entry.unique_id]
diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json
index 0afb5e59e8e..7988c50b1ac 100644
--- a/homeassistant/components/onvif/strings.json
+++ b/homeassistant/components/onvif/strings.json
@@ -62,12 +62,12 @@
"step": {
"onvif_devices": {
"data": {
- "extra_arguments": "Extra FFMPEG arguments",
+ "extra_arguments": "Extra FFmpeg arguments",
"rtsp_transport": "RTSP transport mechanism",
"use_wallclock_as_timestamps": "Use wall clock as timestamps",
- "enable_webhooks": "Enable Webhooks"
+ "enable_webhooks": "Enable webhooks"
},
- "title": "ONVIF Device Options"
+ "title": "ONVIF device options"
}
}
},
diff --git a/homeassistant/components/onvif/switch.py b/homeassistant/components/onvif/switch.py
index ff62e469af0..d8e1020c6a3 100644
--- a/homeassistant/components/onvif/switch.py
+++ b/homeassistant/components/onvif/switch.py
@@ -9,7 +9,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .device import ONVIFDevice
@@ -66,7 +66,7 @@ SWITCHES: tuple[ONVIFSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a ONVIF switch platform."""
device = hass.data[DOMAIN][config_entry.unique_id]
diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py
index 51ee91de083..9782051ab22 100644
--- a/homeassistant/components/open_meteo/weather.py
+++ b/homeassistant/components/open_meteo/weather.py
@@ -20,7 +20,7 @@ from homeassistant.components.weather import (
from homeassistant.const import UnitOfPrecipitationDepth, UnitOfSpeed, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
@@ -31,7 +31,7 @@ from .coordinator import OpenMeteoConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
entry: OpenMeteoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Open-Meteo weather entity based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py
index 0fbda9b7f4a..276f5ddea3b 100644
--- a/homeassistant/components/openai_conversation/__init__.py
+++ b/homeassistant/components/openai_conversation/__init__.py
@@ -2,7 +2,21 @@
from __future__ import annotations
+import base64
+from mimetypes import guess_file_type
+from pathlib import Path
+
import openai
+from openai.types.images_response import ImagesResponse
+from openai.types.responses import (
+ EasyInputMessageParam,
+ Response,
+ ResponseInputFileParam,
+ ResponseInputImageParam,
+ ResponseInputMessageContentListParam,
+ ResponseInputParam,
+ ResponseInputTextParam,
+)
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
@@ -22,15 +36,41 @@ from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.typing import ConfigType
-from .const import DOMAIN, LOGGER
+from .const import (
+ CONF_CHAT_MODEL,
+ CONF_FILENAMES,
+ CONF_MAX_TOKENS,
+ CONF_PROMPT,
+ CONF_REASONING_EFFORT,
+ CONF_TEMPERATURE,
+ CONF_TOP_P,
+ DOMAIN,
+ LOGGER,
+ RECOMMENDED_CHAT_MODEL,
+ RECOMMENDED_MAX_TOKENS,
+ RECOMMENDED_REASONING_EFFORT,
+ RECOMMENDED_TEMPERATURE,
+ RECOMMENDED_TOP_P,
+)
SERVICE_GENERATE_IMAGE = "generate_image"
+SERVICE_GENERATE_CONTENT = "generate_content"
+
PLATFORMS = (Platform.CONVERSATION,)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient]
+def encode_file(file_path: str) -> tuple[str, str]:
+ """Return base64 version of file contents."""
+ mime_type, _ = guess_file_type(file_path)
+ if mime_type is None:
+ mime_type = "application/octet-stream"
+ with open(file_path, "rb") as image_file:
+ return (mime_type, base64.b64encode(image_file.read()).decode("utf-8"))
+
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up OpenAI Conversation."""
@@ -49,9 +89,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
client: openai.AsyncClient = entry.runtime_data
try:
- response = await client.images.generate(
+ response: ImagesResponse = await client.images.generate(
model="dall-e-3",
- prompt=call.data["prompt"],
+ prompt=call.data[CONF_PROMPT],
size=call.data["size"],
quality=call.data["quality"],
style=call.data["style"],
@@ -63,6 +103,117 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return response.data[0].model_dump(exclude={"b64_json"})
+ async def send_prompt(call: ServiceCall) -> ServiceResponse:
+ """Send a prompt to ChatGPT and return the response."""
+ entry_id = call.data["config_entry"]
+ entry = hass.config_entries.async_get_entry(entry_id)
+
+ if entry is None or entry.domain != DOMAIN:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_config_entry",
+ translation_placeholders={"config_entry": entry_id},
+ )
+
+ model: str = entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
+ client: openai.AsyncClient = entry.runtime_data
+
+ content: ResponseInputMessageContentListParam = [
+ ResponseInputTextParam(type="input_text", text=call.data[CONF_PROMPT])
+ ]
+
+ def append_files_to_content() -> None:
+ for filename in call.data[CONF_FILENAMES]:
+ if not hass.config.is_allowed_path(filename):
+ raise HomeAssistantError(
+ f"Cannot read `{filename}`, no access to path; "
+ "`allowlist_external_dirs` may need to be adjusted in "
+ "`configuration.yaml`"
+ )
+ if not Path(filename).exists():
+ raise HomeAssistantError(f"`{filename}` does not exist")
+ mime_type, base64_file = encode_file(filename)
+ if "image/" in mime_type:
+ content.append(
+ ResponseInputImageParam(
+ type="input_image",
+ file_id=filename,
+ image_url=f"data:{mime_type};base64,{base64_file}",
+ detail="auto",
+ )
+ )
+ elif "application/pdf" in mime_type:
+ content.append(
+ ResponseInputFileParam(
+ type="input_file",
+ filename=filename,
+ file_data=f"data:{mime_type};base64,{base64_file}",
+ )
+ )
+ else:
+ raise HomeAssistantError(
+ "Only images and PDF are supported by the OpenAI API,"
+ f"`{filename}` is not an image file or PDF"
+ )
+
+ if CONF_FILENAMES in call.data:
+ await hass.async_add_executor_job(append_files_to_content)
+
+ messages: ResponseInputParam = [
+ EasyInputMessageParam(type="message", role="user", content=content)
+ ]
+
+ try:
+ model_args = {
+ "model": model,
+ "input": messages,
+ "max_output_tokens": entry.options.get(
+ CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
+ ),
+ "top_p": entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
+ "temperature": entry.options.get(
+ CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
+ ),
+ "user": call.context.user_id,
+ "store": False,
+ }
+
+ if model.startswith("o"):
+ model_args["reasoning"] = {
+ "effort": entry.options.get(
+ CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
+ )
+ }
+
+ response: Response = await client.responses.create(**model_args)
+
+ except openai.OpenAIError as err:
+ raise HomeAssistantError(f"Error generating content: {err}") from err
+ except FileNotFoundError as err:
+ raise HomeAssistantError(f"Error generating content: {err}") from err
+
+ return {"text": response.output_text}
+
+ hass.services.async_register(
+ DOMAIN,
+ SERVICE_GENERATE_CONTENT,
+ send_prompt,
+ schema=vol.Schema(
+ {
+ vol.Required("config_entry"): selector.ConfigEntrySelector(
+ {
+ "integration": DOMAIN,
+ }
+ ),
+ vol.Required(CONF_PROMPT): cv.string,
+ vol.Optional(CONF_FILENAMES, default=[]): vol.All(
+ cv.ensure_list, [cv.string]
+ ),
+ }
+ ),
+ supports_response=SupportsResponse.ONLY,
+ )
+
hass.services.async_register(
DOMAIN,
SERVICE_GENERATE_IMAGE,
@@ -74,7 +225,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"integration": DOMAIN,
}
),
- vol.Required("prompt"): cv.string,
+ vol.Required(CONF_PROMPT): cv.string,
vol.Optional("size", default="1024x1024"): vol.In(
("1024x1024", "1024x1792", "1792x1024")
),
@@ -84,6 +235,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
),
supports_response=SupportsResponse.ONLY,
)
+
return True
diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py
index c631884ea0b..5c8ab674bef 100644
--- a/homeassistant/components/openai_conversation/config_flow.py
+++ b/homeassistant/components/openai_conversation/config_flow.py
@@ -2,22 +2,31 @@
from __future__ import annotations
+import json
import logging
from types import MappingProxyType
from typing import Any
import openai
import voluptuous as vol
+from voluptuous_openapi import convert
+from homeassistant.components.zone import ENTITY_ID_HOME
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
-from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API
+from homeassistant.const import (
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+ CONF_API_KEY,
+ CONF_LLM_HASS_API,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import llm
+from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
@@ -37,13 +46,24 @@ from .const import (
CONF_RECOMMENDED,
CONF_TEMPERATURE,
CONF_TOP_P,
+ CONF_WEB_SEARCH,
+ CONF_WEB_SEARCH_CITY,
+ CONF_WEB_SEARCH_CONTEXT_SIZE,
+ CONF_WEB_SEARCH_COUNTRY,
+ CONF_WEB_SEARCH_REGION,
+ CONF_WEB_SEARCH_TIMEZONE,
+ CONF_WEB_SEARCH_USER_LOCATION,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
+ RECOMMENDED_WEB_SEARCH,
+ RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
+ RECOMMENDED_WEB_SEARCH_USER_LOCATION,
UNSUPPORTED_MODELS,
+ WEB_SEARCH_MODELS,
)
_LOGGER = logging.getLogger(__name__)
@@ -66,7 +86,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
"""
- client = openai.AsyncOpenAI(api_key=data[CONF_API_KEY])
+ client = openai.AsyncOpenAI(
+ api_key=data[CONF_API_KEY], http_client=get_async_client(hass)
+ )
await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list)
@@ -132,12 +154,21 @@ class OpenAIOptionsFlow(OptionsFlow):
if user_input is not None:
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
- if user_input[CONF_LLM_HASS_API] == "none":
- user_input.pop(CONF_LLM_HASS_API)
-
+ if not user_input.get(CONF_LLM_HASS_API):
+ user_input.pop(CONF_LLM_HASS_API, None)
if user_input.get(CONF_CHAT_MODEL) in UNSUPPORTED_MODELS:
errors[CONF_CHAT_MODEL] = "model_not_supported"
- else:
+
+ if user_input.get(CONF_WEB_SEARCH):
+ if (
+ user_input.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
+ not in WEB_SEARCH_MODELS
+ ):
+ errors[CONF_WEB_SEARCH] = "web_search_not_supported"
+ elif user_input.get(CONF_WEB_SEARCH_USER_LOCATION):
+ user_input.update(await self.get_location_data())
+
+ if not errors:
return self.async_create_entry(title="", data=user_input)
else:
# Re-render the options again, now with the recommended options shown/hidden
@@ -146,7 +177,7 @@ class OpenAIOptionsFlow(OptionsFlow):
options = {
CONF_RECOMMENDED: user_input[CONF_RECOMMENDED],
CONF_PROMPT: user_input[CONF_PROMPT],
- CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
+ CONF_LLM_HASS_API: user_input.get(CONF_LLM_HASS_API),
}
schema = openai_config_option_schema(self.hass, options)
@@ -156,6 +187,59 @@ class OpenAIOptionsFlow(OptionsFlow):
errors=errors,
)
+ async def get_location_data(self) -> dict[str, str]:
+ """Get approximate location data of the user."""
+ location_data: dict[str, str] = {}
+ zone_home = self.hass.states.get(ENTITY_ID_HOME)
+ if zone_home is not None:
+ client = openai.AsyncOpenAI(
+ api_key=self.config_entry.data[CONF_API_KEY],
+ http_client=get_async_client(self.hass),
+ )
+ location_schema = vol.Schema(
+ {
+ vol.Optional(
+ CONF_WEB_SEARCH_CITY,
+ description="Free text input for the city, e.g. `San Francisco`",
+ ): str,
+ vol.Optional(
+ CONF_WEB_SEARCH_REGION,
+ description="Free text input for the region, e.g. `California`",
+ ): str,
+ }
+ )
+ response = await client.responses.create(
+ model=RECOMMENDED_CHAT_MODEL,
+ input=[
+ {
+ "role": "system",
+ "content": "Where are the following coordinates located: "
+ f"({zone_home.attributes[ATTR_LATITUDE]},"
+ f" {zone_home.attributes[ATTR_LONGITUDE]})?",
+ }
+ ],
+ text={
+ "format": {
+ "type": "json_schema",
+ "name": "approximate_location",
+ "description": "Approximate location data of the user "
+ "for refined web search results",
+ "schema": convert(location_schema),
+ "strict": False,
+ }
+ },
+ store=False,
+ )
+ location_data = location_schema(json.loads(response.output_text) or {})
+
+ if self.hass.config.country:
+ location_data[CONF_WEB_SEARCH_COUNTRY] = self.hass.config.country
+ location_data[CONF_WEB_SEARCH_TIMEZONE] = self.hass.config.time_zone
+
+ _LOGGER.debug("Location data: %s", location_data)
+
+ return location_data
+
def openai_config_option_schema(
hass: HomeAssistant,
@@ -163,19 +247,16 @@ def openai_config_option_schema(
) -> VolDictType:
"""Return a schema for OpenAI completion options."""
hass_apis: list[SelectOptionDict] = [
- SelectOptionDict(
- label="No control",
- value="none",
- )
- ]
- hass_apis.extend(
SelectOptionDict(
label=api.name,
value=api.id,
)
for api in llm.async_get_apis(hass)
- )
-
+ ]
+ if (suggested_llm_apis := options.get(CONF_LLM_HASS_API)) and isinstance(
+ suggested_llm_apis, str
+ ):
+ suggested_llm_apis = [suggested_llm_apis]
schema: VolDictType = {
vol.Optional(
CONF_PROMPT,
@@ -187,9 +268,8 @@ def openai_config_option_schema(
): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
- description={"suggested_value": options.get(CONF_LLM_HASS_API)},
- default="none",
- ): SelectSelector(SelectSelectorConfig(options=hass_apis)),
+ description={"suggested_value": suggested_llm_apis},
+ ): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
vol.Required(
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
): bool,
@@ -227,10 +307,35 @@ def openai_config_option_schema(
): SelectSelector(
SelectSelectorConfig(
options=["low", "medium", "high"],
- translation_key="reasoning_effort",
+ translation_key=CONF_REASONING_EFFORT,
mode=SelectSelectorMode.DROPDOWN,
)
),
+ vol.Optional(
+ CONF_WEB_SEARCH,
+ description={"suggested_value": options.get(CONF_WEB_SEARCH)},
+ default=RECOMMENDED_WEB_SEARCH,
+ ): bool,
+ vol.Optional(
+ CONF_WEB_SEARCH_CONTEXT_SIZE,
+ description={
+ "suggested_value": options.get(CONF_WEB_SEARCH_CONTEXT_SIZE)
+ },
+ default=RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=["low", "medium", "high"],
+ translation_key=CONF_WEB_SEARCH_CONTEXT_SIZE,
+ mode=SelectSelectorMode.DROPDOWN,
+ )
+ ),
+ vol.Optional(
+ CONF_WEB_SEARCH_USER_LOCATION,
+ description={
+ "suggested_value": options.get(CONF_WEB_SEARCH_USER_LOCATION)
+ },
+ default=RECOMMENDED_WEB_SEARCH_USER_LOCATION,
+ ): bool,
}
)
return schema
diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py
index 793e021e332..f022b4840eb 100644
--- a/homeassistant/components/openai_conversation/const.py
+++ b/homeassistant/components/openai_conversation/const.py
@@ -3,22 +3,34 @@
import logging
DOMAIN = "openai_conversation"
-LOGGER = logging.getLogger(__package__)
+LOGGER: logging.Logger = logging.getLogger(__package__)
-CONF_RECOMMENDED = "recommended"
-CONF_PROMPT = "prompt"
CONF_CHAT_MODEL = "chat_model"
-RECOMMENDED_CHAT_MODEL = "gpt-4o-mini"
+CONF_FILENAMES = "filenames"
CONF_MAX_TOKENS = "max_tokens"
-RECOMMENDED_MAX_TOKENS = 150
-CONF_TOP_P = "top_p"
-RECOMMENDED_TOP_P = 1.0
-CONF_TEMPERATURE = "temperature"
-RECOMMENDED_TEMPERATURE = 1.0
+CONF_PROMPT = "prompt"
+CONF_PROMPT = "prompt"
CONF_REASONING_EFFORT = "reasoning_effort"
+CONF_RECOMMENDED = "recommended"
+CONF_TEMPERATURE = "temperature"
+CONF_TOP_P = "top_p"
+CONF_WEB_SEARCH = "web_search"
+CONF_WEB_SEARCH_USER_LOCATION = "user_location"
+CONF_WEB_SEARCH_CONTEXT_SIZE = "search_context_size"
+CONF_WEB_SEARCH_CITY = "city"
+CONF_WEB_SEARCH_REGION = "region"
+CONF_WEB_SEARCH_COUNTRY = "country"
+CONF_WEB_SEARCH_TIMEZONE = "timezone"
+RECOMMENDED_CHAT_MODEL = "gpt-4o-mini"
+RECOMMENDED_MAX_TOKENS = 150
RECOMMENDED_REASONING_EFFORT = "low"
+RECOMMENDED_TEMPERATURE = 1.0
+RECOMMENDED_TOP_P = 1.0
+RECOMMENDED_WEB_SEARCH = False
+RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium"
+RECOMMENDED_WEB_SEARCH_USER_LOCATION = False
-UNSUPPORTED_MODELS = [
+UNSUPPORTED_MODELS: list[str] = [
"o1-mini",
"o1-mini-2024-09-12",
"o1-preview",
@@ -29,3 +41,12 @@ UNSUPPORTED_MODELS = [
"gpt-4o-mini-realtime-preview",
"gpt-4o-mini-realtime-preview-2024-12-17",
]
+
+WEB_SEARCH_MODELS: list[str] = [
+ "gpt-4.1",
+ "gpt-4.1-mini",
+ "gpt-4o",
+ "gpt-4o-search-preview",
+ "gpt-4o-mini",
+ "gpt-4o-mini-search-preview",
+]
diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py
index 4dee1d4b167..026e18f3ce1 100644
--- a/homeassistant/components/openai_conversation/conversation.py
+++ b/homeassistant/components/openai_conversation/conversation.py
@@ -2,21 +2,31 @@
from collections.abc import AsyncGenerator, Callable
import json
-from typing import Any, Literal, cast
+from typing import Any, Literal
import openai
from openai._streaming import AsyncStream
-from openai._types import NOT_GIVEN
-from openai.types.chat import (
- ChatCompletionAssistantMessageParam,
- ChatCompletionChunk,
- ChatCompletionMessageParam,
- ChatCompletionMessageToolCallParam,
- ChatCompletionToolMessageParam,
- ChatCompletionToolParam,
+from openai.types.responses import (
+ EasyInputMessageParam,
+ FunctionToolParam,
+ ResponseCompletedEvent,
+ ResponseErrorEvent,
+ ResponseFailedEvent,
+ ResponseFunctionCallArgumentsDeltaEvent,
+ ResponseFunctionCallArgumentsDoneEvent,
+ ResponseFunctionToolCall,
+ ResponseFunctionToolCallParam,
+ ResponseIncompleteEvent,
+ ResponseInputParam,
+ ResponseOutputItemAddedEvent,
+ ResponseOutputMessage,
+ ResponseStreamEvent,
+ ResponseTextDeltaEvent,
+ ToolParam,
+ WebSearchToolParam,
)
-from openai.types.chat.chat_completion_message_tool_call_param import Function
-from openai.types.shared_params import FunctionDefinition
+from openai.types.responses.response_input_param import FunctionCallOutput
+from openai.types.responses.web_search_tool_param import UserLocation
from voluptuous_openapi import convert
from homeassistant.components import assist_pipeline, conversation
@@ -24,8 +34,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import chat_session, device_registry as dr, intent, llm
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers import device_registry as dr, intent, llm
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OpenAIConfigEntry
from .const import (
@@ -35,6 +45,13 @@ from .const import (
CONF_REASONING_EFFORT,
CONF_TEMPERATURE,
CONF_TOP_P,
+ CONF_WEB_SEARCH,
+ CONF_WEB_SEARCH_CITY,
+ CONF_WEB_SEARCH_CONTEXT_SIZE,
+ CONF_WEB_SEARCH_COUNTRY,
+ CONF_WEB_SEARCH_REGION,
+ CONF_WEB_SEARCH_TIMEZONE,
+ CONF_WEB_SEARCH_USER_LOCATION,
DOMAIN,
LOGGER,
RECOMMENDED_CHAT_MODEL,
@@ -42,6 +59,7 @@ from .const import (
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
+ RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
)
# Max number of back and forth with the LLM to generate a response
@@ -51,7 +69,7 @@ MAX_TOOL_ITERATIONS = 10
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OpenAIConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up conversation entities."""
agent = OpenAIConversationEntity(config_entry)
@@ -60,122 +78,131 @@ async def async_setup_entry(
def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
-) -> ChatCompletionToolParam:
+) -> FunctionToolParam:
"""Format tool specification."""
- tool_spec = FunctionDefinition(
+ return FunctionToolParam(
+ type="function",
name=tool.name,
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
+ description=tool.description,
+ strict=False,
)
- if tool.description:
- tool_spec["description"] = tool.description
- return ChatCompletionToolParam(type="function", function=tool_spec)
def _convert_content_to_param(
content: conversation.Content,
-) -> ChatCompletionMessageParam:
+) -> ResponseInputParam:
"""Convert any native chat message for this agent to the native format."""
- if content.role == "tool_result":
- assert type(content) is conversation.ToolResultContent
- return ChatCompletionToolMessageParam(
- role="tool",
- tool_call_id=content.tool_call_id,
- content=json.dumps(content.tool_result),
- )
- if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr]
- role = content.role
+ messages: ResponseInputParam = []
+ if isinstance(content, conversation.ToolResultContent):
+ return [
+ FunctionCallOutput(
+ type="function_call_output",
+ call_id=content.tool_call_id,
+ output=json.dumps(content.tool_result),
+ )
+ ]
+
+ if content.content:
+ role: Literal["user", "assistant", "system", "developer"] = content.role
if role == "system":
role = "developer"
- return cast(
- ChatCompletionMessageParam,
- {"role": content.role, "content": content.content}, # type: ignore[union-attr]
+ messages.append(
+ EasyInputMessageParam(type="message", role=role, content=content.content)
)
- # Handle the Assistant content including tool calls.
- assert type(content) is conversation.AssistantContent
- return ChatCompletionAssistantMessageParam(
- role="assistant",
- content=content.content,
- tool_calls=[
- ChatCompletionMessageToolCallParam(
- id=tool_call.id,
- function=Function(
- arguments=json.dumps(tool_call.tool_args),
- name=tool_call.tool_name,
- ),
- type="function",
+ if isinstance(content, conversation.AssistantContent) and content.tool_calls:
+ messages.extend(
+ ResponseFunctionToolCallParam(
+ type="function_call",
+ name=tool_call.tool_name,
+ arguments=json.dumps(tool_call.tool_args),
+ call_id=tool_call.id,
)
for tool_call in content.tool_calls
- ],
- )
+ )
+ return messages
async def _transform_stream(
- result: AsyncStream[ChatCompletionChunk],
+ chat_log: conversation.ChatLog,
+ result: AsyncStream[ResponseStreamEvent],
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
"""Transform an OpenAI delta stream into HA format."""
- current_tool_call: dict | None = None
+ async for event in result:
+ LOGGER.debug("Received event: %s", event)
- async for chunk in result:
- LOGGER.debug("Received chunk: %s", chunk)
- choice = chunk.choices[0]
-
- if choice.finish_reason:
- if current_tool_call:
- yield {
- "tool_calls": [
- llm.ToolInput(
- id=current_tool_call["id"],
- tool_name=current_tool_call["tool_name"],
- tool_args=json.loads(current_tool_call["tool_args"]),
- )
- ]
- }
-
- break
-
- delta = chunk.choices[0].delta
-
- # We can yield delta messages not continuing or starting tool calls
- if current_tool_call is None and not delta.tool_calls:
- yield { # type: ignore[misc]
- key: value
- for key in ("role", "content")
- if (value := getattr(delta, key)) is not None
- }
- continue
-
- # When doing tool calls, we should always have a tool call
- # object or we have gotten stopped above with a finish_reason set.
- if (
- not delta.tool_calls
- or not (delta_tool_call := delta.tool_calls[0])
- or not delta_tool_call.function
- ):
- raise ValueError("Expected delta with tool call")
-
- if current_tool_call and delta_tool_call.index == current_tool_call["index"]:
- current_tool_call["tool_args"] += delta_tool_call.function.arguments or ""
- continue
-
- # We got tool call with new index, so we need to yield the previous
- if current_tool_call:
+ if isinstance(event, ResponseOutputItemAddedEvent):
+ if isinstance(event.item, ResponseOutputMessage):
+ yield {"role": event.item.role}
+ elif isinstance(event.item, ResponseFunctionToolCall):
+ current_tool_call = event.item
+ elif isinstance(event, ResponseTextDeltaEvent):
+ yield {"content": event.delta}
+ elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
+ current_tool_call.arguments += event.delta
+ elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent):
+ current_tool_call.status = "completed"
yield {
"tool_calls": [
llm.ToolInput(
- id=current_tool_call["id"],
- tool_name=current_tool_call["tool_name"],
- tool_args=json.loads(current_tool_call["tool_args"]),
+ id=current_tool_call.call_id,
+ tool_name=current_tool_call.name,
+ tool_args=json.loads(current_tool_call.arguments),
)
]
}
+ elif isinstance(event, ResponseCompletedEvent):
+ if event.response.usage is not None:
+ chat_log.async_trace(
+ {
+ "stats": {
+ "input_tokens": event.response.usage.input_tokens,
+ "output_tokens": event.response.usage.output_tokens,
+ }
+ }
+ )
+ elif isinstance(event, ResponseIncompleteEvent):
+ if event.response.usage is not None:
+ chat_log.async_trace(
+ {
+ "stats": {
+ "input_tokens": event.response.usage.input_tokens,
+ "output_tokens": event.response.usage.output_tokens,
+ }
+ }
+ )
- current_tool_call = {
- "index": delta_tool_call.index,
- "id": delta_tool_call.id,
- "tool_name": delta_tool_call.function.name,
- "tool_args": delta_tool_call.function.arguments or "",
- }
+ if (
+ event.response.incomplete_details
+ and event.response.incomplete_details.reason
+ ):
+ reason: str = event.response.incomplete_details.reason
+ else:
+ reason = "unknown reason"
+
+ if reason == "max_output_tokens":
+ reason = "max output tokens reached"
+ elif reason == "content_filter":
+ reason = "content filter triggered"
+
+ raise HomeAssistantError(f"OpenAI response incomplete: {reason}")
+ elif isinstance(event, ResponseFailedEvent):
+ if event.response.usage is not None:
+ chat_log.async_trace(
+ {
+ "stats": {
+ "input_tokens": event.response.usage.input_tokens,
+ "output_tokens": event.response.usage.output_tokens,
+ }
+ }
+ )
+ reason = "unknown reason"
+ if event.response.error is not None:
+ reason = event.response.error.message
+ raise HomeAssistantError(f"OpenAI response failed: {reason}")
+ elif isinstance(event, ResponseErrorEvent):
+ raise HomeAssistantError(f"OpenAI response error: {event.message}")
class OpenAIConversationEntity(
@@ -223,18 +250,6 @@ class OpenAIConversationEntity(
conversation.async_unset_agent(self.hass, self.entry)
await super().async_will_remove_from_hass()
- async def async_process(
- self, user_input: conversation.ConversationInput
- ) -> conversation.ConversationResult:
- """Process a sentence."""
- with (
- chat_session.async_get_chat_session(
- self.hass, user_input.conversation_id
- ) as session,
- conversation.async_get_chat_log(self.hass, session, user_input) as chat_log,
- ):
- return await self._async_handle_message(user_input, chat_log)
-
async def _async_handle_message(
self,
user_input: conversation.ConversationInput,
@@ -253,15 +268,38 @@ class OpenAIConversationEntity(
except conversation.ConverseError as err:
return err.as_conversation_result()
- tools: list[ChatCompletionToolParam] | None = None
+ tools: list[ToolParam] | None = None
if chat_log.llm_api:
tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
+ if options.get(CONF_WEB_SEARCH):
+ web_search = WebSearchToolParam(
+ type="web_search_preview",
+ search_context_size=options.get(
+ CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE
+ ),
+ )
+ if options.get(CONF_WEB_SEARCH_USER_LOCATION):
+ web_search["user_location"] = UserLocation(
+ type="approximate",
+ city=options.get(CONF_WEB_SEARCH_CITY, ""),
+ region=options.get(CONF_WEB_SEARCH_REGION, ""),
+ country=options.get(CONF_WEB_SEARCH_COUNTRY, ""),
+ timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""),
+ )
+ if tools is None:
+ tools = []
+ tools.append(web_search)
+
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
- messages = [_convert_content_to_param(content) for content in chat_log.content]
+ messages = [
+ m
+ for content in chat_log.content
+ for m in _convert_content_to_param(content)
+ ]
client = self.entry.runtime_data
@@ -269,36 +307,39 @@ class OpenAIConversationEntity(
for _iteration in range(MAX_TOOL_ITERATIONS):
model_args = {
"model": model,
- "messages": messages,
- "tools": tools or NOT_GIVEN,
- "max_completion_tokens": options.get(
+ "input": messages,
+ "max_output_tokens": options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
),
"top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
"temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
"user": chat_log.conversation_id,
+ "store": False,
"stream": True,
}
+ if tools:
+ model_args["tools"] = tools
if model.startswith("o"):
- model_args["reasoning_effort"] = options.get(
- CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
- )
+ model_args["reasoning"] = {
+ "effort": options.get(
+ CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
+ )
+ }
try:
- result = await client.chat.completions.create(**model_args)
+ result = await client.responses.create(**model_args)
+ except openai.RateLimitError as err:
+ LOGGER.error("Rate limited by OpenAI: %s", err)
+ raise HomeAssistantError("Rate limited or insufficient funds") from err
except openai.OpenAIError as err:
LOGGER.error("Error talking to OpenAI: %s", err)
raise HomeAssistantError("Error talking to OpenAI") from err
- messages.extend(
- [
- _convert_content_to_param(content)
- async for content in chat_log.async_add_delta_content_stream(
- user_input.agent_id, _transform_stream(result)
- )
- ]
- )
+ async for content in chat_log.async_add_delta_content_stream(
+ user_input.agent_id, _transform_stream(chat_log, result)
+ ):
+ messages.extend(_convert_content_to_param(content))
if not chat_log.unresponded_tool_results:
break
@@ -307,7 +348,9 @@ class OpenAIConversationEntity(
assert type(chat_log.content[-1]) is conversation.AssistantContent
intent_response.async_set_speech(chat_log.content[-1].content or "")
return conversation.ConversationResult(
- response=intent_response, conversation_id=chat_log.conversation_id
+ response=intent_response,
+ conversation_id=chat_log.conversation_id,
+ continue_conversation=chat_log.continue_conversation,
)
async def _async_entry_update_listener(
diff --git a/homeassistant/components/openai_conversation/icons.json b/homeassistant/components/openai_conversation/icons.json
index 3abecd640d1..f0ece31c304 100644
--- a/homeassistant/components/openai_conversation/icons.json
+++ b/homeassistant/components/openai_conversation/icons.json
@@ -2,6 +2,9 @@
"services": {
"generate_image": {
"service": "mdi:image-sync"
+ },
+ "generate_content": {
+ "service": "mdi:receipt-text"
}
}
}
diff --git a/homeassistant/components/openai_conversation/manifest.json b/homeassistant/components/openai_conversation/manifest.json
index a7aa7884dc4..988dd2321d5 100644
--- a/homeassistant/components/openai_conversation/manifest.json
+++ b/homeassistant/components/openai_conversation/manifest.json
@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/openai_conversation",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["openai==1.61.0"]
+ "requirements": ["openai==1.68.2"]
}
diff --git a/homeassistant/components/openai_conversation/services.yaml b/homeassistant/components/openai_conversation/services.yaml
index 3db71cae383..75fa097f25d 100644
--- a/homeassistant/components/openai_conversation/services.yaml
+++ b/homeassistant/components/openai_conversation/services.yaml
@@ -38,3 +38,23 @@ generate_image:
options:
- "vivid"
- "natural"
+generate_content:
+ fields:
+ config_entry:
+ required: true
+ selector:
+ config_entry:
+ integration: openai_conversation
+ prompt:
+ required: true
+ selector:
+ text:
+ multiline: true
+ example: "Hello, how can I help you?"
+ filenames:
+ selector:
+ text:
+ multiline: true
+ example: |
+ - /path/to/file1.txt
+ - /path/to/file2.txt
diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json
index b8768f8abbe..0a07fa354b2 100644
--- a/homeassistant/components/openai_conversation/strings.json
+++ b/homeassistant/components/openai_conversation/strings.json
@@ -24,34 +24,48 @@
"top_p": "Top P",
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
"recommended": "Recommended model settings",
- "reasoning_effort": "Reasoning effort"
+ "reasoning_effort": "Reasoning effort",
+ "web_search": "Enable web search",
+ "search_context_size": "Search context size",
+ "user_location": "Include home location"
},
"data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template.",
- "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)"
+ "reasoning_effort": "How many reasoning tokens the model should generate before creating a response to the prompt (for certain reasoning models)",
+ "web_search": "Allow the model to search the web for the latest information before generating a response",
+ "search_context_size": "High level guidance for the amount of context window space to use for the search",
+ "user_location": "Refine search results based on geography"
}
}
},
"error": {
- "model_not_supported": "This model is not supported, please select a different model"
+ "model_not_supported": "This model is not supported, please select a different model",
+ "web_search_not_supported": "Web search is not supported by this model"
}
},
"selector": {
"reasoning_effort": {
"options": {
- "low": "Low",
- "medium": "Medium",
- "high": "High"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
+ }
+ },
+ "search_context_size": {
+ "options": {
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
}
},
"services": {
"generate_image": {
"name": "Generate image",
- "description": "Turn a prompt into an image",
+ "description": "Turns a prompt into an image",
"fields": {
"config_entry": {
- "name": "Config Entry",
+ "name": "Config entry",
"description": "The config entry to use for this action"
},
"prompt": {
@@ -72,6 +86,24 @@
"description": "The style of the generated image"
}
}
+ },
+ "generate_content": {
+ "name": "Generate content",
+ "description": "Sends a conversational query to ChatGPT including any attached image or PDF files",
+ "fields": {
+ "config_entry": {
+ "name": "Config entry",
+ "description": "The config entry to use for this action"
+ },
+ "prompt": {
+ "name": "Prompt",
+ "description": "The prompt to send"
+ },
+ "filenames": {
+ "name": "Files",
+ "description": "List of files to upload"
+ }
+ }
}
},
"exceptions": {
diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py
index 55ca7bd2fb9..756823ff0ec 100644
--- a/homeassistant/components/openexchangerates/sensor.py
+++ b/homeassistant/components/openexchangerates/sensor.py
@@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_QUOTE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -19,7 +19,7 @@ ATTRIBUTION = "Data provided by openexchangerates.org"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Open Exchange Rates sensor."""
quote: str = config_entry.data.get(CONF_QUOTE, "EUR")
diff --git a/homeassistant/components/opengarage/binary_sensor.py b/homeassistant/components/opengarage/binary_sensor.py
index 55cacfb5f90..33420ab3fd5 100644
--- a/homeassistant/components/opengarage/binary_sensor.py
+++ b/homeassistant/components/opengarage/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import OpenGarageDataUpdateCoordinator
@@ -29,7 +29,9 @@ SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OpenGarage binary sensors."""
open_garage_data_coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/opengarage/button.py b/homeassistant/components/opengarage/button.py
index 9f93e0fa716..64a4f2f20e7 100644
--- a/homeassistant/components/opengarage/button.py
+++ b/homeassistant/components/opengarage/button.py
@@ -16,7 +16,7 @@ from homeassistant.components.button import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import OpenGarageDataUpdateCoordinator
@@ -43,7 +43,7 @@ BUTTONS: tuple[OpenGarageButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OpenGarage button entities."""
coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py
index 9623050c090..859e3382772 100644
--- a/homeassistant/components/opengarage/cover.py
+++ b/homeassistant/components/opengarage/cover.py
@@ -13,7 +13,7 @@ from homeassistant.components.cover import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import OpenGarageDataUpdateCoordinator
@@ -25,7 +25,9 @@ STATES_MAP = {0: CoverState.CLOSED, 1: CoverState.OPEN}
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OpenGarage covers."""
async_add_entities(
diff --git a/homeassistant/components/opengarage/sensor.py b/homeassistant/components/opengarage/sensor.py
index 003e0e0fa5a..14d14dd5d23 100644
--- a/homeassistant/components/opengarage/sensor.py
+++ b/homeassistant/components/opengarage/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import OpenGarageDataUpdateCoordinator
@@ -59,7 +59,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OpenGarage sensors."""
open_garage_data_coordinator: OpenGarageDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py
index 8c903c90bbb..9f8840b8487 100644
--- a/homeassistant/components/openhome/media_player.py
+++ b/homeassistant/components/openhome/media_player.py
@@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_PIN_INDEX, DOMAIN, SERVICE_INVOKE_PIN
@@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Openhome config entry."""
diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py
index bbe4fdac3b3..cc210866e64 100644
--- a/homeassistant/components/openhome/update.py
+++ b/homeassistant/components/openhome/update.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up update entities for Reolink component."""
diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py
index 9d317ae3e0d..0ab5b49f086 100644
--- a/homeassistant/components/opensky/sensor.py
+++ b/homeassistant/components/opensky/sensor.py
@@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
@@ -16,7 +16,7 @@ from .coordinator import OpenSkyDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize the entries."""
diff --git a/homeassistant/components/opensky/strings.json b/homeassistant/components/opensky/strings.json
index 4b4dc908b14..c699783551f 100644
--- a/homeassistant/components/opensky/strings.json
+++ b/homeassistant/components/opensky/strings.json
@@ -15,7 +15,7 @@
"options": {
"step": {
"init": {
- "description": "You can login to your OpenSky account to increase the update frequency.",
+ "description": "You can log in to your OpenSky account to increase the update frequency.",
"data": {
"radius": "[%key:component::opensky::config::step::user::data::radius%]",
"altitude": "[%key:component::opensky::config::step::user::data::altitude%]",
diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py
index 8c92c70ab49..87da159872d 100644
--- a/homeassistant/components/opentherm_gw/__init__.py
+++ b/homeassistant/components/opentherm_gw/__init__.py
@@ -9,8 +9,7 @@ import pyotgw.vars as gw_vars
from serial import SerialException
import voluptuous as vol
-from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DATE,
ATTR_ID,
@@ -21,21 +20,12 @@ from homeassistant.const import (
CONF_ID,
CONF_NAME,
EVENT_HOMEASSISTANT_STOP,
- PRECISION_HALVES,
- PRECISION_TENTHS,
- PRECISION_WHOLE,
Platform,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import (
- config_validation as cv,
- device_registry as dr,
- entity_registry as er,
- issue_registry as ir,
-)
+from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.typing import ConfigType
from .const import (
ATTR_CH_OVRD,
@@ -44,9 +34,6 @@ from .const import (
ATTR_LEVEL,
ATTR_TRANSP_ARG,
ATTR_TRANSP_CMD,
- CONF_CLIMATE,
- CONF_FLOOR_TEMP,
- CONF_PRECISION,
CONF_TEMPORARY_OVRD_MODE,
CONNECTION_TIMEOUT,
DATA_GATEWAYS,
@@ -70,29 +57,6 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
-# *_SCHEMA required for deprecated import from configuration.yaml, can be removed in 2025.4.0
-CLIMATE_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_PRECISION): vol.In(
- [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]
- ),
- vol.Optional(CONF_FLOOR_TEMP, default=False): cv.boolean,
- }
-)
-
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: cv.schema_with_slug_keys(
- {
- vol.Required(CONF_DEVICE): cv.string,
- vol.Optional(CONF_CLIMATE, default={}): CLIMATE_SCHEMA,
- vol.Optional(CONF_NAME): cv.string,
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
@@ -118,35 +82,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
gateway = OpenThermGatewayHub(hass, config_entry)
hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] = gateway
- # Migration can be removed in 2025.4.0
- dev_reg = dr.async_get(hass)
- if (
- migrate_device := dev_reg.async_get_device(
- {(DOMAIN, config_entry.data[CONF_ID])}
- )
- ) is not None:
- dev_reg.async_update_device(
- migrate_device.id,
- new_identifiers={
- (
- DOMAIN,
- f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.GATEWAY}",
- )
- },
- )
-
- # Migration can be removed in 2025.4.0
- ent_reg = er.async_get(hass)
- if (
- entity_id := ent_reg.async_get_entity_id(
- CLIMATE_DOMAIN, DOMAIN, config_entry.data[CONF_ID]
- )
- ) is not None:
- ent_reg.async_update_entity(
- entity_id,
- new_unique_id=f"{config_entry.data[CONF_ID]}-{OpenThermDeviceIdentifier.THERMOSTAT}-thermostat_entity",
- )
-
config_entry.add_update_listener(options_updated)
try:
@@ -164,33 +99,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
return True
-# Deprecated import from configuration.yaml, can be removed in 2025.4.0
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the OpenTherm Gateway component."""
- if DOMAIN in config:
- ir.async_create_issue(
- hass,
- DOMAIN,
- "deprecated_import_from_configuration_yaml",
- breaks_in_ha_version="2025.4.0",
- is_fixable=False,
- is_persistent=False,
- severity=ir.IssueSeverity.WARNING,
- translation_key="deprecated_import_from_configuration_yaml",
- )
- if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config:
- conf = config[DOMAIN]
- for device_id, device_config in conf.items():
- device_config[CONF_ID] = device_id
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=device_config
- )
- )
- return True
-
-
def register_services(hass: HomeAssistant) -> None:
"""Register services for the component."""
service_reset_schema = vol.Schema(
diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py
index 5d542bedc07..8e73392da05 100644
--- a/homeassistant/components/opentherm_gw/binary_sensor.py
+++ b/homeassistant/components/opentherm_gw/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
BOILER_DEVICE_DESCRIPTION,
@@ -393,7 +393,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[OpenThermBinarySensorEntityDescription, ...] =
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OpenTherm Gateway binary sensors."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]]
diff --git a/homeassistant/components/opentherm_gw/button.py b/homeassistant/components/opentherm_gw/button.py
index 00b91ad33e0..046b44bfa8c 100644
--- a/homeassistant/components/opentherm_gw/button.py
+++ b/homeassistant/components/opentherm_gw/button.py
@@ -13,7 +13,7 @@ from homeassistant.components.button import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OpenThermGatewayHub
from .const import (
@@ -53,7 +53,7 @@ BUTTON_DESCRIPTIONS: tuple[OpenThermButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OpenTherm Gateway buttons."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]]
diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py
index e8aa99f7325..c69151c293a 100644
--- a/homeassistant/components/opentherm_gw/climate.py
+++ b/homeassistant/components/opentherm_gw/climate.py
@@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, CONF_ID, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OpenThermGatewayHub
from .const import (
@@ -50,7 +50,7 @@ class OpenThermClimateEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an OpenTherm Gateway climate entity."""
ents = []
diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py
index bcbf279f3f7..a100dcb730f 100644
--- a/homeassistant/components/opentherm_gw/config_flow.py
+++ b/homeassistant/components/opentherm_gw/config_flow.py
@@ -95,19 +95,6 @@ class OpenThermGwConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle manual initiation of the config flow."""
return await self.async_step_init(user_input)
- # Deprecated import from configuration.yaml, can be removed in 2025.4.0
- async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
- """Import an OpenTherm Gateway device as a config entry.
-
- This flow is triggered by `async_setup` for configured devices.
- """
- formatted_config = {
- CONF_NAME: import_data.get(CONF_NAME, import_data[CONF_ID]),
- CONF_DEVICE: import_data[CONF_DEVICE],
- CONF_ID: import_data[CONF_ID],
- }
- return await self.async_step_init(info=formatted_config)
-
def _show_form(self, errors: dict[str, str] | None = None) -> ConfigFlowResult:
"""Show the config flow form with possible errors."""
return self.async_show_form(
diff --git a/homeassistant/components/opentherm_gw/select.py b/homeassistant/components/opentherm_gw/select.py
index cee1632dc48..da3fa1e80ec 100644
--- a/homeassistant/components/opentherm_gw/select.py
+++ b/homeassistant/components/opentherm_gw/select.py
@@ -20,7 +20,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OpenThermGatewayHub
from .const import (
@@ -234,7 +234,7 @@ SELECT_DESCRIPTIONS: tuple[OpenThermSelectEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OpenTherm Gateway select entities."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]]
diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py
index 5ccb4166665..f9ac1b272be 100644
--- a/homeassistant/components/opentherm_gw/sensor.py
+++ b/homeassistant/components/opentherm_gw/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
BOILER_DEVICE_DESCRIPTION,
@@ -875,7 +875,7 @@ SENSOR_DESCRIPTIONS: tuple[OpenThermSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OpenTherm Gateway sensors."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]]
diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json
index 405af126c03..ae1a1eb9276 100644
--- a/homeassistant/components/opentherm_gw/strings.json
+++ b/homeassistant/components/opentherm_gw/strings.json
@@ -172,8 +172,8 @@
"vcc": "Vcc (5V)",
"led_e": "LED E",
"led_f": "LED F",
- "home": "Home",
- "away": "Away",
+ "home": "[%key:common::state::home%]",
+ "away": "[%key:common::state::not_home%]",
"ds1820": "DS1820",
"dhw_block": "Block hot water"
}
@@ -354,12 +354,6 @@
}
}
},
- "issues": {
- "deprecated_import_from_configuration_yaml": {
- "title": "Deprecated configuration",
- "description": "Configuration of the OpenTherm Gateway integration through configuration.yaml is deprecated. Your configuration has been migrated to config entries. Please remove any OpenTherm Gateway configuration from your configuration.yaml."
- }
- },
"options": {
"step": {
"init": {
@@ -385,7 +379,7 @@
},
"set_central_heating_ovrd": {
"name": "Set central heating override",
- "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a set_control_setpoint action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the set_control_setpoint action with temperature value 0. You will only need this if you are writing your own software thermostat.",
+ "description": "Sets the central heating override option on the gateway. When overriding the control setpoint (via a 'Set control set point' action with a value other than 0), the gateway automatically enables the central heating override to start heating. This action can then be used to control the central heating override status. To return control of the central heating to the thermostat, use the 'Set control set point' action with temperature value 0. You will only need this if you are writing your own software thermostat.",
"fields": {
"gateway_id": {
"name": "[%key:component::opentherm_gw::services::reset_gateway::fields::gateway_id::name%]",
@@ -393,7 +387,7 @@
},
"ch_override": {
"name": "Central heating override",
- "description": "The desired boolean value for the central heating override."
+ "description": "Whether to enable or disable the override."
}
}
},
diff --git a/homeassistant/components/opentherm_gw/switch.py b/homeassistant/components/opentherm_gw/switch.py
index 41ffa03a932..873675f0211 100644
--- a/homeassistant/components/opentherm_gw/switch.py
+++ b/homeassistant/components/opentherm_gw/switch.py
@@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OpenThermGatewayHub
from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW, GATEWAY_DEVICE_DESCRIPTION
@@ -48,7 +48,7 @@ SWITCH_DESCRIPTIONS: tuple[OpenThermSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OpenTherm Gateway switches."""
gw_hub = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]]
diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py
index 018d91710df..f45404ce38e 100644
--- a/homeassistant/components/openuv/binary_sensor.py
+++ b/homeassistant/components/openuv/binary_sensor.py
@@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import as_local, parse_datetime, utcnow
from .const import DATA_PROTECTION_WINDOW, DOMAIN, LOGGER, TYPE_PROTECTION_WINDOW
@@ -25,7 +25,9 @@ BINARY_SENSOR_DESCRIPTION_PROTECTION_WINDOW = BinarySensorEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
# Once we've successfully authenticated, we re-enable client request retries:
"""Set up an OpenUV sensor based on a config entry."""
diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py
index 742017be639..5b681655e2b 100644
--- a/homeassistant/components/openuv/sensor.py
+++ b/homeassistant/components/openuv/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UV_INDEX, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import as_local, parse_datetime
from .const import (
@@ -166,7 +166,9 @@ SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a OpenUV sensor based on a config entry."""
coordinators: dict[str, OpenUvCoordinator] = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json
index 9349d2cc116..f3b9aa686d5 100644
--- a/homeassistant/components/openuv/strings.json
+++ b/homeassistant/components/openuv/strings.json
@@ -54,10 +54,10 @@
"name": "Current UV level",
"state": {
"extreme": "Extreme",
- "high": "High",
- "low": "Low",
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
"moderate": "Moderate",
- "very_high": "Very high"
+ "very_high": "[%key:common::state::very_high%]"
}
},
"max_uv_index": {
diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py
index fa51b91dc6d..40ddf0ff37e 100644
--- a/homeassistant/components/openweathermap/__init__.py
+++ b/homeassistant/components/openweathermap/__init__.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_LANGUAGE, CONF_MODE, CONF_NAME
from homeassistant.core import HomeAssistant
-from .const import CONFIG_FLOW_VERSION, OWM_MODE_V25, PLATFORMS
+from .const import CONFIG_FLOW_VERSION, DEFAULT_OWM_MODE, OWM_MODES, PLATFORMS
from .coordinator import WeatherUpdateCoordinator
from .repairs import async_create_issue, async_delete_issue
from .utils import build_data_and_options
@@ -39,7 +39,7 @@ async def async_setup_entry(
language = entry.options[CONF_LANGUAGE]
mode = entry.options[CONF_MODE]
- if mode == OWM_MODE_V25:
+ if mode not in OWM_MODES:
async_create_issue(hass, entry.entry_id)
else:
async_delete_issue(hass, entry.entry_id)
@@ -70,7 +70,7 @@ async def async_migrate_entry(
_LOGGER.debug("Migrating OpenWeatherMap entry from version %s", version)
if version < 5:
- combined_data = {**data, **options, CONF_MODE: OWM_MODE_V25}
+ combined_data = {**data, **options, CONF_MODE: DEFAULT_OWM_MODE}
new_data, new_options = build_data_and_options(combined_data)
config_entries.async_update_entry(
entry,
diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py
index 81a6544c7ce..fbd2cb1aee2 100644
--- a/homeassistant/components/openweathermap/const.py
+++ b/homeassistant/components/openweathermap/const.py
@@ -48,6 +48,7 @@ ATTR_API_WEATHER_CODE = "weather_code"
ATTR_API_CLOUD_COVERAGE = "cloud_coverage"
ATTR_API_FORECAST = "forecast"
ATTR_API_CURRENT = "current"
+ATTR_API_MINUTE_FORECAST = "minute_forecast"
ATTR_API_HOURLY_FORECAST = "hourly_forecast"
ATTR_API_DAILY_FORECAST = "daily_forecast"
UPDATE_LISTENER = "update_listener"
@@ -61,10 +62,8 @@ FORECAST_MODE_ONECALL_DAILY = "onecall_daily"
OWM_MODE_FREE_CURRENT = "current"
OWM_MODE_FREE_FORECAST = "forecast"
OWM_MODE_V30 = "v3.0"
-OWM_MODE_V25 = "v2.5"
OWM_MODES = [
OWM_MODE_V30,
- OWM_MODE_V25,
OWM_MODE_FREE_CURRENT,
OWM_MODE_FREE_FORECAST,
]
diff --git a/homeassistant/components/openweathermap/coordinator.py b/homeassistant/components/openweathermap/coordinator.py
index 55c1aa469c2..994949b5e03 100644
--- a/homeassistant/components/openweathermap/coordinator.py
+++ b/homeassistant/components/openweathermap/coordinator.py
@@ -10,6 +10,7 @@ from pyopenweathermap import (
CurrentWeather,
DailyWeatherForecast,
HourlyWeatherForecast,
+ MinutelyWeatherForecast,
OWMClient,
RequestError,
WeatherReport,
@@ -34,10 +35,14 @@ from .const import (
ATTR_API_CONDITION,
ATTR_API_CURRENT,
ATTR_API_DAILY_FORECAST,
+ ATTR_API_DATETIME,
ATTR_API_DEW_POINT,
ATTR_API_FEELS_LIKE_TEMPERATURE,
+ ATTR_API_FORECAST,
ATTR_API_HOURLY_FORECAST,
ATTR_API_HUMIDITY,
+ ATTR_API_MINUTE_FORECAST,
+ ATTR_API_PRECIPITATION,
ATTR_API_PRECIPITATION_KIND,
ATTR_API_PRESSURE,
ATTR_API_RAIN,
@@ -106,6 +111,11 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
return {
ATTR_API_CURRENT: current_weather,
+ ATTR_API_MINUTE_FORECAST: (
+ self._get_minute_weather_data(weather_report.minutely_forecast)
+ if weather_report.minutely_forecast is not None
+ else {}
+ ),
ATTR_API_HOURLY_FORECAST: [
self._get_hourly_forecast_weather_data(item)
for item in weather_report.hourly_forecast
@@ -116,6 +126,20 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator):
],
}
+ def _get_minute_weather_data(
+ self, minute_forecast: list[MinutelyWeatherForecast]
+ ) -> dict:
+ """Get minute weather data from the forecast."""
+ return {
+ ATTR_API_FORECAST: [
+ {
+ ATTR_API_DATETIME: item.date_time,
+ ATTR_API_PRECIPITATION: round(item.precipitation, 2),
+ }
+ for item in minute_forecast
+ ]
+ }
+
def _get_current_weather_data(self, current_weather: CurrentWeather):
return {
ATTR_API_CONDITION: self._get_condition(current_weather.condition.id),
diff --git a/homeassistant/components/openweathermap/icons.json b/homeassistant/components/openweathermap/icons.json
new file mode 100644
index 00000000000..d493b1538ba
--- /dev/null
+++ b/homeassistant/components/openweathermap/icons.json
@@ -0,0 +1,7 @@
+{
+ "services": {
+ "get_minute_forecast": {
+ "service": "mdi:weather-snowy-rainy"
+ }
+ }
+}
diff --git a/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json
index 14313a5a77e..88510aaae8c 100644
--- a/homeassistant/components/openweathermap/manifest.json
+++ b/homeassistant/components/openweathermap/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/openweathermap",
"iot_class": "cloud_polling",
"loggers": ["pyopenweathermap"],
- "requirements": ["pyopenweathermap==0.2.1"]
+ "requirements": ["pyopenweathermap==0.2.2"]
}
diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py
index 46789f4b3d2..a595652d90b 100644
--- a/homeassistant/components/openweathermap/sensor.py
+++ b/homeassistant/components/openweathermap/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -89,7 +89,8 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
key=ATTR_API_WIND_BEARING,
name="Wind bearing",
native_unit_of_measurement=DEGREE,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
SensorEntityDescription(
key=ATTR_API_HUMIDITY,
@@ -156,7 +157,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OpenweathermapConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OpenWeatherMap sensor entities based on a config entry."""
domain_data = config_entry.runtime_data
diff --git a/homeassistant/components/openweathermap/services.yaml b/homeassistant/components/openweathermap/services.yaml
new file mode 100644
index 00000000000..6bbcf1b23e4
--- /dev/null
+++ b/homeassistant/components/openweathermap/services.yaml
@@ -0,0 +1,5 @@
+get_minute_forecast:
+ target:
+ entity:
+ domain: weather
+ integration: openweathermap
diff --git a/homeassistant/components/openweathermap/strings.json b/homeassistant/components/openweathermap/strings.json
index 46b5feab75c..1aa161c87dc 100644
--- a/homeassistant/components/openweathermap/strings.json
+++ b/homeassistant/components/openweathermap/strings.json
@@ -47,5 +47,16 @@
}
}
}
+ },
+ "services": {
+ "get_minute_forecast": {
+ "name": "Get minute forecast",
+ "description": "Retrieves a minute-by-minute weather forecast for one hour."
+ }
+ },
+ "exceptions": {
+ "service_minute_forecast_mode": {
+ "message": "Minute forecast is available only when {name} mode is set to v3.0"
+ }
}
}
diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py
index 3a134a0ee26..12d883c871a 100644
--- a/homeassistant/components/openweathermap/weather.py
+++ b/homeassistant/components/openweathermap/weather.py
@@ -14,9 +14,11 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import HomeAssistant, SupportsResponse, callback
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers import entity_platform
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OpenweathermapConfigEntry
from .const import (
@@ -28,6 +30,7 @@ from .const import (
ATTR_API_FEELS_LIKE_TEMPERATURE,
ATTR_API_HOURLY_FORECAST,
ATTR_API_HUMIDITY,
+ ATTR_API_MINUTE_FORECAST,
ATTR_API_PRESSURE,
ATTR_API_TEMPERATURE,
ATTR_API_VISIBILITY_DISTANCE,
@@ -39,16 +42,17 @@ from .const import (
DOMAIN,
MANUFACTURER,
OWM_MODE_FREE_FORECAST,
- OWM_MODE_V25,
OWM_MODE_V30,
)
from .coordinator import WeatherUpdateCoordinator
+SERVICE_GET_MINUTE_FORECAST = "get_minute_forecast"
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OpenweathermapConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OpenWeatherMap weather entity based on a config entry."""
domain_data = config_entry.runtime_data
@@ -61,6 +65,14 @@ async def async_setup_entry(
async_add_entities([owm_weather], False)
+ platform = entity_platform.async_get_current_platform()
+ platform.async_register_entity_service(
+ name=SERVICE_GET_MINUTE_FORECAST,
+ schema=None,
+ func="async_get_minute_forecast",
+ supports_response=SupportsResponse.ONLY,
+ )
+
class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordinator]):
"""Implementation of an OpenWeatherMap sensor."""
@@ -91,8 +103,9 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
manufacturer=MANUFACTURER,
name=DEFAULT_NAME,
)
+ self.mode = mode
- if mode in (OWM_MODE_V30, OWM_MODE_V25):
+ if mode == OWM_MODE_V30:
self._attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY
| WeatherEntityFeature.FORECAST_HOURLY
@@ -100,6 +113,17 @@ class OpenWeatherMapWeather(SingleCoordinatorWeatherEntity[WeatherUpdateCoordina
elif mode == OWM_MODE_FREE_FORECAST:
self._attr_supported_features = WeatherEntityFeature.FORECAST_HOURLY
+ async def async_get_minute_forecast(self) -> dict[str, list[dict]] | dict:
+ """Return Minute forecast."""
+
+ if self.mode == OWM_MODE_V30:
+ return self.coordinator.data[ATTR_API_MINUTE_FORECAST]
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="service_minute_forecast_mode",
+ translation_placeholders={"name": DEFAULT_NAME},
+ )
+
@property
def condition(self) -> str | None:
"""Return the current condition."""
diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py
index c351f99339a..e8b6dbf9718 100644
--- a/homeassistant/components/opower/coordinator.py
+++ b/homeassistant/components/opower/coordinator.py
@@ -16,7 +16,11 @@ from opower import (
from opower.exceptions import ApiException, CannotConnect, InvalidAuth
from homeassistant.components.recorder import get_instance
-from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
+from homeassistant.components.recorder.models import (
+ StatisticData,
+ StatisticMeanType,
+ StatisticMetaData,
+)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
@@ -64,7 +68,6 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
config_entry.data[CONF_PASSWORD],
config_entry.data.get(CONF_TOTP_SECRET),
)
- self._statistic_ids: set[str] = set()
@callback
def _dummy_listener() -> None:
@@ -76,12 +79,6 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
# _async_update_data not periodically getting called which is needed for _insert_statistics.
self.async_add_listener(_dummy_listener)
- self.config_entry.async_on_unload(self._clear_statistics)
-
- def _clear_statistics(self) -> None:
- """Clear statistics."""
- get_instance(self.hass).async_clear_statistics(list(self._statistic_ids))
-
async def _async_update_data(
self,
) -> dict[str, Forecast]:
@@ -127,8 +124,6 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
)
cost_statistic_id = f"{DOMAIN}:{id_prefix}_energy_cost"
consumption_statistic_id = f"{DOMAIN}:{id_prefix}_energy_consumption"
- self._statistic_ids.add(cost_statistic_id)
- self._statistic_ids.add(consumption_statistic_id)
_LOGGER.debug(
"Updating Statistics for %s and %s",
cost_statistic_id,
@@ -210,7 +205,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
f"{account.meter_type.name.lower()} {account.utility_account_id}"
)
cost_metadata = StatisticMetaData(
- has_mean=False,
+ mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} cost",
source=DOMAIN,
@@ -218,7 +213,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]):
unit_of_measurement=None,
)
consumption_metadata = StatisticMetaData(
- has_mean=False,
+ mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{name_prefix} consumption",
source=DOMAIN,
diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json
index d168cba5752..2cc942363cf 100644
--- a/homeassistant/components/opower/manifest.json
+++ b/homeassistant/components/opower/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/opower",
"iot_class": "cloud_polling",
"loggers": ["opower"],
- "requirements": ["opower==0.8.9"]
+ "requirements": ["opower==0.11.1"]
}
diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py
index 1b3aa0fd710..46aa9e9b318 100644
--- a/homeassistant/components/opower/sensor.py
+++ b/homeassistant/components/opower/sensor.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
+from datetime import date
from opower import Forecast, MeterType, UnitOfMeasure
@@ -16,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -28,7 +29,7 @@ from .coordinator import OpowerConfigEntry, OpowerCoordinator
class OpowerEntityDescription(SensorEntityDescription):
"""Class describing Opower sensors entities."""
- value_fn: Callable[[Forecast], str | float]
+ value_fn: Callable[[Forecast], str | float | date]
# suggested_display_precision=0 for all sensors since
@@ -96,7 +97,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- value_fn=lambda data: str(data.start_date),
+ value_fn=lambda data: data.start_date,
),
OpowerEntityDescription(
key="elec_end_date",
@@ -104,7 +105,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- value_fn=lambda data: str(data.end_date),
+ value_fn=lambda data: data.end_date,
),
)
GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
@@ -168,7 +169,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- value_fn=lambda data: str(data.start_date),
+ value_fn=lambda data: data.start_date,
),
OpowerEntityDescription(
key="gas_end_date",
@@ -176,7 +177,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
- value_fn=lambda data: str(data.end_date),
+ value_fn=lambda data: data.end_date,
),
)
@@ -184,7 +185,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: OpowerConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Opower sensor."""
@@ -246,7 +247,7 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity):
self.utility_account_id = utility_account_id
@property
- def native_value(self) -> StateType:
+ def native_value(self) -> StateType | date:
"""Return the state."""
if self.coordinator.data is not None:
return self.entity_description.value_fn(
diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json
index 362e6cd7596..749545743fe 100644
--- a/homeassistant/components/opower/strings.json
+++ b/homeassistant/components/opower/strings.json
@@ -11,7 +11,7 @@
"mfa": {
"description": "The TOTP secret below is not one of the 6 digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.",
"data": {
- "totp_secret": "TOTP Secret"
+ "totp_secret": "TOTP secret"
}
},
"reauth_confirm": {
@@ -19,7 +19,7 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
- "totp_secret": "TOTP Secret"
+ "totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]"
}
}
},
diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py
index 9994bfc6443..3b345f4b36a 100644
--- a/homeassistant/components/oralb/sensor.py
+++ b/homeassistant/components/oralb/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from . import OralBConfigEntry
@@ -108,7 +108,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: OralBConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OralB BLE sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/osoenergy/binary_sensor.py b/homeassistant/components/osoenergy/binary_sensor.py
index 0cf0ac74d36..a2ba61ccbe4 100644
--- a/homeassistant/components/osoenergy/binary_sensor.py
+++ b/homeassistant/components/osoenergy/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import OSOEnergyEntity
@@ -45,7 +45,9 @@ SENSOR_TYPES: dict[str, OSOEnergyBinarySensorEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OSO Energy binary sensor."""
osoenergy: OSOEnergy = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/osoenergy/sensor.py b/homeassistant/components/osoenergy/sensor.py
index 40ec33e3e02..18859627952 100644
--- a/homeassistant/components/osoenergy/sensor.py
+++ b/homeassistant/components/osoenergy/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
@@ -138,7 +138,9 @@ SENSOR_TYPES: dict[str, OSOEnergySensorEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OSO Energy sensor."""
osoenergy = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/osoenergy/strings.json b/homeassistant/components/osoenergy/strings.json
index ca23265048f..465f3f15c6b 100644
--- a/homeassistant/components/osoenergy/strings.json
+++ b/homeassistant/components/osoenergy/strings.json
@@ -55,12 +55,12 @@
"heater_mode": {
"name": "Heater mode",
"state": {
- "auto": "Auto",
+ "off": "[%key:common::state::off%]",
+ "auto": "[%key:common::state::auto%]",
+ "manual": "[%key:common::state::manual%]",
"extraenergy": "Extra energy",
"ffr": "Fast frequency reserve",
"legionella": "Legionella",
- "manual": "Manual",
- "off": "Off",
"powersave": "Power save",
"voltage": "Voltage"
}
@@ -70,7 +70,7 @@
"state": {
"advanced": "Advanced",
"gridcompany": "Grid company",
- "off": "Off",
+ "off": "[%key:common::state::off%]",
"oso": "OSO",
"smartcompany": "Smart company"
}
@@ -215,7 +215,7 @@
"fields": {
"until_temp_limit": {
"name": "Until temperature limit",
- "description": "Choose if heating should be off until min temperature (True) is reached or for one hour (False)"
+ "description": "Whether heating should be off until the minimum temperature is reached instead of for one hour."
}
}
},
@@ -225,7 +225,7 @@
"fields": {
"until_temp_limit": {
"name": "Until temperature limit",
- "description": "Choose if heating should be on until max temperature (True) is reached or for one hour (False)"
+ "description": "Whether heating should be on until the maximum temperature is reached instead of for one hour."
}
}
}
diff --git a/homeassistant/components/osoenergy/water_heater.py b/homeassistant/components/osoenergy/water_heater.py
index b3281193da3..07820ee97d5 100644
--- a/homeassistant/components/osoenergy/water_heater.py
+++ b/homeassistant/components/osoenergy/water_heater.py
@@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonValueType
@@ -49,7 +49,9 @@ SERVICE_TURN_ON = "turn_on"
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OSO Energy heater based on a config entry."""
osoenergy = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py
index 4b95be1d40d..0756f32ab18 100644
--- a/homeassistant/components/otbr/__init__.py
+++ b/homeassistant/components/otbr/__init__.py
@@ -7,16 +7,20 @@ import logging
import aiohttp
import python_otbr_api
+from homeassistant.components.homeassistant_hardware.helpers import (
+ async_notify_firmware_info,
+ async_register_firmware_info_provider,
+)
from homeassistant.components.thread import async_add_dataset
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
-from . import websocket_api
+from . import homeassistant_hardware, websocket_api
from .const import DOMAIN
+from .types import OTBRConfigEntry
from .util import (
GetBorderAgentIdNotSupported,
OTBRData,
@@ -28,12 +32,13 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
-type OTBRConfigEntry = ConfigEntry[OTBRData]
-
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Open Thread Border Router component."""
websocket_api.async_setup(hass)
+
+ async_register_firmware_info_provider(hass, DOMAIN, homeassistant_hardware)
+
return True
@@ -77,6 +82,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: OTBRConfigEntry) -> bool
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
entry.runtime_data = otbrdata
+ if fw_info := await homeassistant_hardware.async_get_firmware_info(hass, entry):
+ await async_notify_firmware_info(hass, DOMAIN, fw_info)
+
return True
diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py
index aff79ca4651..514f6c7617c 100644
--- a/homeassistant/components/otbr/config_flow.py
+++ b/homeassistant/components/otbr/config_flow.py
@@ -16,7 +16,12 @@ import yarl
from homeassistant.components.hassio import AddonError, AddonManager
from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
from homeassistant.components.thread import async_get_preferred_dataset
-from homeassistant.config_entries import SOURCE_HASSIO, ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ SOURCE_HASSIO,
+ ConfigEntryState,
+ ConfigFlow,
+ ConfigFlowResult,
+)
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -201,12 +206,23 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN):
# we have to assume it's the first version
# This check can be removed in HA Core 2025.9
unique_id = discovery_info.uuid
+
+ if unique_id != discovery_info.uuid:
+ continue
+
if (
- unique_id != discovery_info.uuid
- or current_url.host != config["host"]
+ current_url.host != config["host"]
or current_url.port == config["port"]
):
+ # Reload the entry since OTBR has restarted
+ if current_entry.state == ConfigEntryState.LOADED:
+ assert current_entry.unique_id is not None
+ await self.hass.config_entries.async_reload(
+ current_entry.entry_id
+ )
+
continue
+
# Update URL with the new port
self.hass.config_entries.async_update_entry(
current_entry,
diff --git a/homeassistant/components/otbr/homeassistant_hardware.py b/homeassistant/components/otbr/homeassistant_hardware.py
new file mode 100644
index 00000000000..94193be1359
--- /dev/null
+++ b/homeassistant/components/otbr/homeassistant_hardware.py
@@ -0,0 +1,76 @@
+"""Home Assistant Hardware firmware utilities."""
+
+from __future__ import annotations
+
+import logging
+
+from yarl import URL
+
+from homeassistant.components.hassio import AddonManager
+from homeassistant.components.homeassistant_hardware.util import (
+ ApplicationType,
+ FirmwareInfo,
+ OwningAddon,
+ OwningIntegration,
+ get_otbr_addon_firmware_info,
+)
+from homeassistant.config_entries import ConfigEntryState
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.hassio import is_hassio
+
+from .const import DOMAIN
+from .types import OTBRConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_firmware_info(
+ hass: HomeAssistant, config_entry: OTBRConfigEntry
+) -> FirmwareInfo | None:
+ """Return firmware information for the OpenThread Border Router."""
+ owners: list[OwningIntegration | OwningAddon] = [
+ OwningIntegration(config_entry_id=config_entry.entry_id)
+ ]
+
+ device = None
+
+ if is_hassio(hass) and (host := URL(config_entry.data["url"]).host) is not None:
+ otbr_addon_manager = AddonManager(
+ hass=hass,
+ logger=_LOGGER,
+ addon_name="OpenThread Border Router",
+ addon_slug=host.replace("-", "_"),
+ )
+
+ if (
+ addon_fw_info := await get_otbr_addon_firmware_info(
+ hass, otbr_addon_manager
+ )
+ ) is not None:
+ device = addon_fw_info.device
+ owners.extend(addon_fw_info.owners)
+
+ firmware_version = None
+
+ if config_entry.state in (
+ # This function is called during OTBR config entry setup so we need to account
+ # for both config entry states
+ ConfigEntryState.LOADED,
+ ConfigEntryState.SETUP_IN_PROGRESS,
+ ):
+ try:
+ firmware_version = await config_entry.runtime_data.get_coprocessor_version()
+ except HomeAssistantError:
+ firmware_version = None
+
+ if device is None:
+ return None
+
+ return FirmwareInfo(
+ device=device,
+ firmware_type=ApplicationType.SPINEL,
+ firmware_version=firmware_version,
+ source=DOMAIN,
+ owners=owners,
+ )
diff --git a/homeassistant/components/otbr/strings.json b/homeassistant/components/otbr/strings.json
index e1afa5b8909..3a9661c454d 100644
--- a/homeassistant/components/otbr/strings.json
+++ b/homeassistant/components/otbr/strings.json
@@ -5,7 +5,7 @@
"data": {
"url": "[%key:common::config_flow::data::url%]"
},
- "description": "Provide URL for the Open Thread Border Router's REST API"
+ "description": "Provide URL for the OpenThread Border Router's REST API"
}
},
"error": {
@@ -20,8 +20,8 @@
},
"issues": {
"get_get_border_agent_id_unsupported": {
- "title": "The OTBR does not support border agent ID",
- "description": "Your OTBR does not support border agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nTo update the OTBR, update the Open Thread Border Router or Silicon Labs Multiprotocol add-on if you use the OTBR from the add-on, otherwise update your self managed OTBR."
+ "title": "The OTBR does not support Border Agent ID",
+ "description": "Your OTBR does not support Border Agent ID.\n\nTo fix this issue, update the OTBR to the latest version and restart Home Assistant.\nIf you are using an OTBR integrated in Home Assistant, update either the OpenThread Border Router add-on or the Silicon Labs Multiprotocol add-on. Otherwise update your self-managed OTBR."
},
"insecure_thread_network": {
"title": "Insecure Thread network settings detected",
diff --git a/homeassistant/components/otbr/types.py b/homeassistant/components/otbr/types.py
new file mode 100644
index 00000000000..eff6aa980d6
--- /dev/null
+++ b/homeassistant/components/otbr/types.py
@@ -0,0 +1,7 @@
+"""The Open Thread Border Router integration types."""
+
+from homeassistant.config_entries import ConfigEntry
+
+from .util import OTBRData
+
+type OTBRConfigEntry = ConfigEntry[OTBRData]
diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py
index 351e23c7736..30e456e11a8 100644
--- a/homeassistant/components/otbr/util.py
+++ b/homeassistant/components/otbr/util.py
@@ -163,6 +163,11 @@ class OTBRData:
"""Get extended address (EUI-64)."""
return await self.api.get_extended_address()
+ @_handle_otbr_error
+ async def get_coprocessor_version(self) -> str:
+ """Get coprocessor firmware version."""
+ return await self.api.get_coprocessor_version()
+
async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None:
"""Return the allowed channel, or None if there's no restriction."""
diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py
index 255bc0ded34..af508d2e915 100644
--- a/homeassistant/components/otp/sensor.py
+++ b/homeassistant/components/otp/sensor.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_TOKEN
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
@@ -20,7 +20,9 @@ TIME_STEP = 30 # Default time step assumed by Google Authenticator
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OTP sensor."""
diff --git a/homeassistant/components/ourgroceries/todo.py b/homeassistant/components/ourgroceries/todo.py
index 5b8d19e5aa1..f257ef481c7 100644
--- a/homeassistant/components/ourgroceries/todo.py
+++ b/homeassistant/components/ourgroceries/todo.py
@@ -11,7 +11,7 @@ from homeassistant.components.todo import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -19,7 +19,9 @@ from .coordinator import OurGroceriesDataUpdateCoordinator
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the OurGroceries todo platform config entry."""
coordinator: OurGroceriesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/overkiz/alarm_control_panel.py b/homeassistant/components/overkiz/alarm_control_panel.py
index 90c135291c3..1a5490dd329 100644
--- a/homeassistant/components/overkiz/alarm_control_panel.py
+++ b/homeassistant/components/overkiz/alarm_control_panel.py
@@ -19,7 +19,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OverkizDataConfigEntry
from .coordinator import OverkizDataUpdateCoordinator
@@ -209,7 +209,7 @@ SUPPORTED_DEVICES = {description.key: description for description in ALARM_DESCR
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz alarm control panel from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/binary_sensor.py b/homeassistant/components/overkiz/binary_sensor.py
index 3a75cd77c2f..5db96e17322 100644
--- a/homeassistant/components/overkiz/binary_sensor.py
+++ b/homeassistant/components/overkiz/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OverkizDataConfigEntry
from .const import IGNORED_OVERKIZ_DEVICES
@@ -49,6 +49,7 @@ BINARY_SENSOR_DESCRIPTIONS: list[OverkizBinarySensorDescription] = [
key=OverkizState.CORE_WATER_DETECTION,
name="Water",
icon="mdi:water",
+ device_class=BinarySensorDeviceClass.MOISTURE,
value_fn=lambda state: state == OverkizCommandParam.DETECTED,
),
# AirSensor/AirFlowSensor
@@ -143,7 +144,7 @@ SUPPORTED_STATES = {
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz binary sensors from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/button.py b/homeassistant/components/overkiz/button.py
index 92711ac8ca8..f4e051ef9ca 100644
--- a/homeassistant/components/overkiz/button.py
+++ b/homeassistant/components/overkiz/button.py
@@ -14,7 +14,7 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OverkizDataConfigEntry
from .const import IGNORED_OVERKIZ_DEVICES
@@ -100,7 +100,7 @@ SUPPORTED_COMMANDS = {
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz button from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/climate/__init__.py b/homeassistant/components/overkiz/climate/__init__.py
index 3276a1979cc..058c3aefdb7 100644
--- a/homeassistant/components/overkiz/climate/__init__.py
+++ b/homeassistant/components/overkiz/climate/__init__.py
@@ -10,7 +10,7 @@ from pyoverkiz.enums.ui import UIWidget
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .. import OverkizDataConfigEntry
from .atlantic_electrical_heater import AtlanticElectricalHeater
@@ -82,7 +82,7 @@ WIDGET_AND_PROTOCOL_TO_CLIMATE_ENTITY = {
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz climate from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/cover/__init__.py b/homeassistant/components/overkiz/cover/__init__.py
index 38c02eba1bb..dd3216f9c10 100644
--- a/homeassistant/components/overkiz/cover/__init__.py
+++ b/homeassistant/components/overkiz/cover/__init__.py
@@ -4,7 +4,7 @@ from pyoverkiz.enums import OverkizCommand, UIClass
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .. import OverkizDataConfigEntry
from .awning import Awning
@@ -15,7 +15,7 @@ from .vertical_cover import LowSpeedCover, VerticalCover
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz covers from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/light.py b/homeassistant/components/overkiz/light.py
index 933d4cf695b..acd63140196 100644
--- a/homeassistant/components/overkiz/light.py
+++ b/homeassistant/components/overkiz/light.py
@@ -14,7 +14,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OverkizDataConfigEntry
from .coordinator import OverkizDataUpdateCoordinator
@@ -24,7 +24,7 @@ from .entity import OverkizEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz lights from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/lock.py b/homeassistant/components/overkiz/lock.py
index 1c073d2f9aa..16ec32b0667 100644
--- a/homeassistant/components/overkiz/lock.py
+++ b/homeassistant/components/overkiz/lock.py
@@ -9,7 +9,7 @@ from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
from homeassistant.components.lock import LockEntity
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OverkizDataConfigEntry
from .entity import OverkizEntity
@@ -18,7 +18,7 @@ from .entity import OverkizEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz locks from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json
index c25accd87f3..7f4be56979a 100644
--- a/homeassistant/components/overkiz/manifest.json
+++ b/homeassistant/components/overkiz/manifest.json
@@ -13,7 +13,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
- "requirements": ["pyoverkiz==1.16.0"],
+ "requirements": ["pyoverkiz==1.17.0"],
"zeroconf": [
{
"type": "_kizbox._tcp.local.",
diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py
index 0e03e822424..70028f138b7 100644
--- a/homeassistant/components/overkiz/number.py
+++ b/homeassistant/components/overkiz/number.py
@@ -14,9 +14,9 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
-from homeassistant.const import EntityCategory, UnitOfTemperature
+from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OverkizDataConfigEntry
from .const import IGNORED_OVERKIZ_DEVICES
@@ -172,6 +172,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [
native_max_value=7,
set_native_value=_async_set_native_value_boost_mode_duration,
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.DAYS,
),
# DomesticHotWaterProduction - away mode in days (0 - 6)
OverkizNumberDescription(
@@ -182,6 +184,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [
native_min_value=0,
native_max_value=6,
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
+ native_unit_of_measurement=UnitOfTime.DAYS,
),
]
@@ -191,7 +195,7 @@ SUPPORTED_STATES = {description.key: description for description in NUMBER_DESCR
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz number from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/scene.py b/homeassistant/components/overkiz/scene.py
index 4533ed3245c..bd362b4b372 100644
--- a/homeassistant/components/overkiz/scene.py
+++ b/homeassistant/components/overkiz/scene.py
@@ -9,7 +9,7 @@ from pyoverkiz.models import Scenario
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OverkizDataConfigEntry
@@ -17,7 +17,7 @@ from . import OverkizDataConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz scenes from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py
index ac467eaaa7a..e23dafdaab8 100644
--- a/homeassistant/components/overkiz/select.py
+++ b/homeassistant/components/overkiz/select.py
@@ -10,7 +10,7 @@ from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OverkizDataConfigEntry
from .const import IGNORED_OVERKIZ_DEVICES
@@ -129,7 +129,7 @@ SUPPORTED_STATES = {description.key: description for description in SELECT_DESCR
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz select from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py
index 81a9ab41d2d..b0a15b3970e 100644
--- a/homeassistant/components/overkiz/sensor.py
+++ b/homeassistant/components/overkiz/sensor.py
@@ -23,6 +23,7 @@ from homeassistant.const import (
EntityCategory,
UnitOfEnergy,
UnitOfPower,
+ UnitOfSpeed,
UnitOfTemperature,
UnitOfTime,
UnitOfVolume,
@@ -30,7 +31,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import OverkizDataConfigEntry
@@ -70,6 +71,15 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
options=["full", "normal", "medium", "low", "verylow"],
translation_key="battery",
),
+ OverkizSensorDescription(
+ key=OverkizState.CORE_BATTERY_DISCRETE_LEVEL,
+ name="Battery",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ icon="mdi:battery",
+ device_class=SensorDeviceClass.ENUM,
+ options=["good", "medium", "low", "critical"],
+ translation_key="battery",
+ ),
OverkizSensorDescription(
key=OverkizState.CORE_RSSI_LEVEL,
name="RSSI level",
@@ -117,6 +127,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
name="Outlet engine",
icon="mdi:fan-chevron-down",
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
state_class=SensorStateClass.MEASUREMENT,
),
OverkizSensorDescription(
@@ -143,14 +154,23 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
OverkizSensorDescription(
key=OverkizState.CORE_FOSSIL_ENERGY_CONSUMPTION,
name="Fossil energy consumption",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_GAS_CONSUMPTION,
name="Gas consumption",
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
+ device_class=SensorDeviceClass.GAS,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_THERMAL_ENERGY_CONSUMPTION,
name="Thermal energy consumption",
+ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
# LightSensor/LuminanceSensor
OverkizSensorDescription(
@@ -195,7 +215,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF2,
@@ -204,7 +224,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF3,
@@ -213,7 +233,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF4,
@@ -222,7 +242,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF5,
@@ -231,7 +251,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF6,
@@ -240,7 +260,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF7,
@@ -249,7 +269,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF8,
@@ -258,7 +278,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
OverkizSensorDescription(
key=OverkizState.CORE_CONSUMPTION_TARIFF9,
@@ -267,7 +287,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
# core:MeasuredValueType = core:ElectricalEnergyInWh
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
entity_registry_enabled_default=False,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.TOTAL_INCREASING,
),
# HumiditySensor/RelativeHumiditySensor
OverkizSensorDescription(
@@ -333,6 +353,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
name="Sun energy",
native_value=lambda value: round(cast(float, value), 2),
icon="mdi:solar-power",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
# WindSensor/WindSpeedSensor
@@ -341,6 +363,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
name="Wind speed",
native_value=lambda value: round(cast(float, value), 2),
icon="mdi:weather-windy",
+ device_class=SensorDeviceClass.WIND_SPEED,
+ native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND,
state_class=SensorStateClass.MEASUREMENT,
),
# SmokeSensor/SmokeSensor
@@ -389,6 +413,8 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [
native_value=lambda value: OVERKIZ_STATE_TO_TRANSLATION.get(
cast(str, value), cast(str, value)
),
+ device_class=SensorDeviceClass.ENUM,
+ options=["dead", "low_battery", "maintenance_required", "no_defect"],
),
# DomesticHotWaterProduction/WaterHeatingSystem
OverkizSensorDescription(
@@ -483,7 +509,7 @@ SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCR
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz sensors from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/siren.py b/homeassistant/components/overkiz/siren.py
index f7246e50ec0..af761611444 100644
--- a/homeassistant/components/overkiz/siren.py
+++ b/homeassistant/components/overkiz/siren.py
@@ -12,7 +12,7 @@ from homeassistant.components.siren import (
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OverkizDataConfigEntry
from .entity import OverkizEntity
@@ -21,7 +21,7 @@ from .entity import OverkizEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz sirens from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json
index 0c564a003d6..363147150dc 100644
--- a/homeassistant/components/overkiz/strings.json
+++ b/homeassistant/components/overkiz/strings.json
@@ -71,14 +71,14 @@
"state_attributes": {
"preset_mode": {
"state": {
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
+ "manual": "[%key:common::state::manual%]",
"comfort-1": "Comfort 1",
"comfort-2": "Comfort 2",
"drying": "Drying",
"external": "External",
"freeze": "Freeze",
"frost_protection": "Frost protection",
- "manual": "Manual",
"night": "Night",
"prog": "Prog"
}
@@ -120,18 +120,20 @@
"battery": {
"state": {
"full": "Full",
- "low": "Low",
- "normal": "Normal",
- "medium": "Medium",
- "verylow": "Very low"
+ "low": "[%key:common::state::low%]",
+ "normal": "[%key:common::state::normal%]",
+ "medium": "[%key:common::state::medium%]",
+ "verylow": "[%key:common::state::very_low%]",
+ "good": "Good",
+ "critical": "Critical"
}
},
"discrete_rssi_level": {
"state": {
"good": "Good",
- "low": "Low",
- "normal": "Normal",
- "verylow": "Very low"
+ "low": "[%key:common::state::low%]",
+ "normal": "[%key:common::state::normal%]",
+ "verylow": "[%key:common::state::very_low%]"
}
},
"priority_lock_originator": {
diff --git a/homeassistant/components/overkiz/switch.py b/homeassistant/components/overkiz/switch.py
index c921dbab776..d14b2792947 100644
--- a/homeassistant/components/overkiz/switch.py
+++ b/homeassistant/components/overkiz/switch.py
@@ -17,7 +17,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OverkizDataConfigEntry
from .entity import OverkizDescriptiveEntity
@@ -110,7 +110,7 @@ SUPPORTED_DEVICES = {
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz switch from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/overkiz/water_heater/__init__.py b/homeassistant/components/overkiz/water_heater/__init__.py
index 1dd1d596a33..2960cefe10c 100644
--- a/homeassistant/components/overkiz/water_heater/__init__.py
+++ b/homeassistant/components/overkiz/water_heater/__init__.py
@@ -6,13 +6,16 @@ from pyoverkiz.enums.ui import UIWidget
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .. import OverkizDataConfigEntry
from ..entity import OverkizEntity
from .atlantic_domestic_hot_water_production_mlb_component import (
AtlanticDomesticHotWaterProductionMBLComponent,
)
+from .atlantic_domestic_hot_water_production_v2_io_component import (
+ AtlanticDomesticHotWaterProductionV2IOComponent,
+)
from .atlantic_pass_apc_dhw import AtlanticPassAPCDHW
from .domestic_hot_water_production import DomesticHotWaterProduction
from .hitachi_dhw import HitachiDHW
@@ -21,7 +24,7 @@ from .hitachi_dhw import HitachiDHW
async def async_setup_entry(
hass: HomeAssistant,
entry: OverkizDataConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Overkiz DHW from a config entry."""
data = entry.runtime_data
@@ -52,4 +55,5 @@ WIDGET_TO_WATER_HEATER_ENTITY = {
CONTROLLABLE_NAME_TO_WATER_HEATER_ENTITY = {
"modbuslink:AtlanticDomesticHotWaterProductionMBLComponent": AtlanticDomesticHotWaterProductionMBLComponent,
+ "io:AtlanticDomesticHotWaterProductionV2_CV4E_IOComponent": AtlanticDomesticHotWaterProductionV2IOComponent,
}
diff --git a/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py
new file mode 100644
index 00000000000..7e7db07f847
--- /dev/null
+++ b/homeassistant/components/overkiz/water_heater/atlantic_domestic_hot_water_production_v2_io_component.py
@@ -0,0 +1,332 @@
+"""Support for AtlanticDomesticHotWaterProductionV2IOComponent."""
+
+from typing import Any, cast
+
+from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
+
+from homeassistant.components.water_heater import (
+ STATE_ECO,
+ STATE_ELECTRIC,
+ STATE_HEAT_PUMP,
+ STATE_PERFORMANCE,
+ WaterHeaterEntity,
+ WaterHeaterEntityFeature,
+)
+from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+
+from ..entity import OverkizEntity
+
+DEFAULT_MIN_TEMP: float = 50.0
+DEFAULT_MAX_TEMP: float = 62.0
+MAX_BOOST_MODE_DURATION: int = 7
+
+DHWP_AWAY_MODES = [
+ OverkizCommandParam.ABSENCE,
+ OverkizCommandParam.AWAY,
+ OverkizCommandParam.FROSTPROTECTION,
+]
+
+
+class AtlanticDomesticHotWaterProductionV2IOComponent(OverkizEntity, WaterHeaterEntity):
+ """Representation of AtlanticDomesticHotWaterProductionV2IOComponent (io)."""
+
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+ _attr_supported_features = (
+ WaterHeaterEntityFeature.TARGET_TEMPERATURE
+ | WaterHeaterEntityFeature.OPERATION_MODE
+ | WaterHeaterEntityFeature.AWAY_MODE
+ | WaterHeaterEntityFeature.ON_OFF
+ )
+ _attr_operation_list = [
+ STATE_ECO,
+ STATE_PERFORMANCE,
+ STATE_HEAT_PUMP,
+ STATE_ELECTRIC,
+ ]
+
+ @property
+ def min_temp(self) -> float:
+ """Return the minimum temperature."""
+
+ min_temp = self.device.states[OverkizState.CORE_MINIMAL_TEMPERATURE_MANUAL_MODE]
+ if min_temp:
+ return cast(float, min_temp.value_as_float)
+ return DEFAULT_MIN_TEMP
+
+ @property
+ def max_temp(self) -> float:
+ """Return the maximum temperature."""
+
+ max_temp = self.device.states[OverkizState.CORE_MAXIMAL_TEMPERATURE_MANUAL_MODE]
+ if max_temp:
+ return cast(float, max_temp.value_as_float)
+ return DEFAULT_MAX_TEMP
+
+ @property
+ def current_temperature(self) -> float:
+ """Return the current temperature."""
+
+ return cast(
+ float,
+ self.executor.select_state(
+ OverkizState.IO_MIDDLE_WATER_TEMPERATURE,
+ ),
+ )
+
+ @property
+ def target_temperature(self) -> float:
+ """Return the temperature corresponding to the PRESET."""
+
+ return cast(
+ float,
+ self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE),
+ )
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new temperature."""
+
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_TARGET_TEMPERATURE, temperature, refresh_afterwards=False
+ )
+ await self.executor.async_execute_command(
+ OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False
+ )
+ await self.coordinator.async_refresh()
+
+ @property
+ def is_state_eco(self) -> bool:
+ """Return true if eco mode is on."""
+
+ return (
+ self.executor.select_state(OverkizState.IO_DHW_MODE)
+ == OverkizCommandParam.MANUAL_ECO_ACTIVE
+ )
+
+ @property
+ def is_state_performance(self) -> bool:
+ """Return true if performance mode is on."""
+
+ return (
+ self.executor.select_state(OverkizState.IO_DHW_MODE)
+ == OverkizCommandParam.AUTO_MODE
+ )
+
+ @property
+ def is_state_heat_pump(self) -> bool:
+ """Return true if heat pump mode is on."""
+
+ return (
+ self.executor.select_state(OverkizState.IO_DHW_MODE)
+ == OverkizCommandParam.MANUAL_ECO_INACTIVE
+ )
+
+ @property
+ def is_away_mode_on(self) -> bool:
+ """Return true if away mode is on."""
+
+ away_mode_duration = cast(
+ str, self.executor.select_state(OverkizState.IO_AWAY_MODE_DURATION)
+ )
+ # away_mode_duration can be either a Literal["always"]
+ if away_mode_duration == OverkizCommandParam.ALWAYS:
+ return True
+
+ # Or an int of 0 to 7 days. But it still is a string.
+ if away_mode_duration.isdecimal() and int(away_mode_duration) > 0:
+ return True
+
+ return False
+
+ @property
+ def current_operation(self) -> str | None:
+ """Return current operation."""
+
+ # The Away Mode leaves the current operation unchanged
+ if self.is_boost_mode_on:
+ return STATE_ELECTRIC
+
+ if self.is_state_eco:
+ return STATE_ECO
+
+ if self.is_state_performance:
+ return STATE_PERFORMANCE
+
+ if self.is_state_heat_pump:
+ return STATE_HEAT_PUMP
+
+ return None
+
+ @property
+ def is_boost_mode_on(self) -> bool:
+ """Return true if boost mode is on."""
+
+ return (
+ cast(
+ int,
+ self.executor.select_state(OverkizState.CORE_BOOST_MODE_DURATION),
+ )
+ > 0
+ )
+
+ async def async_set_operation_mode(self, operation_mode: str) -> None:
+ """Set new operation mode."""
+
+ if operation_mode == STATE_ECO:
+ if self.is_boost_mode_on:
+ await self.async_turn_boost_mode_off(refresh_afterwards=False)
+
+ if self.is_away_mode_on:
+ await self.async_turn_away_mode_off(refresh_afterwards=False)
+
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_DHW_MODE,
+ OverkizCommandParam.MANUAL_ECO_ACTIVE,
+ refresh_afterwards=False,
+ )
+ # ECO changes the target temperature so we have to refresh it
+ await self.executor.async_execute_command(
+ OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False
+ )
+ await self.coordinator.async_refresh()
+
+ elif operation_mode == STATE_PERFORMANCE:
+ if self.is_boost_mode_on:
+ await self.async_turn_boost_mode_off(refresh_afterwards=False)
+ if self.is_away_mode_on:
+ await self.async_turn_away_mode_off(refresh_afterwards=False)
+
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_DHW_MODE,
+ OverkizCommandParam.AUTO_MODE,
+ refresh_afterwards=False,
+ )
+
+ await self.coordinator.async_refresh()
+
+ elif operation_mode == STATE_HEAT_PUMP:
+ refresh_target_temp = False
+ if self.is_state_performance:
+ # Switching from STATE_PERFORMANCE to STATE_HEAT_PUMP
+ # changes the target temperature and requires a target temperature refresh
+ refresh_target_temp = True
+
+ if self.is_boost_mode_on:
+ await self.async_turn_boost_mode_off(refresh_afterwards=False)
+ if self.is_away_mode_on:
+ await self.async_turn_away_mode_off(refresh_afterwards=False)
+
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_DHW_MODE,
+ OverkizCommandParam.MANUAL_ECO_INACTIVE,
+ refresh_afterwards=False,
+ )
+
+ if refresh_target_temp:
+ await self.executor.async_execute_command(
+ OverkizCommand.REFRESH_TARGET_TEMPERATURE,
+ refresh_afterwards=False,
+ )
+
+ await self.coordinator.async_refresh()
+
+ elif operation_mode == STATE_ELECTRIC:
+ if self.is_away_mode_on:
+ await self.async_turn_away_mode_off(refresh_afterwards=False)
+ if not self.is_boost_mode_on:
+ await self.async_turn_boost_mode_on(refresh_afterwards=False)
+ await self.coordinator.async_refresh()
+
+ async def async_turn_away_mode_on(self, refresh_afterwards: bool = True) -> None:
+ """Turn away mode on."""
+
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_CURRENT_OPERATING_MODE,
+ {
+ OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF,
+ OverkizCommandParam.ABSENCE: OverkizCommandParam.ON,
+ },
+ refresh_afterwards=False,
+ )
+ # Toggling the AWAY mode changes away mode duration so we have to refresh it
+ await self.executor.async_execute_command(
+ OverkizCommand.REFRESH_AWAY_MODE_DURATION,
+ refresh_afterwards=False,
+ )
+ if refresh_afterwards:
+ await self.coordinator.async_refresh()
+
+ async def async_turn_away_mode_off(self, refresh_afterwards: bool = True) -> None:
+ """Turn away mode off."""
+
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_CURRENT_OPERATING_MODE,
+ {
+ OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF,
+ OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF,
+ },
+ refresh_afterwards=False,
+ )
+ # Toggling the AWAY mode changes away mode duration so we have to refresh it
+ await self.executor.async_execute_command(
+ OverkizCommand.REFRESH_AWAY_MODE_DURATION,
+ refresh_afterwards=False,
+ )
+ if refresh_afterwards:
+ await self.coordinator.async_refresh()
+
+ async def async_turn_boost_mode_on(self, refresh_afterwards: bool = True) -> None:
+ """Turn boost mode on."""
+
+ refresh_target_temp = False
+ if self.is_state_performance:
+ # Switching from STATE_PERFORMANCE to BOOST requires a target temperature refresh
+ refresh_target_temp = True
+
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_BOOST_MODE_DURATION,
+ MAX_BOOST_MODE_DURATION,
+ refresh_afterwards=False,
+ )
+
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_CURRENT_OPERATING_MODE,
+ {
+ OverkizCommandParam.RELAUNCH: OverkizCommandParam.ON,
+ OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF,
+ },
+ refresh_afterwards=False,
+ )
+
+ await self.executor.async_execute_command(
+ OverkizCommand.REFRESH_BOOST_MODE_DURATION,
+ refresh_afterwards=False,
+ )
+
+ if refresh_target_temp:
+ await self.executor.async_execute_command(
+ OverkizCommand.REFRESH_TARGET_TEMPERATURE, refresh_afterwards=False
+ )
+
+ if refresh_afterwards:
+ await self.coordinator.async_refresh()
+
+ async def async_turn_boost_mode_off(self, refresh_afterwards: bool = True) -> None:
+ """Turn boost mode off."""
+
+ await self.executor.async_execute_command(
+ OverkizCommand.SET_CURRENT_OPERATING_MODE,
+ {
+ OverkizCommandParam.RELAUNCH: OverkizCommandParam.OFF,
+ OverkizCommandParam.ABSENCE: OverkizCommandParam.OFF,
+ },
+ refresh_afterwards=False,
+ )
+ # Toggling the BOOST mode changes boost mode duration so we have to refresh it
+ await self.executor.async_execute_command(
+ OverkizCommand.REFRESH_BOOST_MODE_DURATION,
+ refresh_afterwards=False,
+ )
+
+ if refresh_afterwards:
+ await self.coordinator.async_refresh()
diff --git a/homeassistant/components/overseerr/event.py b/homeassistant/components/overseerr/event.py
index 589a80c5404..1ffb1e71771 100644
--- a/homeassistant/components/overseerr/event.py
+++ b/homeassistant/components/overseerr/event.py
@@ -8,7 +8,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, EVENT_KEY
from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
@@ -44,7 +44,7 @@ EVENTS: tuple[OverseerrEventEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: OverseerrConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Overseerr sensor entities based on a config entry."""
diff --git a/homeassistant/components/overseerr/manifest.json b/homeassistant/components/overseerr/manifest.json
index 6258481adcf..3c4321ebb37 100644
--- a/homeassistant/components/overseerr/manifest.json
+++ b/homeassistant/components/overseerr/manifest.json
@@ -9,5 +9,5 @@
"integration_type": "service",
"iot_class": "local_push",
"quality_scale": "platinum",
- "requirements": ["python-overseerr==0.7.0"]
+ "requirements": ["python-overseerr==0.7.1"]
}
diff --git a/homeassistant/components/overseerr/sensor.py b/homeassistant/components/overseerr/sensor.py
index 2daaa3de0cb..510e6f52c59 100644
--- a/homeassistant/components/overseerr/sensor.py
+++ b/homeassistant/components/overseerr/sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import REQUESTS
from .coordinator import OverseerrConfigEntry, OverseerrCoordinator
@@ -76,7 +76,7 @@ SENSORS: tuple[OverseerrSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: OverseerrConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Overseerr sensor entities based on a config entry."""
diff --git a/homeassistant/components/overseerr/strings.json b/homeassistant/components/overseerr/strings.json
index 14650fd5c25..ce8b9fe9fec 100644
--- a/homeassistant/components/overseerr/strings.json
+++ b/homeassistant/components/overseerr/strings.json
@@ -90,7 +90,7 @@
"services": {
"get_requests": {
"name": "Get requests",
- "description": "Get media requests from Overseerr.",
+ "description": "Retrieves a list of media requests from Overseerr.",
"fields": {
"config_entry_id": {
"name": "Overseerr instance",
@@ -106,7 +106,7 @@
},
"requested_by": {
"name": "Requested by",
- "description": "Filter the requests by the user id that requested them."
+ "description": "Filter the requests by the user ID that requested them."
}
}
}
diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py
index 8cada86da34..1dc12c7f008 100644
--- a/homeassistant/components/ovo_energy/sensor.py
+++ b/homeassistant/components/ovo_energy/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util import dt as dt_util
@@ -111,7 +111,9 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OVO Energy sensor based on a config entry."""
coordinator: DataUpdateCoordinator[OVODailyUsage] = hass.data[DOMAIN][
diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py
index 6a6f0f078b1..7ccbbb69aa1 100644
--- a/homeassistant/components/owntracks/device_tracker.py
+++ b/homeassistant/components/owntracks/device_tracker.py
@@ -16,14 +16,16 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from . import DOMAIN as OT_DOMAIN
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up OwnTracks based off an entry."""
# Restore previously loaded devices
diff --git a/homeassistant/components/p1_monitor/sensor.py b/homeassistant/components/p1_monitor/sensor.py
index 84e331a4099..15a8f510fd7 100644
--- a/homeassistant/components/p1_monitor/sensor.py
+++ b/homeassistant/components/p1_monitor/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -237,7 +237,7 @@ SENSORS_WATERMETER: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: P1MonitorConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up P1 Monitor Sensors based on a config entry."""
entities: list[P1MonitorSensorEntity] = []
diff --git a/homeassistant/components/palazzetti/button.py b/homeassistant/components/palazzetti/button.py
index 32a60e195e9..319a1174542 100644
--- a/homeassistant/components/palazzetti/button.py
+++ b/homeassistant/components/palazzetti/button.py
@@ -7,7 +7,7 @@ from pypalazzetti.exceptions import CommunicationError
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator
@@ -17,7 +17,7 @@ from .entity import PalazzettiEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PalazzettiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Palazzetti button platform."""
diff --git a/homeassistant/components/palazzetti/climate.py b/homeassistant/components/palazzetti/climate.py
index 2c7053073ea..5a4097e083a 100644
--- a/homeassistant/components/palazzetti/climate.py
+++ b/homeassistant/components/palazzetti/climate.py
@@ -13,7 +13,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, FAN_AUTO, FAN_HIGH, FAN_MODES
from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator
@@ -23,7 +23,7 @@ from .entity import PalazzettiEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: PalazzettiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Palazzetti climates based on a config entry."""
async_add_entities([PalazzettiClimateEntity(entry.runtime_data)])
diff --git a/homeassistant/components/palazzetti/number.py b/homeassistant/components/palazzetti/number.py
index bba729c523c..63c1ed16f0c 100644
--- a/homeassistant/components/palazzetti/number.py
+++ b/homeassistant/components/palazzetti/number.py
@@ -8,7 +8,7 @@ from pypalazzetti.fan import FanType
from homeassistant.components.number import NumberDeviceClass, NumberEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import PalazzettiConfigEntry, PalazzettiDataUpdateCoordinator
@@ -18,7 +18,7 @@ from .entity import PalazzettiEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PalazzettiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Palazzetti number platform."""
diff --git a/homeassistant/components/palazzetti/sensor.py b/homeassistant/components/palazzetti/sensor.py
index fdad817da4d..57d5ca861a2 100644
--- a/homeassistant/components/palazzetti/sensor.py
+++ b/homeassistant/components/palazzetti/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfLength, UnitOfMass, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import STATUS_TO_HA
@@ -59,7 +59,7 @@ PROPERTY_SENSOR_DESCRIPTIONS: list[PropertySensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
entry: PalazzettiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Palazzetti sensor entities based on a config entry."""
diff --git a/homeassistant/components/palazzetti/strings.json b/homeassistant/components/palazzetti/strings.json
index 501ee777fe9..59a2ba1ffe9 100644
--- a/homeassistant/components/palazzetti/strings.json
+++ b/homeassistant/components/palazzetti/strings.json
@@ -52,8 +52,8 @@
"fan_mode": {
"state": {
"silent": "Silent",
- "auto": "Auto",
- "high": "High"
+ "auto": "[%key:common::state::auto%]",
+ "high": "[%key:common::state::high%]"
}
}
}
@@ -74,7 +74,7 @@
"status": {
"name": "Status",
"state": {
- "off": "Off",
+ "off": "[%key:common::state::off%]",
"off_timer": "Timer-regulated switch off",
"test_fire": "Ignition test",
"heatup": "Pellet feed",
@@ -83,7 +83,7 @@
"burning": "Operating",
"burning_mod": "Operating - Modulating",
"unknown": "Unknown",
- "cool_fluid": "Stand-by",
+ "cool_fluid": "[%key:common::state::standby%]",
"fire_stop": "Switch off",
"clean_fire": "Burn pot cleaning",
"cooling": "Cooling in progress",
diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py
index 8738b897d29..a78920f33a5 100644
--- a/homeassistant/components/panasonic_viera/media_player.py
+++ b/homeassistant/components/panasonic_viera/media_player.py
@@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_DEVICE_INFO,
@@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Panasonic Viera TV from a config entry."""
diff --git a/homeassistant/components/panasonic_viera/remote.py b/homeassistant/components/panasonic_viera/remote.py
index ad40a97f700..5fa4be9ca2b 100644
--- a/homeassistant/components/panasonic_viera/remote.py
+++ b/homeassistant/components/panasonic_viera/remote.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Remote
from .const import (
@@ -28,7 +28,7 @@ from .const import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Panasonic Viera TV Remote from a config entry."""
diff --git a/homeassistant/components/peblar/binary_sensor.py b/homeassistant/components/peblar/binary_sensor.py
index e8e5095f050..8834a2ba2a0 100644
--- a/homeassistant/components/peblar/binary_sensor.py
+++ b/homeassistant/components/peblar/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PeblarConfigEntry, PeblarData, PeblarDataUpdateCoordinator
from .entity import PeblarEntity
@@ -50,7 +50,7 @@ DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: PeblarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Peblar binary sensor based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/peblar/button.py b/homeassistant/components/peblar/button.py
index 22150c82649..8c60c8d84d3 100644
--- a/homeassistant/components/peblar/button.py
+++ b/homeassistant/components/peblar/button.py
@@ -15,7 +15,7 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PeblarConfigEntry, PeblarUserConfigurationDataUpdateCoordinator
from .entity import PeblarEntity
@@ -52,7 +52,7 @@ DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: PeblarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Peblar buttons based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/peblar/number.py b/homeassistant/components/peblar/number.py
index 0e929a63523..bff1bb26db4 100644
--- a/homeassistant/components/peblar/number.py
+++ b/homeassistant/components/peblar/number.py
@@ -14,7 +14,7 @@ from homeassistant.const import (
UnitOfElectricCurrent,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PeblarConfigEntry, PeblarDataUpdateCoordinator
from .entity import PeblarEntity
@@ -26,7 +26,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: PeblarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Peblar number based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/peblar/select.py b/homeassistant/components/peblar/select.py
index a2a0997a797..17503951ccd 100644
--- a/homeassistant/components/peblar/select.py
+++ b/homeassistant/components/peblar/select.py
@@ -11,7 +11,7 @@ from peblar import Peblar, PeblarUserConfiguration, SmartChargingMode
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PeblarConfigEntry, PeblarUserConfigurationDataUpdateCoordinator
from .entity import PeblarEntity
@@ -49,7 +49,7 @@ DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: PeblarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Peblar select based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/peblar/sensor.py b/homeassistant/components/peblar/sensor.py
index e655253d75c..81476eef9aa 100644
--- a/homeassistant/components/peblar/sensor.py
+++ b/homeassistant/components/peblar/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from .const import (
@@ -231,7 +231,7 @@ DESCRIPTIONS: tuple[PeblarSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PeblarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Peblar sensors based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/peblar/strings.json b/homeassistant/components/peblar/strings.json
index 4a1500e54c5..416f1a2c062 100644
--- a/homeassistant/components/peblar/strings.json
+++ b/homeassistant/components/peblar/strings.json
@@ -107,7 +107,7 @@
"cp_state": {
"name": "State",
"state": {
- "charging": "Charging",
+ "charging": "[%key:common::state::charging%]",
"error": "Error",
"fault": "Fault",
"invalid": "Invalid",
diff --git a/homeassistant/components/peblar/switch.py b/homeassistant/components/peblar/switch.py
index 74a42ddc47d..f2e1ae13ae2 100644
--- a/homeassistant/components/peblar/switch.py
+++ b/homeassistant/components/peblar/switch.py
@@ -11,7 +11,7 @@ from peblar import PeblarEVInterface
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
PeblarConfigEntry,
@@ -71,7 +71,7 @@ DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: PeblarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Peblar switch based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/peblar/update.py b/homeassistant/components/peblar/update.py
index 58c2fbdc899..88966916069 100644
--- a/homeassistant/components/peblar/update.py
+++ b/homeassistant/components/peblar/update.py
@@ -11,7 +11,7 @@ from homeassistant.components.update import (
UpdateEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
PeblarConfigEntry,
@@ -53,7 +53,7 @@ DESCRIPTIONS: tuple[PeblarUpdateEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PeblarConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Peblar update based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/peco/binary_sensor.py b/homeassistant/components/peco/binary_sensor.py
index a55f0fcc731..a4d59a8c9a2 100644
--- a/homeassistant/components/peco/binary_sensor.py
+++ b/homeassistant/components/peco/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -24,7 +24,7 @@ PARALLEL_UPDATES: Final = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensor for PECO."""
if "smart_meter" not in hass.data[DOMAIN][config_entry.entry_id]:
diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py
index d08947eb0ec..eafa36c98e9 100644
--- a/homeassistant/components/peco/sensor.py
+++ b/homeassistant/components/peco/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -76,7 +76,7 @@ SENSOR_LIST: tuple[PECOSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
county: str = config_entry.data[CONF_COUNTY]
diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py
index 181c0f5dc6d..fd90683a9b2 100644
--- a/homeassistant/components/pegel_online/sensor.py
+++ b/homeassistant/components/pegel_online/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PegelOnlineConfigEntry, PegelOnlineDataUpdateCoordinator
from .entity import PegelOnlineEntity
@@ -92,7 +92,7 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PegelOnlineConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the PEGELONLINE sensor."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/permobil/binary_sensor.py b/homeassistant/components/permobil/binary_sensor.py
index 4b768cf5af5..c2d51067e19 100644
--- a/homeassistant/components/permobil/binary_sensor.py
+++ b/homeassistant/components/permobil/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MyPermobilCoordinator
@@ -42,7 +42,7 @@ BINARY_SENSOR_DESCRIPTIONS: tuple[PermobilBinarySensorEntityDescription, ...] =
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create and setup the binary sensor."""
diff --git a/homeassistant/components/permobil/sensor.py b/homeassistant/components/permobil/sensor.py
index 54d3a61c519..5f8cb88290a 100644
--- a/homeassistant/components/permobil/sensor.py
+++ b/homeassistant/components/permobil/sensor.py
@@ -32,7 +32,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfLength, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import BATTERY_ASSUMED_VOLTAGE, DOMAIN, KM, MILES
from .coordinator import MyPermobilCoordinator
@@ -175,7 +175,7 @@ DISTANCE_UNITS: dict[Any, UnitOfLength] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create sensors from a config entry created in the integrations UI."""
diff --git a/homeassistant/components/person/manifest.json b/homeassistant/components/person/manifest.json
index 7f370be6fbe..0c1792e9277 100644
--- a/homeassistant/components/person/manifest.json
+++ b/homeassistant/components/person/manifest.json
@@ -1,7 +1,6 @@
{
"domain": "person",
"name": "Person",
- "after_dependencies": ["device_tracker"],
"codeowners": [],
"dependencies": ["image_upload", "http"],
"documentation": "https://www.home-assistant.io/integrations/person",
diff --git a/homeassistant/components/pglab/__init__.py b/homeassistant/components/pglab/__init__.py
new file mode 100644
index 00000000000..a490f476f83
--- /dev/null
+++ b/homeassistant/components/pglab/__init__.py
@@ -0,0 +1,89 @@
+"""PG LAB Electronics integration."""
+
+from __future__ import annotations
+
+from pypglab.mqtt import (
+ Client as PyPGLabMqttClient,
+ Sub_State as PyPGLabSubState,
+ Subscribe_CallBack as PyPGLabSubscribeCallBack,
+)
+
+from homeassistant.components import mqtt
+from homeassistant.components.mqtt import (
+ ReceiveMessage,
+ async_prepare_subscribe_topics,
+ async_subscribe_topics,
+ async_unsubscribe_topics,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import config_validation as cv
+
+from .const import DOMAIN, LOGGER
+from .discovery import PGLabDiscovery
+
+type PGLabConfigEntry = ConfigEntry[PGLabDiscovery]
+
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: PGLabConfigEntry
+) -> bool:
+ """Set up PG LAB Electronics integration from a config entry."""
+
+ async def mqtt_publish(topic: str, payload: str, qos: int, retain: bool) -> None:
+ """Publish an MQTT message using the Home Assistant MQTT client."""
+ await mqtt.async_publish(hass, topic, payload, qos, retain)
+
+ async def mqtt_subscribe(
+ sub_state: PyPGLabSubState, topic: str, callback_func: PyPGLabSubscribeCallBack
+ ) -> PyPGLabSubState:
+ """Subscribe to MQTT topics using the Home Assistant MQTT client."""
+
+ @callback
+ def mqtt_message_received(msg: ReceiveMessage) -> None:
+ """Handle PGLab mqtt messages."""
+ callback_func(msg.topic, msg.payload)
+
+ topics = {
+ "pglab_subscribe_topic": {
+ "topic": topic,
+ "msg_callback": mqtt_message_received,
+ }
+ }
+
+ sub_state = async_prepare_subscribe_topics(hass, sub_state, topics)
+ await async_subscribe_topics(hass, sub_state)
+ return sub_state
+
+ async def mqtt_unsubscribe(sub_state: PyPGLabSubState) -> None:
+ async_unsubscribe_topics(hass, sub_state)
+
+ if not await mqtt.async_wait_for_mqtt_client(hass):
+ LOGGER.error("MQTT integration not available")
+ raise ConfigEntryNotReady("MQTT integration not available")
+
+ # Create an MQTT client for PGLab used for PGLab python module.
+ pglab_mqtt = PyPGLabMqttClient(mqtt_publish, mqtt_subscribe, mqtt_unsubscribe)
+
+ # Setup PGLab device discovery.
+ config_entry.runtime_data = PGLabDiscovery()
+
+ # Start to discovery PG Lab devices.
+ await config_entry.runtime_data.start(hass, pglab_mqtt, config_entry)
+
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: PGLabConfigEntry
+) -> bool:
+ """Unload a config entry."""
+
+ # Stop PGLab device discovery.
+ pglab_discovery = config_entry.runtime_data
+ await pglab_discovery.stop(hass, config_entry)
+
+ return True
diff --git a/homeassistant/components/pglab/config_flow.py b/homeassistant/components/pglab/config_flow.py
new file mode 100644
index 00000000000..606de757622
--- /dev/null
+++ b/homeassistant/components/pglab/config_flow.py
@@ -0,0 +1,73 @@
+"""Config flow for PG LAB Electronics integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.components import mqtt
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
+
+from .const import DISCOVERY_TOPIC, DOMAIN
+
+
+class PGLabFlowHandler(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow."""
+
+ VERSION = 1
+
+ async def async_step_mqtt(
+ self, discovery_info: MqttServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by MQTT discovery."""
+
+ await self.async_set_unique_id(DOMAIN)
+
+ # Validate the message, abort if it fails.
+ if not discovery_info.topic.endswith("/config"):
+ # Not a PGLab Electronics discovery message.
+ return self.async_abort(reason="invalid_discovery_info")
+ if not discovery_info.payload:
+ # Empty payload, unexpected payload.
+ return self.async_abort(reason="invalid_discovery_info")
+
+ return await self.async_step_confirm_from_mqtt()
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle a flow initialized by the user."""
+ try:
+ if not mqtt.is_connected(self.hass):
+ return self.async_abort(reason="mqtt_not_connected")
+ except KeyError:
+ return self.async_abort(reason="mqtt_not_configured")
+
+ return await self.async_step_confirm_from_user()
+
+ def step_confirm(
+ self, step_id: str, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm the setup."""
+
+ if user_input is not None:
+ return self.async_create_entry(
+ title="PG LAB Electronics",
+ data={
+ "discovery_prefix": DISCOVERY_TOPIC,
+ },
+ )
+
+ return self.async_show_form(step_id=step_id)
+
+ async def async_step_confirm_from_mqtt(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm the setup from MQTT discovered."""
+ return self.step_confirm(step_id="confirm_from_mqtt", user_input=user_input)
+
+ async def async_step_confirm_from_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm the setup from user add integration."""
+ return self.step_confirm(step_id="confirm_from_user", user_input=user_input)
diff --git a/homeassistant/components/pglab/const.py b/homeassistant/components/pglab/const.py
new file mode 100644
index 00000000000..de076ac37f0
--- /dev/null
+++ b/homeassistant/components/pglab/const.py
@@ -0,0 +1,12 @@
+"""Constants used by PG LAB Electronics integration."""
+
+import logging
+
+# The domain of the integration.
+DOMAIN = "pglab"
+
+# The message logger.
+LOGGER = logging.getLogger(__package__)
+
+# The MQTT message used to subscribe to get a new PG LAB device.
+DISCOVERY_TOPIC = "pglab/discovery"
diff --git a/homeassistant/components/pglab/coordinator.py b/homeassistant/components/pglab/coordinator.py
new file mode 100644
index 00000000000..b703f368eb1
--- /dev/null
+++ b/homeassistant/components/pglab/coordinator.py
@@ -0,0 +1,78 @@
+"""Coordinator for PG LAB Electronics."""
+
+from __future__ import annotations
+
+from datetime import datetime, timedelta
+from typing import TYPE_CHECKING, Any
+
+from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE
+from pypglab.device import Device as PyPGLabDevice
+from pypglab.sensor import StatusSensor as PyPGLabSensors
+
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.util.dt import utcnow
+
+from .const import DOMAIN, LOGGER
+
+if TYPE_CHECKING:
+ from . import PGLabConfigEntry
+
+
+class PGLabSensorsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
+ """Class to update Sensor Entities when receiving new data."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: PGLabConfigEntry,
+ pglab_device: PyPGLabDevice,
+ ) -> None:
+ """Initialize."""
+
+ # get a reference of PG Lab device internal sensors state
+ self._sensors: PyPGLabSensors = pglab_device.status_sensor
+
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=config_entry,
+ name=DOMAIN,
+ )
+
+ @callback
+ def _new_sensors_data(self, payload: str) -> None:
+ """Handle new sensor data."""
+
+ # notify all listeners that new sensor values are available
+ self.async_set_updated_data(self._sensors.state)
+
+ async def subscribe_topics(self) -> None:
+ """Subscribe the sensors state to be notifty from MQTT update messages."""
+
+ # subscribe to the pypglab sensors to receive updates from the mqtt broker
+ # when a new sensor values are available
+ await self._sensors.subscribe_topics()
+
+ # set the callback to be called when a new sensor values are available
+ self._sensors.set_on_state_callback(self._new_sensors_data)
+
+ def get_sensor_value(self, sensor_key: str) -> float | datetime | None:
+ """Return the value of a sensor."""
+
+ if self.data:
+ value = self.data[sensor_key]
+
+ if (sensor_key == SENSOR_REBOOT_TIME) and value:
+ # convert the reboot time to a datetime object
+ return utcnow() - timedelta(seconds=value)
+
+ if (sensor_key == SENSOR_TEMPERATURE) and value:
+ # convert the temperature value to a float
+ return float(value)
+
+ if (sensor_key == SENSOR_VOLTAGE) and value:
+ # convert the voltage value to a float
+ return float(value)
+
+ return None
diff --git a/homeassistant/components/pglab/cover.py b/homeassistant/components/pglab/cover.py
new file mode 100644
index 00000000000..8385fd95ffa
--- /dev/null
+++ b/homeassistant/components/pglab/cover.py
@@ -0,0 +1,107 @@
+"""PG LAB Electronics Cover."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pypglab.device import Device as PyPGLabDevice
+from pypglab.shutter import Shutter as PyPGLabShutter
+
+from homeassistant.components.cover import (
+ CoverDeviceClass,
+ CoverEntity,
+ CoverEntityFeature,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .discovery import PGLabDiscovery
+from .entity import PGLabEntity
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up switches for device."""
+
+ @callback
+ def async_discover(
+ pglab_device: PyPGLabDevice, pglab_shutter: PyPGLabShutter
+ ) -> None:
+ """Discover and add a PG LAB Cover."""
+ pglab_discovery = config_entry.runtime_data
+ pglab_cover = PGLabCover(pglab_discovery, pglab_device, pglab_shutter)
+ async_add_entities([pglab_cover])
+
+ # Register the callback to create the cover entity when discovered.
+ pglab_discovery = config_entry.runtime_data
+ await pglab_discovery.register_platform(hass, Platform.COVER, async_discover)
+
+
+class PGLabCover(PGLabEntity, CoverEntity):
+ """A PGLab Cover."""
+
+ _attr_translation_key = "shutter"
+
+ def __init__(
+ self,
+ pglab_discovery: PGLabDiscovery,
+ pglab_device: PyPGLabDevice,
+ pglab_shutter: PyPGLabShutter,
+ ) -> None:
+ """Initialize the Cover class."""
+
+ super().__init__(
+ pglab_discovery,
+ pglab_device,
+ pglab_shutter,
+ )
+
+ self._attr_unique_id = f"{pglab_device.id}_shutter{pglab_shutter.id}"
+ self._attr_translation_placeholders = {"shutter_id": pglab_shutter.id}
+
+ self._shutter = pglab_shutter
+
+ self._attr_device_class = CoverDeviceClass.SHUTTER
+ self._attr_supported_features = (
+ CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP
+ )
+
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Open the cover."""
+ await self._shutter.open()
+
+ async def async_close_cover(self, **kwargs: Any) -> None:
+ """Close cover."""
+ await self._shutter.close()
+
+ async def async_stop_cover(self, **kwargs: Any) -> None:
+ """Stop the cover."""
+ await self._shutter.stop()
+
+ @property
+ def is_closed(self) -> bool | None:
+ """Return if cover is closed."""
+ if not self._shutter.state:
+ return None
+ return self._shutter.state == PyPGLabShutter.STATE_CLOSED
+
+ @property
+ def is_closing(self) -> bool | None:
+ """Return if the cover is closing."""
+ if not self._shutter.state:
+ return None
+ return self._shutter.state == PyPGLabShutter.STATE_CLOSING
+
+ @property
+ def is_opening(self) -> bool | None:
+ """Return if the cover is opening."""
+ if not self._shutter.state:
+ return None
+ return self._shutter.state == PyPGLabShutter.STATE_OPENING
diff --git a/homeassistant/components/pglab/discovery.py b/homeassistant/components/pglab/discovery.py
new file mode 100644
index 00000000000..c83ea4466fa
--- /dev/null
+++ b/homeassistant/components/pglab/discovery.py
@@ -0,0 +1,320 @@
+"""Discovery PG LAB Electronics devices."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import json
+from typing import TYPE_CHECKING, Any
+
+from pypglab.device import Device as PyPGLabDevice
+from pypglab.mqtt import Client as PyPGLabMqttClient
+
+from homeassistant.components.mqtt import (
+ EntitySubscription,
+ ReceiveMessage,
+ async_prepare_subscribe_topics,
+ async_subscribe_topics,
+ async_unsubscribe_topics,
+)
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect,
+ async_dispatcher_send,
+)
+
+from .const import DISCOVERY_TOPIC, DOMAIN, LOGGER
+from .coordinator import PGLabSensorsCoordinator
+
+if TYPE_CHECKING:
+ from . import PGLabConfigEntry
+
+# Supported platforms.
+PLATFORMS = [
+ Platform.COVER,
+ Platform.SENSOR,
+ Platform.SWITCH,
+]
+
+# Used to create a new component entity.
+CREATE_NEW_ENTITY = {
+ Platform.COVER: "pglab_create_new_entity_cover",
+ Platform.SENSOR: "pglab_create_new_entity_sensor",
+ Platform.SWITCH: "pglab_create_new_entity_switch",
+}
+
+
+class PGLabDiscoveryError(Exception):
+ """Raised when a discovery has failed."""
+
+
+def get_device_id_from_discovery_topic(topic: str) -> str | None:
+ """From the discovery topic get the PG LAB Electronics device id."""
+
+ # The discovery topic has the following format "pglab/discovery/[Device ID]/config"
+ split_topic = topic.split("/", 5)
+
+ # Do a sanity check on the string.
+ if len(split_topic) != 4:
+ return None
+
+ if split_topic[3] != "config":
+ return None
+
+ return split_topic[2]
+
+
+class DiscoverDeviceInfo:
+ """Keeps information of the PGLab discovered device."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: PGLabConfigEntry,
+ pglab_device: PyPGLabDevice,
+ ) -> None:
+ """Initialize the device discovery info."""
+
+ # Hash string represents the devices actual configuration,
+ # it depends on the number of available relays and shutters.
+ # When the hash string changes the devices entities must be rebuilt.
+ self._hash = pglab_device.hash
+ self._entities: list[tuple[str, str]] = []
+ self.coordinator = PGLabSensorsCoordinator(hass, config_entry, pglab_device)
+
+ def add_entity(self, platform_domain: str, entity_unique_id: str | None) -> None:
+ """Add an entity."""
+
+ # PGLabEntity always have unique IDs
+ if TYPE_CHECKING:
+ assert entity_unique_id is not None
+ self._entities.append((platform_domain, entity_unique_id))
+
+ @property
+ def hash(self) -> int:
+ """Return the hash for this configuration."""
+ return self._hash
+
+ @property
+ def entities(self) -> list[tuple[str, str]]:
+ """Return array of entities available."""
+ return self._entities
+
+
+async def create_discover_device_info(
+ hass: HomeAssistant, config_entry: PGLabConfigEntry, pglab_device: PyPGLabDevice
+) -> DiscoverDeviceInfo:
+ """Create a new DiscoverDeviceInfo instance."""
+ discovery_info = DiscoverDeviceInfo(hass, config_entry, pglab_device)
+
+ # Subscribe to sensor state changes.
+ await discovery_info.coordinator.subscribe_topics()
+ return discovery_info
+
+
+@dataclass
+class PGLabDiscovery:
+ """Discovery a PGLab device with the following MQTT topic format pglab/discovery/[device]/config."""
+
+ def __init__(self) -> None:
+ """Initialize the discovery class."""
+ self._substate: dict[str, EntitySubscription] = {}
+ self._discovery_topic = DISCOVERY_TOPIC
+ self._mqtt_client = None
+ self._discovered: dict[str, DiscoverDeviceInfo] = {}
+ self._disconnect_platform: list = []
+
+ async def __build_device(
+ self, mqtt: PyPGLabMqttClient, msg: ReceiveMessage
+ ) -> PyPGLabDevice:
+ """Build a PGLab device."""
+
+ # Check if the discovery message is in valid json format.
+ try:
+ payload = json.loads(msg.payload)
+ except ValueError as err:
+ raise PGLabDiscoveryError(
+ f"Can't decode discovery payload: {msg.payload!r}"
+ ) from err
+
+ device_id = "id"
+
+ # Check if the key id is present in the payload. It must always be present.
+ if device_id not in payload:
+ raise PGLabDiscoveryError(
+ "Unexpected discovery payload format, id key not present"
+ )
+
+ # Do a sanity check: the id must match the discovery topic /pglab/discovery/[id]/config
+ topic = msg.topic
+ if not topic.endswith(f"{payload[device_id]}/config"):
+ raise PGLabDiscoveryError("Unexpected discovery topic format")
+
+ # Build and configure the PGLab device.
+ pglab_device = PyPGLabDevice()
+ if not await pglab_device.config(mqtt, payload):
+ raise PGLabDiscoveryError("Error during setup of a new discovered device")
+
+ return pglab_device
+
+ def __clean_discovered_device(self, hass: HomeAssistant, device_id: str) -> None:
+ """Destroy the device and any entities connected to the device."""
+
+ if device_id not in self._discovered:
+ return
+
+ discovery_info = self._discovered[device_id]
+
+ # Destroy all entities connected to the device.
+ entity_registry = er.async_get(hass)
+ for platform, unique_id in discovery_info.entities:
+ if entity_id := entity_registry.async_get_entity_id(
+ platform, DOMAIN, unique_id
+ ):
+ entity_registry.async_remove(entity_id)
+
+ # Destroy the device.
+ device_registry = dr.async_get(hass)
+ if device_entry := device_registry.async_get_device(
+ identifiers={(DOMAIN, device_id)}
+ ):
+ device_registry.async_remove_device(device_entry.id)
+
+ # Clean the discovery info.
+ del self._discovered[device_id]
+
+ async def start(
+ self,
+ hass: HomeAssistant,
+ mqtt: PyPGLabMqttClient,
+ config_entry: PGLabConfigEntry,
+ ) -> None:
+ """Start discovering a PGLab devices."""
+
+ async def discovery_message_received(msg: ReceiveMessage) -> None:
+ """Received a new discovery message."""
+
+ # Create a PGLab device and add entities.
+ try:
+ pglab_device = await self.__build_device(mqtt, msg)
+ except PGLabDiscoveryError as err:
+ LOGGER.warning("Can't create PGLabDiscovery instance(%s) ", str(err))
+
+ # For some reason it's not possible to create the device with the discovery message,
+ # be sure that any previous device with the same topic is now destroyed.
+ device_id = get_device_id_from_discovery_topic(msg.topic)
+
+ # If there is a valid topic device_id clean everything relative to the device.
+ if device_id:
+ self.__clean_discovered_device(hass, device_id)
+
+ return
+
+ # Create a new device.
+ device_registry = dr.async_get(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ configuration_url=f"http://{pglab_device.ip}/",
+ connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)},
+ identifiers={(DOMAIN, pglab_device.id)},
+ manufacturer=pglab_device.manufacturer,
+ model=pglab_device.type,
+ name=pglab_device.name,
+ sw_version=pglab_device.firmware_version,
+ hw_version=pglab_device.hardware_version,
+ )
+
+ # Do some checking if previous entities must be updated.
+ if pglab_device.id in self._discovered:
+ # The device is already been discovered,
+ # get the old discovery info data.
+ discovery_info = self._discovered[pglab_device.id]
+
+ if discovery_info.hash == pglab_device.hash:
+ # Best case, there is nothing to do.
+ # The device is still in the same configuration. Same name, same shutters, same relay etc.
+ return
+
+ LOGGER.warning(
+ "Changed internal configuration of device(%s). Rebuilding all entities",
+ pglab_device.id,
+ )
+
+ # Something has changed, all previous entities must be destroyed and re-created.
+ self.__clean_discovered_device(hass, pglab_device.id)
+
+ # Add a new device.
+ discovery_info = await create_discover_device_info(
+ hass, config_entry, pglab_device
+ )
+ self._discovered[pglab_device.id] = discovery_info
+
+ # Create all new cover entities.
+ for s in pglab_device.shutters:
+ # the HA entity is not yet created, send a message to create it
+ async_dispatcher_send(
+ hass, CREATE_NEW_ENTITY[Platform.COVER], pglab_device, s
+ )
+
+ # Create all new relay entities.
+ for r in pglab_device.relays:
+ # The HA entity is not yet created, send a message to create it.
+ async_dispatcher_send(
+ hass, CREATE_NEW_ENTITY[Platform.SWITCH], pglab_device, r
+ )
+
+ # Create all new sensor entities.
+ async_dispatcher_send(
+ hass,
+ CREATE_NEW_ENTITY[Platform.SENSOR],
+ pglab_device,
+ discovery_info.coordinator,
+ )
+
+ topics = {
+ "discovery_topic": {
+ "topic": f"{self._discovery_topic}/#",
+ "msg_callback": discovery_message_received,
+ }
+ }
+
+ # Forward setup all HA supported platforms.
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
+
+ self._mqtt_client = mqtt
+ self._substate = async_prepare_subscribe_topics(hass, self._substate, topics)
+ await async_subscribe_topics(hass, self._substate)
+
+ async def register_platform(
+ self, hass: HomeAssistant, platform: Platform, target: Callable[..., Any]
+ ):
+ """Register a callback to create entity of a specific HA platform."""
+ disconnect_callback = async_dispatcher_connect(
+ hass, CREATE_NEW_ENTITY[platform], target
+ )
+ self._disconnect_platform.append(disconnect_callback)
+
+ async def stop(self, hass: HomeAssistant, config_entry: PGLabConfigEntry) -> None:
+ """Stop to discovery PG LAB devices."""
+ await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
+
+ # Disconnect all registered platforms.
+ for disconnect_callback in self._disconnect_platform:
+ disconnect_callback()
+
+ async_unsubscribe_topics(hass, self._substate)
+
+ async def add_entity(
+ self, platform_domain: str, entity_unique_id: str | None, device_id: str
+ ):
+ """Save a new PG LAB device entity."""
+
+ # Be sure that the device is been discovered.
+ if device_id not in self._discovered:
+ raise PGLabDiscoveryError("Unknown device, device_id not discovered")
+
+ discovery_info = self._discovered[device_id]
+ discovery_info.add_entity(platform_domain, entity_unique_id)
diff --git a/homeassistant/components/pglab/entity.py b/homeassistant/components/pglab/entity.py
new file mode 100644
index 00000000000..c0a02f4f835
--- /dev/null
+++ b/homeassistant/components/pglab/entity.py
@@ -0,0 +1,112 @@
+"""Entity for PG LAB Electronics."""
+
+from __future__ import annotations
+
+from pypglab.device import Device as PyPGLabDevice
+from pypglab.entity import Entity as PyPGLabEntity
+
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import PGLabSensorsCoordinator
+from .discovery import PGLabDiscovery
+
+
+class PGLabBaseEntity(Entity):
+ """Base class of a PGLab entity in Home Assistant."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ pglab_discovery: PGLabDiscovery,
+ pglab_device: PyPGLabDevice,
+ ) -> None:
+ """Initialize the class."""
+
+ self._device_id = pglab_device.id
+ self._discovery = pglab_discovery
+
+ # Information about the device that is partially visible in the UI.
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, pglab_device.id)},
+ name=pglab_device.name,
+ sw_version=pglab_device.firmware_version,
+ hw_version=pglab_device.hardware_version,
+ model=pglab_device.type,
+ manufacturer=pglab_device.manufacturer,
+ configuration_url=f"http://{pglab_device.ip}/",
+ connections={(CONNECTION_NETWORK_MAC, pglab_device.mac)},
+ )
+
+ async def async_added_to_hass(self) -> None:
+ """Update the device discovery info."""
+
+ # Inform PGLab discovery instance that a new entity is available.
+ # This is important to know in case the device needs to be reconfigured
+ # and the entity can be potentially destroyed.
+ await self._discovery.add_entity(
+ self.platform.domain,
+ self.unique_id,
+ self._device_id,
+ )
+
+ # propagate the async_added_to_hass to the super class
+ await super().async_added_to_hass()
+
+
+class PGLabEntity(PGLabBaseEntity):
+ """Representation of a PGLab entity in Home Assistant."""
+
+ def __init__(
+ self,
+ pglab_discovery: PGLabDiscovery,
+ pglab_device: PyPGLabDevice,
+ pglab_entity: PyPGLabEntity,
+ ) -> None:
+ """Initialize the class."""
+
+ super().__init__(pglab_discovery, pglab_device)
+
+ self._id = pglab_entity.id
+ self._entity: PyPGLabEntity = pglab_entity
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe pypglab entity to be updated from mqtt when pypglab entity internal state change."""
+
+ # set the callback to be called when pypglab entity state is changed
+ self._entity.set_on_state_callback(self.state_updated)
+
+ # subscribe to the pypglab entity to receive updates from the mqtt broker
+ await self._entity.subscribe_topics()
+ await super().async_added_to_hass()
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Unsubscribe when removed."""
+
+ await super().async_will_remove_from_hass()
+ await self._entity.unsubscribe_topics()
+ self._entity.set_on_state_callback(None)
+
+ @callback
+ def state_updated(self, payload: str) -> None:
+ """Handle state updates."""
+ self.async_write_ha_state()
+
+
+class PGLabSensorEntity(PGLabBaseEntity, CoordinatorEntity[PGLabSensorsCoordinator]):
+ """Representation of a PGLab sensor entity in Home Assistant."""
+
+ def __init__(
+ self,
+ pglab_discovery: PGLabDiscovery,
+ pglab_device: PyPGLabDevice,
+ pglab_coordinator: PGLabSensorsCoordinator,
+ ) -> None:
+ """Initialize the class."""
+
+ PGLabBaseEntity.__init__(self, pglab_discovery, pglab_device)
+ CoordinatorEntity.__init__(self, pglab_coordinator)
diff --git a/homeassistant/components/pglab/manifest.json b/homeassistant/components/pglab/manifest.json
new file mode 100644
index 00000000000..c8dca6c6229
--- /dev/null
+++ b/homeassistant/components/pglab/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "pglab",
+ "name": "PG LAB Electronics",
+ "codeowners": ["@pglab-electronics"],
+ "config_flow": true,
+ "dependencies": ["mqtt"],
+ "documentation": "https://www.home-assistant.io/integrations/pglab",
+ "iot_class": "local_push",
+ "loggers": ["pglab"],
+ "mqtt": ["pglab/discovery/#"],
+ "quality_scale": "bronze",
+ "requirements": ["pypglab==0.0.5"],
+ "single_config_entry": true
+}
diff --git a/homeassistant/components/pglab/quality_scale.yaml b/homeassistant/components/pglab/quality_scale.yaml
new file mode 100644
index 00000000000..dda637e5833
--- /dev/null
+++ b/homeassistant/components/pglab/quality_scale.yaml
@@ -0,0 +1,80 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: The integration does not provide any additional actions.
+ appropriate-polling:
+ status: exempt
+ comment: The integration does not poll.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: The integration does not provide any additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure:
+ status: exempt
+ comment: The integration relies solely on auto-discovery.
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: The integration does not provide any additional actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: No options flow.
+ docs-installation-parameters:
+ status: exempt
+ comment: There are no parameters.
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: done
+ reauthentication-flow:
+ status: exempt
+ comment: The integration does not require authentication.
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: done
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow:
+ status: exempt
+ comment: The integration has no settings.
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession:
+ status: exempt
+ comment: The integration does not make HTTP requests.
+ strict-typing: todo
diff --git a/homeassistant/components/pglab/sensor.py b/homeassistant/components/pglab/sensor.py
new file mode 100644
index 00000000000..ce19ec3a21a
--- /dev/null
+++ b/homeassistant/components/pglab/sensor.py
@@ -0,0 +1,109 @@
+"""Sensor for PG LAB Electronics."""
+
+from __future__ import annotations
+
+from pypglab.const import SENSOR_REBOOT_TIME, SENSOR_TEMPERATURE, SENSOR_VOLTAGE
+from pypglab.device import Device as PyPGLabDevice
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import Platform, UnitOfElectricPotential, UnitOfTemperature
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import PGLabConfigEntry
+from .coordinator import PGLabSensorsCoordinator
+from .discovery import PGLabDiscovery
+from .entity import PGLabSensorEntity
+
+PARALLEL_UPDATES = 0
+
+SENSOR_INFO: list[SensorEntityDescription] = [
+ SensorEntityDescription(
+ key=SENSOR_TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key=SENSOR_VOLTAGE,
+ translation_key="mpu_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key=SENSOR_REBOOT_TIME,
+ translation_key="runtime",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ icon="mdi:progress-clock",
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: PGLabConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up sensor for device."""
+
+ @callback
+ def async_discover(
+ pglab_device: PyPGLabDevice,
+ pglab_coordinator: PGLabSensorsCoordinator,
+ ) -> None:
+ """Discover and add a PG LAB Sensor."""
+ pglab_discovery = config_entry.runtime_data
+
+ sensors: list[PGLabSensor] = [
+ PGLabSensor(
+ description,
+ pglab_discovery,
+ pglab_device,
+ pglab_coordinator,
+ )
+ for description in SENSOR_INFO
+ ]
+
+ async_add_entities(sensors)
+
+ # Register the callback to create the sensor entity when discovered.
+ pglab_discovery = config_entry.runtime_data
+ await pglab_discovery.register_platform(hass, Platform.SENSOR, async_discover)
+
+
+class PGLabSensor(PGLabSensorEntity, SensorEntity):
+ """A PGLab sensor."""
+
+ def __init__(
+ self,
+ description: SensorEntityDescription,
+ pglab_discovery: PGLabDiscovery,
+ pglab_device: PyPGLabDevice,
+ pglab_coordinator: PGLabSensorsCoordinator,
+ ) -> None:
+ """Initialize the Sensor class."""
+
+ super().__init__(pglab_discovery, pglab_device, pglab_coordinator)
+
+ self._attr_unique_id = f"{pglab_device.id}_{description.key}"
+ self.entity_description = description
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Update attributes when the coordinator updates."""
+
+ self._attr_native_value = self.coordinator.get_sensor_value(
+ self.entity_description.key
+ )
+ super()._handle_coordinator_update()
+
+ @property
+ def available(self) -> bool:
+ """Return PG LAB sensor availability."""
+ return super().available and self.native_value is not None
diff --git a/homeassistant/components/pglab/strings.json b/homeassistant/components/pglab/strings.json
new file mode 100644
index 00000000000..c6f80d12f09
--- /dev/null
+++ b/homeassistant/components/pglab/strings.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "step": {
+ "confirm_from_user": {
+ "description": "In order to be found PG LAB Electronics devices need to be connected to the same broker as the Home Assistant MQTT integration client. Do you want to continue?"
+ },
+ "confirm_from_mqtt": {
+ "description": "Do you want to set up PG LAB Electronics?"
+ }
+ },
+ "abort": {
+ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
+ "mqtt_not_connected": "Home Assistant MQTT integration not connected to MQTT broker.",
+ "mqtt_not_configured": "Home Assistant MQTT integration not configured."
+ }
+ },
+ "entity": {
+ "cover": {
+ "shutter": {
+ "name": "Shutter {shutter_id}"
+ }
+ },
+ "switch": {
+ "relay": {
+ "name": "Relay {relay_id}"
+ }
+ },
+ "sensor": {
+ "temperature": {
+ "name": "Temperature"
+ },
+ "runtime": {
+ "name": "Run time"
+ },
+ "mpu_voltage": {
+ "name": "MPU voltage"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/pglab/switch.py b/homeassistant/components/pglab/switch.py
new file mode 100644
index 00000000000..76b177e84c4
--- /dev/null
+++ b/homeassistant/components/pglab/switch.py
@@ -0,0 +1,76 @@
+"""Switch for PG LAB Electronics."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pypglab.device import Device as PyPGLabDevice
+from pypglab.relay import Relay as PyPGLabRelay
+
+from homeassistant.components.switch import SwitchEntity
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import PGLabConfigEntry
+from .discovery import PGLabDiscovery
+from .entity import PGLabEntity
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: PGLabConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up switches for device."""
+
+ @callback
+ def async_discover(pglab_device: PyPGLabDevice, pglab_relay: PyPGLabRelay) -> None:
+ """Discover and add a PGLab Relay."""
+ pglab_discovery = config_entry.runtime_data
+ pglab_switch = PGLabSwitch(pglab_discovery, pglab_device, pglab_relay)
+ async_add_entities([pglab_switch])
+
+ # Register the callback to create the switch entity when discovered.
+ pglab_discovery = config_entry.runtime_data
+ await pglab_discovery.register_platform(hass, Platform.SWITCH, async_discover)
+
+
+class PGLabSwitch(PGLabEntity, SwitchEntity):
+ """A PGLab switch."""
+
+ _attr_translation_key = "relay"
+
+ def __init__(
+ self,
+ pglab_discovery: PGLabDiscovery,
+ pglab_device: PyPGLabDevice,
+ pglab_relay: PyPGLabRelay,
+ ) -> None:
+ """Initialize the Switch class."""
+
+ super().__init__(
+ pglab_discovery,
+ pglab_device,
+ pglab_relay,
+ )
+
+ self._attr_unique_id = f"{pglab_device.id}_relay{pglab_relay.id}"
+ self._attr_translation_placeholders = {"relay_id": pglab_relay.id}
+
+ self._relay = pglab_relay
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the device on."""
+ await self._relay.turn_on()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the device off."""
+ await self._relay.turn_off()
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if device is on."""
+ return self._relay.state
diff --git a/homeassistant/components/philips_js/binary_sensor.py b/homeassistant/components/philips_js/binary_sensor.py
index eef91513efe..3667d37dc48 100644
--- a/homeassistant/components/philips_js/binary_sensor.py
+++ b/homeassistant/components/philips_js/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator
from .entity import PhilipsJsEntity
@@ -41,7 +41,7 @@ DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PhilipsTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the configuration entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/philips_js/light.py b/homeassistant/components/philips_js/light.py
index 5c4f629aea4..87e3323a30c 100644
--- a/homeassistant/components/philips_js/light.py
+++ b/homeassistant/components/philips_js/light.py
@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
-from typing import Any
+from typing import Any, cast
from haphilipsjs import PhilipsTV
from haphilipsjs.typing import AmbilightCurrentConfiguration
@@ -18,7 +18,7 @@ from homeassistant.components.light import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import color_hsv_to_RGB, color_RGB_to_hsv
from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator
@@ -34,7 +34,7 @@ EFFECT_EXPERT_STYLES = {"FOLLOW_AUDIO", "FOLLOW_COLOR", "Lounge light"}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PhilipsTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the configuration entry."""
coordinator = config_entry.runtime_data
@@ -328,7 +328,7 @@ class PhilipsTVLightEntity(PhilipsJsEntity, LightEntity):
"""Turn the bulb on."""
brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness)
hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color)
- attr_effect = kwargs.get(ATTR_EFFECT, self.effect)
+ attr_effect = cast(str, kwargs.get(ATTR_EFFECT, self.effect))
if not self._tv.on:
raise HomeAssistantError("TV is not available")
diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py
index a1ed3e4c168..a433a63f31f 100644
--- a/homeassistant/components/philips_js/media_player.py
+++ b/homeassistant/components/philips_js/media_player.py
@@ -18,7 +18,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.trigger import PluggableAction
from . import LOGGER as _LOGGER
@@ -49,7 +49,7 @@ def _inverted(data):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PhilipsTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the configuration entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/philips_js/remote.py b/homeassistant/components/philips_js/remote.py
index a573a2946fe..b026b33a857 100644
--- a/homeassistant/components/philips_js/remote.py
+++ b/homeassistant/components/philips_js/remote.py
@@ -13,7 +13,7 @@ from homeassistant.components.remote import (
RemoteEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.trigger import PluggableAction
from . import LOGGER
@@ -25,7 +25,7 @@ from .helpers import async_get_turn_on_trigger
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PhilipsTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the configuration entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/philips_js/switch.py b/homeassistant/components/philips_js/switch.py
index fd7add5122d..45963432665 100644
--- a/homeassistant/components/philips_js/switch.py
+++ b/homeassistant/components/philips_js/switch.py
@@ -6,7 +6,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PhilipsTVConfigEntry, PhilipsTVDataUpdateCoordinator
from .entity import PhilipsJsEntity
@@ -18,7 +18,7 @@ HUE_POWER_ON = "On"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PhilipsTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the configuration entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/pi_hole/binary_sensor.py b/homeassistant/components/pi_hole/binary_sensor.py
index 5e3ce560ab4..1d12307b6e5 100644
--- a/homeassistant/components/pi_hole/binary_sensor.py
+++ b/homeassistant/components/pi_hole/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import PiHoleConfigEntry
@@ -41,7 +41,7 @@ BINARY_SENSOR_TYPES: tuple[PiHoleBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PiHoleConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Pi-hole binary sensor."""
name = entry.data[CONF_NAME]
diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py
index 4cf5133e700..54a9cb23d02 100644
--- a/homeassistant/components/pi_hole/sensor.py
+++ b/homeassistant/components/pi_hole/sensor.py
@@ -7,7 +7,7 @@ from hole import Hole
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.const import CONF_NAME, PERCENTAGE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -47,7 +47,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PiHoleConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Pi-hole sensor."""
name = entry.data[CONF_NAME]
diff --git a/homeassistant/components/pi_hole/switch.py b/homeassistant/components/pi_hole/switch.py
index 805ba479a9e..84ffe7e51a4 100644
--- a/homeassistant/components/pi_hole/switch.py
+++ b/homeassistant/components/pi_hole/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PiHoleConfigEntry
from .const import SERVICE_DISABLE, SERVICE_DISABLE_ATTR_DURATION
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: PiHoleConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Pi-hole switch."""
name = entry.data[CONF_NAME]
diff --git a/homeassistant/components/pi_hole/update.py b/homeassistant/components/pi_hole/update.py
index 510f5d1dc19..56e92b47289 100644
--- a/homeassistant/components/pi_hole/update.py
+++ b/homeassistant/components/pi_hole/update.py
@@ -10,7 +10,7 @@ from hole import Hole
from homeassistant.components.update import UpdateEntity, UpdateEntityDescription
from homeassistant.const import CONF_NAME, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from . import PiHoleConfigEntry
@@ -65,7 +65,7 @@ UPDATE_ENTITY_TYPES: tuple[PiHoleUpdateEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PiHoleConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Pi-hole update entities."""
name = entry.data[CONF_NAME]
diff --git a/homeassistant/components/picnic/__init__.py b/homeassistant/components/picnic/__init__.py
index d2f023af79f..8de407133cd 100644
--- a/homeassistant/components/picnic/__init__.py
+++ b/homeassistant/components/picnic/__init__.py
@@ -1,6 +1,6 @@
"""The Picnic integration."""
-from python_picnic_api import PicnicAPI
+from python_picnic_api2 import PicnicAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY_CODE, Platform
diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py
index 4c8281f21de..a60086173a8 100644
--- a/homeassistant/components/picnic/config_flow.py
+++ b/homeassistant/components/picnic/config_flow.py
@@ -6,8 +6,8 @@ from collections.abc import Mapping
import logging
from typing import Any
-from python_picnic_api import PicnicAPI
-from python_picnic_api.session import PicnicAuthError
+from python_picnic_api2 import PicnicAPI
+from python_picnic_api2.session import PicnicAuthError
import requests
import voluptuous as vol
diff --git a/homeassistant/components/picnic/coordinator.py b/homeassistant/components/picnic/coordinator.py
index de686cad37d..9b23157dbf3 100644
--- a/homeassistant/components/picnic/coordinator.py
+++ b/homeassistant/components/picnic/coordinator.py
@@ -6,8 +6,8 @@ import copy
from datetime import timedelta
import logging
-from python_picnic_api import PicnicAPI
-from python_picnic_api.session import PicnicAuthError
+from python_picnic_api2 import PicnicAPI
+from python_picnic_api2.session import PicnicAuthError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
diff --git a/homeassistant/components/picnic/manifest.json b/homeassistant/components/picnic/manifest.json
index 947dd0241d2..251964c15d0 100644
--- a/homeassistant/components/picnic/manifest.json
+++ b/homeassistant/components/picnic/manifest.json
@@ -1,10 +1,10 @@
{
"domain": "picnic",
"name": "Picnic",
- "codeowners": ["@corneyl"],
+ "codeowners": ["@corneyl", "@codesalatdev"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/picnic",
"iot_class": "cloud_polling",
- "loggers": ["python_picnic_api"],
- "requirements": ["python-picnic-api==1.1.0"]
+ "loggers": ["python_picnic_api2"],
+ "requirements": ["python-picnic-api2==1.2.4"]
}
diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py
index 866bd6b56c1..dcfd9086491 100644
--- a/homeassistant/components/picnic/sensor.py
+++ b/homeassistant/components/picnic/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CURRENCY_EURO
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -203,7 +203,7 @@ SENSOR_TYPES: tuple[PicnicSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Picnic sensor entries."""
picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR]
diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py
index bbc775891b7..76d7b8a6c44 100644
--- a/homeassistant/components/picnic/services.py
+++ b/homeassistant/components/picnic/services.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import cast
-from python_picnic_api import PicnicAPI
+from python_picnic_api2 import PicnicAPI
import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall
diff --git a/homeassistant/components/picnic/todo.py b/homeassistant/components/picnic/todo.py
index 7fa2bbccd3e..383c236de3c 100644
--- a/homeassistant/components/picnic/todo.py
+++ b/homeassistant/components/picnic/todo.py
@@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_COORDINATOR, DOMAIN
@@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Picnic shopping cart todo platform config entry."""
picnic_coordinator = hass.data[DOMAIN][config_entry.entry_id][CONF_COORDINATOR]
diff --git a/homeassistant/components/pilight/entity.py b/homeassistant/components/pilight/entity.py
index fbb924d7f8f..fbfa5cfb5e1 100644
--- a/homeassistant/components/pilight/entity.py
+++ b/homeassistant/components/pilight/entity.py
@@ -86,7 +86,7 @@ class PilightBaseDevice(RestoreEntity):
self._brightness = 255
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Call when entity about to be added to hass."""
await super().async_added_to_hass()
if state := await self.async_get_last_state():
@@ -99,7 +99,7 @@ class PilightBaseDevice(RestoreEntity):
return self._name
@property
- def assumed_state(self):
+ def assumed_state(self) -> bool:
"""Return True if unable to access real state of the entity."""
return True
diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py
index 060d2532309..35bf2707694 100644
--- a/homeassistant/components/ping/binary_sensor.py
+++ b/homeassistant/components/ping/binary_sensor.py
@@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_IMPORTED_BY
from .coordinator import PingConfigEntry, PingUpdateCoordinator
@@ -15,7 +15,9 @@ from .entity import PingEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: PingConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Ping config entry."""
async_add_entities([PingBinarySensor(entry, entry.runtime_data)])
diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py
index 43969aaac03..9d093da262d 100644
--- a/homeassistant/components/ping/device_tracker.py
+++ b/homeassistant/components/ping/device_tracker.py
@@ -10,7 +10,7 @@ from homeassistant.components.device_tracker import (
ScannerEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -19,7 +19,9 @@ from .coordinator import PingConfigEntry, PingUpdateCoordinator
async def async_setup_entry(
- hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: PingConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Ping config entry."""
async_add_entities([PingDeviceTracker(entry, entry.runtime_data)])
diff --git a/homeassistant/components/ping/sensor.py b/homeassistant/components/ping/sensor.py
index afd6f53db7c..82d88064e02 100644
--- a/homeassistant/components/ping/sensor.py
+++ b/homeassistant/components/ping/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PingConfigEntry, PingResult, PingUpdateCoordinator
from .entity import PingEntity
@@ -75,7 +75,9 @@ SENSORS: tuple[PingSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: PingConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: PingConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ping sensors from config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/ping/strings.json b/homeassistant/components/ping/strings.json
index ef9f74b4207..c301a1b277d 100644
--- a/homeassistant/components/ping/strings.json
+++ b/homeassistant/components/ping/strings.json
@@ -2,16 +2,16 @@
"entity": {
"sensor": {
"round_trip_time_avg": {
- "name": "Round Trip Time Average"
+ "name": "Round-trip time average"
},
"round_trip_time_max": {
- "name": "Round Trip Time Maximum"
+ "name": "Round-trip time maximum"
},
"round_trip_time_mdev": {
- "name": "Round Trip Time Mean Deviation"
+ "name": "Round-trip time mean deviation"
},
"round_trip_time_min": {
- "name": "Round Trip Time Minimum"
+ "name": "Round-trip time minimum"
}
}
},
diff --git a/homeassistant/components/pitsos/__init__.py b/homeassistant/components/pitsos/__init__.py
new file mode 100644
index 00000000000..e49539d8ed2
--- /dev/null
+++ b/homeassistant/components/pitsos/__init__.py
@@ -0,0 +1 @@
+"""Pitsos virtual integration."""
diff --git a/homeassistant/components/pitsos/manifest.json b/homeassistant/components/pitsos/manifest.json
new file mode 100644
index 00000000000..55f5ac7b2fc
--- /dev/null
+++ b/homeassistant/components/pitsos/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "pitsos",
+ "name": "Pitsos",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+}
diff --git a/homeassistant/components/plaato/binary_sensor.py b/homeassistant/components/plaato/binary_sensor.py
index 42019bbec9b..b71673aa1fd 100644
--- a/homeassistant/components/plaato/binary_sensor.py
+++ b/homeassistant/components/plaato/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_USE_WEBHOOK, COORDINATOR, DOMAIN
from .entity import PlaatoEntity
@@ -19,7 +19,7 @@ from .entity import PlaatoEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Plaato from a config entry."""
diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py
index 7ab8367bd1d..9cc63a38a64 100644
--- a/homeassistant/components/plaato/entity.py
+++ b/homeassistant/components/plaato/entity.py
@@ -73,13 +73,13 @@ class PlaatoEntity(entity.Entity):
return None
@property
- def available(self):
+ def available(self) -> bool:
"""Return if sensor is available."""
if self._coordinator is not None:
return self._coordinator.last_update_success
return True
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
if self._coordinator is not None:
self.async_on_remove(
diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py
index b11bac40144..7a98c8a1ced 100644
--- a/homeassistant/components/plaato/sensor.py
+++ b/homeassistant/components/plaato/sensor.py
@@ -12,7 +12,10 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import ATTR_TEMP, SENSOR_UPDATE
@@ -38,7 +41,9 @@ async def async_setup_platform(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Plaato from a config entry."""
entry_data = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/plex/button.py b/homeassistant/components/plex/button.py
index 8bb34be38ce..5ed34eac6b2 100644
--- a/homeassistant/components/plex/button.py
+++ b/homeassistant/components/plex/button.py
@@ -8,7 +8,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PlexServer
from .const import CONF_SERVER_IDENTIFIER, DOMAIN, PLEX_UPDATE_PLATFORMS_SIGNAL
@@ -18,7 +18,7 @@ from .helpers import get_plex_server
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Plex button from config entry."""
server_id: str = config_entry.data[CONF_SERVER_IDENTIFIER]
diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py
index 3c9f35b20a4..48459a81860 100644
--- a/homeassistant/components/plex/config_flow.py
+++ b/homeassistant/components/plex/config_flow.py
@@ -14,7 +14,6 @@ from plexauth import PlexAuth
import requests.exceptions
import voluptuous as vol
-from homeassistant.components import http
from homeassistant.components.http import KEY_HASS, HomeAssistantView
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.config_entries import (
@@ -36,7 +35,7 @@ from homeassistant.const import (
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers import config_validation as cv, discovery_flow
+from homeassistant.helpers import config_validation as cv, discovery_flow, http
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py
index 1dd79ad27a5..4a1654959f6 100644
--- a/homeassistant/components/plex/media_player.py
+++ b/homeassistant/components/plex/media_player.py
@@ -27,7 +27,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.network import is_internal_request
from .const import (
@@ -68,7 +68,7 @@ def needs_session[_PlexMediaPlayerT: PlexMediaPlayer, **_P, _R](
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Plex media_player from a config entry."""
server_id = config_entry.data[CONF_SERVER_IDENTIFIER]
diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py
index eb27f465a7e..66e513dd83a 100644
--- a/homeassistant/components/plex/sensor.py
+++ b/homeassistant/components/plex/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_SERVER_IDENTIFIER,
@@ -52,7 +52,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Plex sensor from a config entry."""
server_id = config_entry.data[CONF_SERVER_IDENTIFIER]
diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json
index 4f5ca3f2bc4..6243e2caa93 100644
--- a/homeassistant/components/plex/strings.json
+++ b/homeassistant/components/plex/strings.json
@@ -11,7 +11,7 @@
}
},
"manual_setup": {
- "title": "Manual Plex Configuration",
+ "title": "Manual Plex configuration",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
@@ -29,8 +29,8 @@
}
},
"error": {
- "faulty_credentials": "Authorization failed, verify Token",
- "host_or_token": "Must provide at least one of Host or Token",
+ "faulty_credentials": "Authorization failed, verify token",
+ "host_or_token": "Must provide at least one of host or token",
"no_servers": "No servers linked to Plex account",
"not_found": "Plex server not found",
"ssl_error": "SSL certificate issue"
@@ -47,12 +47,12 @@
"options": {
"step": {
"plex_mp_settings": {
- "description": "Options for Plex Media Players",
+ "description": "Options for Plex media players",
"data": {
"use_episode_art": "Use episode art",
"ignore_new_shared_users": "Ignore new managed/shared users",
"monitored_users": "Monitored users",
- "ignore_plex_web_clients": "Ignore Plex Web clients"
+ "ignore_plex_web_clients": "Ignore Plex web clients"
}
}
}
diff --git a/homeassistant/components/plex/update.py b/homeassistant/components/plex/update.py
index 7acf4551f33..9b7645cd078 100644
--- a/homeassistant/components/plex/update.py
+++ b/homeassistant/components/plex/update.py
@@ -11,7 +11,7 @@ from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_SERVER_IDENTIFIER
from .helpers import get_plex_server
@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Plex update entities from a config entry."""
server_id = config_entry.data[CONF_SERVER_IDENTIFIER]
diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py
index e8e658da5bb..f2c2fd6ed68 100644
--- a/homeassistant/components/plugwise/binary_sensor.py
+++ b/homeassistant/components/plugwise/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
@@ -85,7 +85,7 @@ BINARY_SENSORS: tuple[PlugwiseBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PlugwiseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smile binary_sensors from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/plugwise/button.py b/homeassistant/components/plugwise/button.py
index aa541378a36..c0896b602f0 100644
--- a/homeassistant/components/plugwise/button.py
+++ b/homeassistant/components/plugwise/button.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import REBOOT
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
@@ -18,7 +18,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: PlugwiseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Plugwise buttons from a ConfigEntry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py
index a7e17f6b688..c7fac07f1cb 100644
--- a/homeassistant/components/plugwise/climate.py
+++ b/homeassistant/components/plugwise/climate.py
@@ -16,7 +16,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MASTER_THERMOSTATS
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
@@ -29,7 +29,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: PlugwiseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smile Thermostats from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json
index 983ff10b0a6..3f812c1a63b 100644
--- a/homeassistant/components/plugwise/manifest.json
+++ b/homeassistant/components/plugwise/manifest.json
@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
- "requirements": ["plugwise==1.7.1"],
+ "requirements": ["plugwise==1.7.3"],
"zeroconf": ["_plugwise._tcp.local."]
}
diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py
index 57e3ba77972..1dbb0506748 100644
--- a/homeassistant/components/plugwise/number.py
+++ b/homeassistant/components/plugwise/number.py
@@ -12,7 +12,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import NumberType
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
@@ -57,7 +57,7 @@ NUMBER_TYPES = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PlugwiseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Plugwise number platform."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py
index 9c43b71f5f4..6ca1d4ce7a2 100644
--- a/homeassistant/components/plugwise/select.py
+++ b/homeassistant/components/plugwise/select.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SelectOptionsType, SelectType
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
@@ -55,7 +55,7 @@ SELECT_TYPES = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PlugwiseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smile selector from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/plugwise/sensor.py b/homeassistant/components/plugwise/sensor.py
index 33419abb4dc..7bd93e2ff84 100644
--- a/homeassistant/components/plugwise/sensor.py
+++ b/homeassistant/components/plugwise/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
@@ -405,7 +405,7 @@ SENSORS: tuple[PlugwiseSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PlugwiseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smile sensors from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json
index d16b38df992..d26e70d1c4f 100644
--- a/homeassistant/components/plugwise/strings.json
+++ b/homeassistant/components/plugwise/strings.json
@@ -19,11 +19,11 @@
"host": "[%key:common::config_flow::data::ip%]",
"password": "Smile ID",
"port": "[%key:common::config_flow::data::port%]",
- "username": "Smile Username"
+ "username": "Smile username"
},
"data_description": {
"password": "The Smile ID printed on the label on the back of your Adam, Smile-T, or P1.",
- "host": "The hostname or IP-address of your Smile. You can find it in your router or the Plugwise App.",
+ "host": "The hostname or IP-address of your Smile. You can find it in your router or the Plugwise app.",
"port": "By default your Smile uses port 80, normally you should not have to change this.",
"username": "Default is `smile`, or `stretch` for the legacy Stretch."
}
@@ -85,7 +85,7 @@
"preset_mode": {
"state": {
"asleep": "Night",
- "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]",
+ "away": "[%key:common::state::not_home%]",
"home": "[%key:common::state::home%]",
"no_frost": "Anti-frost",
"vacation": "Vacation"
@@ -113,7 +113,7 @@
"name": "DHW mode",
"state": {
"off": "[%key:common::state::off%]",
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
"boost": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::boost%]",
"comfort": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::comfort%]"
}
@@ -122,7 +122,7 @@
"name": "Gateway mode",
"state": {
"away": "Pause",
- "full": "Normal",
+ "full": "[%key:common::state::normal%]",
"vacation": "Vacation"
}
},
@@ -139,7 +139,7 @@
"select_schedule": {
"name": "Thermostat schedule",
"state": {
- "off": "Off"
+ "off": "[%key:common::state::off%]"
}
}
},
@@ -184,7 +184,7 @@
"name": "Electricity consumed peak interval"
},
"electricity_consumed_off_peak_interval": {
- "name": "Electricity consumed off peak interval"
+ "name": "Electricity consumed off-peak interval"
},
"electricity_produced_interval": {
"name": "Electricity produced interval"
@@ -193,19 +193,19 @@
"name": "Electricity produced peak interval"
},
"electricity_produced_off_peak_interval": {
- "name": "Electricity produced off peak interval"
+ "name": "Electricity produced off-peak interval"
},
"electricity_consumed_point": {
"name": "Electricity consumed point"
},
"electricity_consumed_off_peak_point": {
- "name": "Electricity consumed off peak point"
+ "name": "Electricity consumed off-peak point"
},
"electricity_consumed_peak_point": {
"name": "Electricity consumed peak point"
},
"electricity_consumed_off_peak_cumulative": {
- "name": "Electricity consumed off peak cumulative"
+ "name": "Electricity consumed off-peak cumulative"
},
"electricity_consumed_peak_cumulative": {
"name": "Electricity consumed peak cumulative"
@@ -214,13 +214,13 @@
"name": "Electricity produced point"
},
"electricity_produced_off_peak_point": {
- "name": "Electricity produced off peak point"
+ "name": "Electricity produced off-peak point"
},
"electricity_produced_peak_point": {
"name": "Electricity produced peak point"
},
"electricity_produced_off_peak_cumulative": {
- "name": "Electricity produced off peak cumulative"
+ "name": "Electricity produced off-peak cumulative"
},
"electricity_produced_peak_cumulative": {
"name": "Electricity produced peak cumulative"
diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py
index 9a36d0d708c..8179fb546b4 100644
--- a/homeassistant/components/plugwise/switch.py
+++ b/homeassistant/components/plugwise/switch.py
@@ -14,7 +14,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
@@ -57,7 +57,7 @@ SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PlugwiseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smile switches from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py
index 08a3d0ab0b9..78743c12808 100644
--- a/homeassistant/components/plum_lightpad/light.py
+++ b/homeassistant/components/plum_lightpad/light.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .const import DOMAIN
@@ -25,7 +25,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Plum Lightpad dimmer lights and glow rings."""
diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py
index e446606f191..5c782bb3304 100644
--- a/homeassistant/components/point/__init__.py
+++ b/homeassistant/components/point/__init__.py
@@ -1,107 +1,28 @@
"""Support for Minut Point."""
-import asyncio
-from dataclasses import dataclass
from http import HTTPStatus
import logging
from aiohttp import ClientError, ClientResponseError, web
from pypoint import PointSession
-import voluptuous as vol
from homeassistant.components import webhook
-from homeassistant.components.application_credentials import (
- ClientCredential,
- async_import_client_credential,
-)
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import (
- CONF_CLIENT_ID,
- CONF_CLIENT_SECRET,
- CONF_WEBHOOK_ID,
- Platform,
-)
-from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_WEBHOOK_ID, Platform
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
-from homeassistant.helpers import (
- aiohttp_client,
- config_entry_oauth2_flow,
- config_validation as cv,
-)
+from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.event import async_track_time_interval
-from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
-from homeassistant.helpers.typing import ConfigType
from . import api
-from .const import (
- CONF_WEBHOOK_URL,
- DOMAIN,
- EVENT_RECEIVED,
- POINT_DISCOVERY_NEW,
- SCAN_INTERVAL,
- SIGNAL_UPDATE_ENTITY,
- SIGNAL_WEBHOOK,
-)
+from .const import CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, SIGNAL_WEBHOOK
+from .coordinator import PointDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
-type PointConfigEntry = ConfigEntry[PointData]
-
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {
- vol.Required(CONF_CLIENT_ID): cv.string,
- vol.Required(CONF_CLIENT_SECRET): cv.string,
- }
- )
- },
- extra=vol.ALLOW_EXTRA,
-)
-
-
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the Minut Point component."""
- if DOMAIN not in config:
- return True
-
- conf = config[DOMAIN]
-
- async_create_issue(
- hass,
- HOMEASSISTANT_DOMAIN,
- f"deprecated_yaml_{DOMAIN}",
- breaks_in_ha_version="2025.4.0",
- is_fixable=False,
- issue_domain=DOMAIN,
- severity=IssueSeverity.WARNING,
- translation_key="deprecated_yaml",
- translation_placeholders={
- "domain": DOMAIN,
- "integration_title": "Point",
- },
- )
-
- if not hass.config_entries.async_entries(DOMAIN):
- await async_import_client_credential(
- hass,
- DOMAIN,
- ClientCredential(
- conf[CONF_CLIENT_ID],
- conf[CONF_CLIENT_SECRET],
- ),
- )
-
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
- )
- )
-
- return True
+type PointConfigEntry = ConfigEntry[PointDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bool:
@@ -131,9 +52,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PointConfigEntry) -> boo
point_session = PointSession(auth)
- client = MinutPointClient(hass, entry, point_session)
- hass.async_create_task(client.update())
- entry.runtime_data = PointData(client)
+ coordinator = PointDataUpdateCoordinator(hass, point_session)
+
+ await coordinator.async_config_entry_first_refresh()
+
+ entry.runtime_data = coordinator
await async_setup_webhook(hass, entry, point_session)
await hass.config_entries.async_forward_entry_setups(
@@ -176,7 +99,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: PointConfigEntry) -> bo
if unload_ok := await hass.config_entries.async_unload_platforms(
entry, [*PLATFORMS, Platform.ALARM_CONTROL_PANEL]
):
- session: PointSession = entry.runtime_data.client
+ session = entry.runtime_data.point
if CONF_WEBHOOK_ID in entry.data:
webhook.async_unregister(hass, entry.data[CONF_WEBHOOK_ID])
await session.remove_webhook()
@@ -197,87 +120,3 @@ async def handle_webhook(
data["webhook_id"] = webhook_id
async_dispatcher_send(hass, SIGNAL_WEBHOOK, data, data.get("hook_id"))
hass.bus.async_fire(EVENT_RECEIVED, data)
-
-
-class MinutPointClient:
- """Get the latest data and update the states."""
-
- def __init__(
- self, hass: HomeAssistant, config_entry: ConfigEntry, session: PointSession
- ) -> None:
- """Initialize the Minut data object."""
- self._known_devices: set[str] = set()
- self._known_homes: set[str] = set()
- self._hass = hass
- self._config_entry = config_entry
- self._is_available = True
- self._client = session
-
- async_track_time_interval(self._hass, self.update, SCAN_INTERVAL)
-
- async def update(self, *args):
- """Periodically poll the cloud for current state."""
- await self._sync()
-
- async def _sync(self):
- """Update local list of devices."""
- if not await self._client.update():
- self._is_available = False
- _LOGGER.warning("Device is unavailable")
- async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY)
- return
-
- self._is_available = True
- for home_id in self._client.homes:
- if home_id not in self._known_homes:
- async_dispatcher_send(
- self._hass,
- POINT_DISCOVERY_NEW.format(Platform.ALARM_CONTROL_PANEL),
- home_id,
- )
- self._known_homes.add(home_id)
- for device in self._client.devices:
- if device.device_id not in self._known_devices:
- for platform in PLATFORMS:
- async_dispatcher_send(
- self._hass,
- POINT_DISCOVERY_NEW.format(platform),
- device.device_id,
- )
- self._known_devices.add(device.device_id)
- async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY)
-
- def device(self, device_id):
- """Return device representation."""
- return self._client.device(device_id)
-
- def is_available(self, device_id):
- """Return device availability."""
- if not self._is_available:
- return False
- return device_id in self._client.device_ids
-
- async def remove_webhook(self):
- """Remove the session webhook."""
- return await self._client.remove_webhook()
-
- @property
- def homes(self):
- """Return known homes."""
- return self._client.homes
-
- async def async_alarm_disarm(self, home_id):
- """Send alarm disarm command."""
- return await self._client.alarm_disarm(home_id)
-
- async def async_alarm_arm(self, home_id):
- """Send alarm arm command."""
- return await self._client.alarm_arm(home_id)
-
-
-@dataclass
-class PointData:
- """Point Data."""
-
- client: MinutPointClient
- entry_lock: asyncio.Lock = asyncio.Lock()
diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py
index 4e4e4238176..fa56bf70546 100644
--- a/homeassistant/components/point/alarm_control_panel.py
+++ b/homeassistant/components/point/alarm_control_panel.py
@@ -2,23 +2,22 @@
from __future__ import annotations
-from collections.abc import Callable
import logging
+from pypoint import PointSession
+
from homeassistant.components.alarm_control_panel import (
- DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import MinutPointClient
-from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK
+from . import PointConfigEntry
+from .const import DOMAIN as POINT_DOMAIN, SIGNAL_WEBHOOK
_LOGGER = logging.getLogger(__name__)
@@ -32,21 +31,20 @@ EVENT_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: PointConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Point's alarm_control_panel based on a config entry."""
+ coordinator = config_entry.runtime_data
- async def async_discover_home(home_id):
+ def async_discover_home(home_id: str) -> None:
"""Discover and add a discovered home."""
- client = config_entry.runtime_data.client
- async_add_entities([MinutPointAlarmControl(client, home_id)], True)
+ async_add_entities([MinutPointAlarmControl(coordinator.point, home_id)])
- async_dispatcher_connect(
- hass,
- POINT_DISCOVERY_NEW.format(ALARM_CONTROL_PANEL_DOMAIN, POINT_DOMAIN),
- async_discover_home,
- )
+ coordinator.new_home_callback = async_discover_home
+
+ for home_id in coordinator.point.homes:
+ async_discover_home(home_id)
class MinutPointAlarmControl(AlarmControlPanelEntity):
@@ -55,12 +53,11 @@ class MinutPointAlarmControl(AlarmControlPanelEntity):
_attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY
_attr_code_arm_required = False
- def __init__(self, point_client: MinutPointClient, home_id: str) -> None:
+ def __init__(self, point: PointSession, home_id: str) -> None:
"""Initialize the entity."""
- self._client = point_client
+ self._client = point
self._home_id = home_id
- self._async_unsub_hook_dispatcher_connect: Callable[[], None] | None = None
- self._home = point_client.homes[self._home_id]
+ self._home = point.homes[self._home_id]
self._attr_name = self._home["name"]
self._attr_unique_id = f"point.{home_id}"
@@ -73,16 +70,10 @@ class MinutPointAlarmControl(AlarmControlPanelEntity):
async def async_added_to_hass(self) -> None:
"""Call when entity is added to HOme Assistant."""
await super().async_added_to_hass()
- self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect(
- self.hass, SIGNAL_WEBHOOK, self._webhook_event
+ self.async_on_remove(
+ async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event)
)
- async def async_will_remove_from_hass(self) -> None:
- """Disconnect dispatcher listener when removed."""
- await super().async_will_remove_from_hass()
- if self._async_unsub_hook_dispatcher_connect:
- self._async_unsub_hook_dispatcher_connect()
-
@callback
def _webhook_event(self, data, webhook):
"""Process new event from the webhook."""
@@ -107,12 +98,12 @@ class MinutPointAlarmControl(AlarmControlPanelEntity):
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
- status = await self._client.async_alarm_disarm(self._home_id)
+ status = await self._client.alarm_disarm(self._home_id)
if status:
self._home["alarm_status"] = "off"
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
- status = await self._client.async_alarm_arm(self._home_id)
+ status = await self._client.alarm_arm(self._home_id)
if status:
self._home["alarm_status"] = "on"
diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py
index 546c7d9cb0f..17fe40b9654 100644
--- a/homeassistant/components/point/binary_sensor.py
+++ b/homeassistant/components/point/binary_sensor.py
@@ -3,26 +3,27 @@
from __future__ import annotations
import logging
+from typing import Any
from pypoint import EVENTS
from homeassistant.components.binary_sensor import (
- DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK
+from . import PointConfigEntry
+from .const import SIGNAL_WEBHOOK
+from .coordinator import PointDataUpdateCoordinator
from .entity import MinutPointEntity
_LOGGER = logging.getLogger(__name__)
-DEVICES = {
+DEVICES: dict[str, Any] = {
"alarm": {"icon": "mdi:alarm-bell"},
"battery": {"device_class": BinarySensorDeviceClass.BATTERY},
"button_press": {"icon": "mdi:gesture-tap-button"},
@@ -42,69 +43,60 @@ DEVICES = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: PointConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Point's binary sensors based on a config entry."""
- async def async_discover_sensor(device_id):
+ coordinator = config_entry.runtime_data
+
+ def async_discover_sensor(device_id: str) -> None:
"""Discover and add a discovered sensor."""
- client = config_entry.runtime_data.client
async_add_entities(
- (
- MinutPointBinarySensor(client, device_id, device_name)
- for device_name in DEVICES
- if device_name in EVENTS
- ),
- True,
+ MinutPointBinarySensor(coordinator, device_id, device_name)
+ for device_name in DEVICES
+ if device_name in EVENTS
)
- async_dispatcher_connect(
- hass,
- POINT_DISCOVERY_NEW.format(BINARY_SENSOR_DOMAIN, POINT_DOMAIN),
- async_discover_sensor,
+ coordinator.new_device_callbacks.append(async_discover_sensor)
+
+ async_add_entities(
+ MinutPointBinarySensor(coordinator, device_id, device_name)
+ for device_name in DEVICES
+ if device_name in EVENTS
+ for device_id in coordinator.point.device_ids
)
class MinutPointBinarySensor(MinutPointEntity, BinarySensorEntity):
"""The platform class required by Home Assistant."""
- def __init__(self, point_client, device_id, device_name):
+ def __init__(
+ self, coordinator: PointDataUpdateCoordinator, device_id: str, key: str
+ ) -> None:
"""Initialize the binary sensor."""
- super().__init__(
- point_client,
- device_id,
- DEVICES[device_name].get("device_class", device_name),
- )
- self._device_name = device_name
- self._async_unsub_hook_dispatcher_connect = None
- self._events = EVENTS[device_name]
- self._attr_unique_id = f"point.{device_id}-{device_name}"
- self._attr_icon = DEVICES[self._device_name].get("icon")
+ self._attr_device_class = DEVICES[key].get("device_class", key)
+ super().__init__(coordinator, device_id)
+ self._device_name = key
+ self._events = EVENTS[key]
+ self._attr_unique_id = f"point.{device_id}-{key}"
+ self._attr_icon = DEVICES[key].get("icon")
async def async_added_to_hass(self) -> None:
"""Call when entity is added to HOme Assistant."""
await super().async_added_to_hass()
- self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect(
- self.hass, SIGNAL_WEBHOOK, self._webhook_event
+ self.async_on_remove(
+ async_dispatcher_connect(self.hass, SIGNAL_WEBHOOK, self._webhook_event)
)
- async def async_will_remove_from_hass(self) -> None:
- """Disconnect dispatcher listener when removed."""
- await super().async_will_remove_from_hass()
- if self._async_unsub_hook_dispatcher_connect:
- self._async_unsub_hook_dispatcher_connect()
-
- async def _update_callback(self):
+ def _handle_coordinator_update(self) -> None:
"""Update the value of the sensor."""
- if not self.is_updated:
- return
if self.device_class == BinarySensorDeviceClass.CONNECTIVITY:
# connectivity is the other way around.
self._attr_is_on = self._events[0] not in self.device.ongoing_events
else:
self._attr_is_on = self._events[0] in self.device.ongoing_events
- self.async_write_ha_state()
+ super()._handle_coordinator_update()
@callback
def _webhook_event(self, data, webhook):
diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py
index a0a51c7b9e6..426177a1849 100644
--- a/homeassistant/components/point/config_flow.py
+++ b/homeassistant/components/point/config_flow.py
@@ -11,6 +11,8 @@ from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHan
from .const import DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle Minut Point OAuth2 authentication."""
@@ -22,10 +24,6 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Return logger."""
return logging.getLogger(__name__)
- async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult:
- """Handle import from YAML."""
- return await self.async_step_user()
-
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
@@ -56,7 +54,7 @@ class OAuth2FlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN):
if reauth_entry.unique_id is not None:
self._abort_if_unique_id_mismatch(reason="wrong_account")
- logging.debug("user_id: %s", user_id)
+ _LOGGER.debug("user_id: %s", user_id)
return self.async_update_reload_and_abort(
reauth_entry, data_updates=data, unique_id=user_id
)
diff --git a/homeassistant/components/point/coordinator.py b/homeassistant/components/point/coordinator.py
new file mode 100644
index 00000000000..c0cb4e27646
--- /dev/null
+++ b/homeassistant/components/point/coordinator.py
@@ -0,0 +1,70 @@
+"""Define a data update coordinator for Point."""
+
+from collections.abc import Callable
+from datetime import datetime
+import logging
+from typing import Any
+
+from pypoint import PointSession
+from tempora.utc import fromtimestamp
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util.dt import parse_datetime
+
+from .const import DOMAIN, SCAN_INTERVAL
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class PointDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
+ """Class to manage fetching Point data from the API."""
+
+ def __init__(self, hass: HomeAssistant, point: PointSession) -> None:
+ """Initialize."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ )
+ self.point = point
+ self.device_updates: dict[str, datetime] = {}
+ self._known_devices: set[str] = set()
+ self._known_homes: set[str] = set()
+ self.new_home_callback: Callable[[str], None] | None = None
+ self.new_device_callbacks: list[Callable[[str], None]] = []
+ self.data: dict[str, dict[str, Any]] = {}
+
+ async def _async_update_data(self) -> dict[str, dict[str, Any]]:
+ if not await self.point.update():
+ raise UpdateFailed("Failed to fetch data from Point")
+
+ if new_homes := set(self.point.homes) - self._known_homes:
+ _LOGGER.debug("Found new homes: %s", new_homes)
+ for home_id in new_homes:
+ if self.new_home_callback:
+ self.new_home_callback(home_id)
+ self._known_homes.update(new_homes)
+
+ device_ids = {device.device_id for device in self.point.devices}
+ if new_devices := device_ids - self._known_devices:
+ _LOGGER.debug("Found new devices: %s", new_devices)
+ for device_id in new_devices:
+ for callback in self.new_device_callbacks:
+ callback(device_id)
+ self._known_devices.update(new_devices)
+
+ for device in self.point.devices:
+ last_updated = parse_datetime(device.last_update)
+ if (
+ not last_updated
+ or device.device_id not in self.device_updates
+ or self.device_updates[device.device_id] < last_updated
+ ):
+ self.device_updates[device.device_id] = last_updated or fromtimestamp(0)
+ self.data[device.device_id] = {
+ k: await device.sensor(k)
+ for k in ("temperature", "humidity", "sound_pressure")
+ }
+ return self.data
diff --git a/homeassistant/components/point/entity.py b/homeassistant/components/point/entity.py
index 4784dd43180..39af7867e97 100644
--- a/homeassistant/components/point/entity.py
+++ b/homeassistant/components/point/entity.py
@@ -2,31 +2,27 @@
import logging
+from pypoint import Device, PointSession
+
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity import Entity
-from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.util.dt import as_local
-from .const import DOMAIN, SIGNAL_UPDATE_ENTITY
+from .const import DOMAIN
+from .coordinator import PointDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
-class MinutPointEntity(Entity):
+class MinutPointEntity(CoordinatorEntity[PointDataUpdateCoordinator]):
"""Base Entity used by the sensors."""
- _attr_should_poll = False
-
- def __init__(self, point_client, device_id, device_class) -> None:
+ def __init__(self, coordinator: PointDataUpdateCoordinator, device_id: str) -> None:
"""Initialize the entity."""
- self._async_unsub_dispatcher_connect = None
- self._client = point_client
- self._id = device_id
+ super().__init__(coordinator)
+ self.device_id = device_id
self._name = self.device.name
- self._attr_device_class = device_class
- self._updated = utc_from_timestamp(0)
- self._attr_unique_id = f"point.{device_id}-{device_class}"
device = self.device.device
self._attr_device_info = DeviceInfo(
connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])},
@@ -37,59 +33,32 @@ class MinutPointEntity(Entity):
sw_version=device["firmware"]["installed"],
via_device=(DOMAIN, device["home"]),
)
- if device_class:
- self._attr_name = f"{self._name} {device_class.capitalize()}"
-
- def __str__(self) -> str:
- """Return string representation of device."""
- return f"MinutPoint {self.name}"
-
- async def async_added_to_hass(self):
- """Call when entity is added to hass."""
- _LOGGER.debug("Created device %s", self)
- self._async_unsub_dispatcher_connect = async_dispatcher_connect(
- self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback
- )
- await self._update_callback()
-
- async def async_will_remove_from_hass(self):
- """Disconnect dispatcher listener when removed."""
- if self._async_unsub_dispatcher_connect:
- self._async_unsub_dispatcher_connect()
+ if self.device_class:
+ self._attr_name = f"{self._name} {self.device_class.capitalize()}"
async def _update_callback(self):
"""Update the value of the sensor."""
@property
- def available(self):
+ def client(self) -> PointSession:
+ """Return the client object."""
+ return self.coordinator.point
+
+ @property
+ def available(self) -> bool:
"""Return true if device is not offline."""
- return self._client.is_available(self.device_id)
+ return super().available and self.device_id in self.client.device_ids
@property
- def device(self):
+ def device(self) -> Device:
"""Return the representation of the device."""
- return self._client.device(self.device_id)
-
- @property
- def device_id(self):
- """Return the id of the device."""
- return self._id
+ return self.client.device(self.device_id)
@property
def extra_state_attributes(self):
"""Return status of device."""
attrs = self.device.device_status
- attrs["last_heard_from"] = as_local(self.last_update).strftime(
- "%Y-%m-%d %H:%M:%S"
- )
+ attrs["last_heard_from"] = as_local(
+ self.coordinator.device_updates[self.device_id]
+ ).strftime("%Y-%m-%d %H:%M:%S")
return attrs
-
- @property
- def is_updated(self):
- """Return true if sensor have been updated."""
- return self.last_update > self._updated
-
- @property
- def last_update(self):
- """Return the last_update time for the device."""
- return parse_datetime(self.device.last_update)
diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py
index d864c8bb18c..246536d86ab 100644
--- a/homeassistant/components/point/sensor.py
+++ b/homeassistant/components/point/sensor.py
@@ -5,19 +5,17 @@ from __future__ import annotations
import logging
from homeassistant.components.sensor import (
- DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfSoundPressure, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util.dt import parse_datetime
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
-from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW
+from . import PointConfigEntry
+from .coordinator import PointDataUpdateCoordinator
from .entity import MinutPointEntity
_LOGGER = logging.getLogger(__name__)
@@ -37,7 +35,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
native_unit_of_measurement=PERCENTAGE,
),
SensorEntityDescription(
- key="sound",
+ key="sound_pressure",
suggested_display_precision=1,
device_class=SensorDeviceClass.SOUND_PRESSURE,
native_unit_of_measurement=UnitOfSoundPressure.WEIGHTED_DECIBEL_A,
@@ -47,26 +45,26 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: PointConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Point's sensors based on a config entry."""
- async def async_discover_sensor(device_id):
+ coordinator = config_entry.runtime_data
+
+ def async_discover_sensor(device_id: str) -> None:
"""Discover and add a discovered sensor."""
- client = config_entry.runtime_data.client
async_add_entities(
- [
- MinutPointSensor(client, device_id, description)
- for description in SENSOR_TYPES
- ],
- True,
+ MinutPointSensor(coordinator, device_id, description)
+ for description in SENSOR_TYPES
)
- async_dispatcher_connect(
- hass,
- POINT_DISCOVERY_NEW.format(SENSOR_DOMAIN, POINT_DOMAIN),
- async_discover_sensor,
+ coordinator.new_device_callbacks.append(async_discover_sensor)
+
+ async_add_entities(
+ MinutPointSensor(coordinator, device_id, description)
+ for device_id in coordinator.data
+ for description in SENSOR_TYPES
)
@@ -74,16 +72,17 @@ class MinutPointSensor(MinutPointEntity, SensorEntity):
"""The platform class required by Home Assistant."""
def __init__(
- self, point_client, device_id, description: SensorEntityDescription
+ self,
+ coordinator: PointDataUpdateCoordinator,
+ device_id: str,
+ description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
- super().__init__(point_client, device_id, description.device_class)
self.entity_description = description
+ super().__init__(coordinator, device_id)
+ self._attr_unique_id = f"point.{device_id}-{description.key}"
- async def _update_callback(self):
- """Update the value of the sensor."""
- _LOGGER.debug("Update sensor value for %s", self)
- if self.is_updated:
- self._attr_native_value = await self.device.sensor(self.device_class)
- self._updated = parse_datetime(self.device.last_update)
- self.async_write_ha_state()
+ @property
+ def native_value(self) -> StateType:
+ """Return the state of the sensor."""
+ return self.coordinator.data[self.device_id].get(self.entity_description.key)
diff --git a/homeassistant/components/poolsense/binary_sensor.py b/homeassistant/components/poolsense/binary_sensor.py
index dbff3d4cef4..b93f017501d 100644
--- a/homeassistant/components/poolsense/binary_sensor.py
+++ b/homeassistant/components/poolsense/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PoolSenseConfigEntry
from .entity import PoolSenseEntity
@@ -30,7 +30,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PoolSenseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py
index 11d94167b6d..b0ac4404237 100644
--- a/homeassistant/components/poolsense/sensor.py
+++ b/homeassistant/components/poolsense/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfElectricPotential, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import PoolSenseConfigEntry
@@ -65,7 +65,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: PoolSenseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Defer sensor setup to the shared sensor module."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/powerfox/sensor.py b/homeassistant/components/powerfox/sensor.py
index d293c5c7a53..ab60c99a58b 100644
--- a/homeassistant/components/powerfox/sensor.py
+++ b/homeassistant/components/powerfox/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfEnergy, UnitOfPower, UnitOfVolume
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import PowerfoxConfigEntry, PowerfoxDataUpdateCoordinator
from .entity import PowerfoxEntity
@@ -130,7 +130,7 @@ SENSORS_HEAT: tuple[PowerfoxSensorEntityDescription[HeatMeter], ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PowerfoxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Powerfox sensors based on a config entry."""
entities: list[SensorEntity] = []
diff --git a/homeassistant/components/powerwall/binary_sensor.py b/homeassistant/components/powerwall/binary_sensor.py
index c50876e22fb..100e31b1c21 100644
--- a/homeassistant/components/powerwall/binary_sensor.py
+++ b/homeassistant/components/powerwall/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import PowerWallEntity
from .models import PowerwallConfigEntry
@@ -23,7 +23,7 @@ CONNECTED_GRID_STATUSES = {
async def async_setup_entry(
hass: HomeAssistant,
entry: PowerwallConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the powerwall sensors."""
powerwall_data = entry.runtime_data
diff --git a/homeassistant/components/powerwall/sensor.py b/homeassistant/components/powerwall/sensor.py
index 28506e2a60c..f242d2c67e6 100644
--- a/homeassistant/components/powerwall/sensor.py
+++ b/homeassistant/components/powerwall/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import POWERWALL_COORDINATOR
from .entity import BatteryEntity, PowerWallEntity
@@ -213,7 +213,7 @@ BATTERY_INSTANT_SENSORS: list[PowerwallSensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
entry: PowerwallConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the powerwall sensors."""
powerwall_data = entry.runtime_data
@@ -297,7 +297,6 @@ class PowerWallBackupReserveSensor(PowerWallEntity, SensorEntity):
_attr_translation_key = "backup_reserve"
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_native_unit_of_measurement = PERCENTAGE
- _attr_device_class = SensorDeviceClass.BATTERY
@property
def unique_id(self) -> str:
diff --git a/homeassistant/components/powerwall/switch.py b/homeassistant/components/powerwall/switch.py
index 214ca01fb63..a874161de5b 100644
--- a/homeassistant/components/powerwall/switch.py
+++ b/homeassistant/components/powerwall/switch.py
@@ -8,7 +8,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import PowerWallEntity
from .models import PowerwallConfigEntry, PowerwallRuntimeData
@@ -22,7 +22,7 @@ OFF_GRID_STATUSES = {
async def async_setup_entry(
hass: HomeAssistant,
entry: PowerwallConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Powerwall switch platform from Powerwall resources."""
async_add_entities([PowerwallOffGridEnabledEntity(entry.runtime_data)])
diff --git a/homeassistant/components/private_ble_device/device_tracker.py b/homeassistant/components/private_ble_device/device_tracker.py
index fbaf0d44751..eaccbd6c785 100644
--- a/homeassistant/components/private_ble_device/device_tracker.py
+++ b/homeassistant/components/private_ble_device/device_tracker.py
@@ -11,7 +11,7 @@ from homeassistant.components.device_tracker.config_entry import BaseTrackerEnti
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import BasePrivateDeviceEntity
@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Device Tracker entities for a config entry."""
async_add_entities([BasePrivateDeviceTracker(config_entry)])
diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json
index 445affbcd57..ceafd8dc4f7 100644
--- a/homeassistant/components/private_ble_device/manifest.json
+++ b/homeassistant/components/private_ble_device/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/private_ble_device",
"iot_class": "local_push",
- "requirements": ["bluetooth-data-tools==1.23.4"]
+ "requirements": ["bluetooth-data-tools==1.27.0"]
}
diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py
index e2c4fb0c7da..d8c09500332 100644
--- a/homeassistant/components/private_ble_device/sensor.py
+++ b/homeassistant/components/private_ble_device/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import BasePrivateDeviceEntity
@@ -92,7 +92,9 @@ SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for Private BLE component."""
async_add_entities(
diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json
index c35775a4843..845a5d92bae 100644
--- a/homeassistant/components/private_ble_device/strings.json
+++ b/homeassistant/components/private_ble_device/strings.json
@@ -14,7 +14,7 @@
"irk_not_valid": "The key does not look like a valid IRK."
},
"abort": {
- "bluetooth_not_available": "At least one Bluetooth adapter or remote bluetooth proxy must be configured to track Private BLE Devices."
+ "bluetooth_not_available": "At least one Bluetooth adapter or remote Bluetooth proxy must be configured to track Private BLE Devices."
}
},
"entity": {
diff --git a/homeassistant/components/profilo/__init__.py b/homeassistant/components/profilo/__init__.py
new file mode 100644
index 00000000000..5f727b1bc8b
--- /dev/null
+++ b/homeassistant/components/profilo/__init__.py
@@ -0,0 +1 @@
+"""Profilo virtual integration."""
diff --git a/homeassistant/components/profilo/manifest.json b/homeassistant/components/profilo/manifest.json
new file mode 100644
index 00000000000..c5671d5be3f
--- /dev/null
+++ b/homeassistant/components/profilo/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "profilo",
+ "name": "Profilo",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+}
diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py
index a89b8b3c3f1..40296dcac90 100644
--- a/homeassistant/components/progettihwsw/binary_sensor.py
+++ b/homeassistant/components/progettihwsw/binary_sensor.py
@@ -9,7 +9,7 @@ from ProgettiHWSW.input import Input
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(DOMAIN)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary sensors from a config entry."""
board_api = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/progettihwsw/config_flow.py b/homeassistant/components/progettihwsw/config_flow.py
index 2e5ea221dca..8818eff2d81 100644
--- a/homeassistant/components/progettihwsw/config_flow.py
+++ b/homeassistant/components/progettihwsw/config_flow.py
@@ -1,5 +1,6 @@
"""Config flow for ProgettiHWSW Automation integration."""
+import logging
from typing import TYPE_CHECKING, Any
from ProgettiHWSW.ProgettiHWSWAPI import ProgettiHWSWAPI
@@ -11,6 +12,8 @@ from homeassistant.exceptions import HomeAssistantError
from .const import DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
DATA_SCHEMA = vol.Schema(
{vol.Required("host"): str, vol.Required("port", default=80): int}
)
@@ -86,7 +89,8 @@ class ProgettiHWSWConfigFlow(ConfigFlow, domain=DOMAIN):
info = await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
user_input.update(info)
diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py
index 983a2383e99..256d90ae5b7 100644
--- a/homeassistant/components/progettihwsw/switch.py
+++ b/homeassistant/components/progettihwsw/switch.py
@@ -10,7 +10,7 @@ from ProgettiHWSW.relay import Relay
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(DOMAIN)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switches from a config entry."""
board_api = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py
index 1c58b64cf55..1f0f89c5f04 100644
--- a/homeassistant/components/prosegur/alarm_control_panel.py
+++ b/homeassistant/components/prosegur/alarm_control_panel.py
@@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
@@ -30,7 +30,9 @@ STATE_MAPPING = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Prosegur alarm control panel platform."""
async_add_entities(
diff --git a/homeassistant/components/prosegur/camera.py b/homeassistant/components/prosegur/camera.py
index 2df6ff62038..3e1c91713e1 100644
--- a/homeassistant/components/prosegur/camera.py
+++ b/homeassistant/components/prosegur/camera.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
@@ -24,7 +24,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Prosegur camera platform."""
diff --git a/homeassistant/components/prosegur/manifest.json b/homeassistant/components/prosegur/manifest.json
index adf5e985fe9..2e649ebd5bd 100644
--- a/homeassistant/components/prosegur/manifest.json
+++ b/homeassistant/components/prosegur/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/prosegur",
"iot_class": "cloud_polling",
"loggers": ["pyprosegur"],
- "requirements": ["pyprosegur==0.0.9"]
+ "requirements": ["pyprosegur==0.0.14"]
}
diff --git a/homeassistant/components/prosegur/strings.json b/homeassistant/components/prosegur/strings.json
index 9b9ac45fc85..e5176e96090 100644
--- a/homeassistant/components/prosegur/strings.json
+++ b/homeassistant/components/prosegur/strings.json
@@ -5,7 +5,7 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
- "country": "Country"
+ "country": "[%key:common::config_flow::data::country%]"
}
},
"choose_contract": {
diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py
index 055c15125f1..856138c9051 100644
--- a/homeassistant/components/proximity/coordinator.py
+++ b/homeassistant/components/proximity/coordinator.py
@@ -164,7 +164,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
)
return None
- distance_to_zone = distance(
+ distance_to_centre = distance(
zone.attributes[ATTR_LATITUDE],
zone.attributes[ATTR_LONGITUDE],
latitude,
@@ -172,8 +172,13 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
)
# it is ensured, that distance can't be None, since zones must have lat/lon coordinates
- assert distance_to_zone is not None
- return round(distance_to_zone)
+ assert distance_to_centre is not None
+
+ zone_radius: float = zone.attributes["radius"]
+ if zone_radius > distance_to_centre:
+ # we've arrived the zone
+ return 0
+ return round(distance_to_centre - zone_radius)
def _calc_direction_of_travel(
self,
diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py
index 55d4ca02b9b..72203a2dff4 100644
--- a/homeassistant/components/proximity/sensor.py
+++ b/homeassistant/components/proximity/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.const import UnitOfLength
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -82,7 +82,7 @@ def _device_info(coordinator: ProximityDataUpdateCoordinator) -> DeviceInfo:
async def async_setup_entry(
hass: HomeAssistant,
entry: ProximityConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the proximity sensors."""
diff --git a/homeassistant/components/proximity/strings.json b/homeassistant/components/proximity/strings.json
index 118004e908e..5f713174f50 100644
--- a/homeassistant/components/proximity/strings.json
+++ b/homeassistant/components/proximity/strings.json
@@ -61,7 +61,7 @@
"step": {
"confirm": {
"title": "[%key:component::proximity::issues::tracked_entity_removed::title%]",
- "description": "The entity `{entity_id}` has been removed from HA, but is used in proximity {name}. Please remove `{entity_id}` from the list of tracked entities. Related proximity sensor entites were set to unavailable and can be removed."
+ "description": "The entity `{entity_id}` has been removed from HA, but is used in proximity {name}. Please remove `{entity_id}` from the list of tracked entities. Related proximity sensor entities were set to unavailable and can be removed."
}
}
}
diff --git a/homeassistant/components/proxmoxve/__init__.py b/homeassistant/components/proxmoxve/__init__.py
index 0db6ea28652..11fa530f47b 100644
--- a/homeassistant/components/proxmoxve/__init__.py
+++ b/homeassistant/components/proxmoxve/__init__.py
@@ -6,7 +6,6 @@ from datetime import timedelta
from typing import Any
from proxmoxer import AuthenticationError, ProxmoxAPI
-from proxmoxer.core import ResourceException
import requests.exceptions
from requests.exceptions import ConnectTimeout, SSLError
import voluptuous as vol
@@ -25,6 +24,7 @@ from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from .common import ProxmoxClient, call_api_container_vm, parse_api_container_vm
from .const import (
_LOGGER,
CONF_CONTAINERS,
@@ -219,80 +219,3 @@ def create_coordinator_container_vm(
update_method=async_update_data,
update_interval=timedelta(seconds=UPDATE_INTERVAL),
)
-
-
-def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]:
- """Get the container or vm api data and return it formatted in a dictionary.
-
- It is implemented in this way to allow for more data to be added for sensors
- in the future.
- """
-
- return {"status": status["status"], "name": status["name"]}
-
-
-def call_api_container_vm(
- proxmox: ProxmoxAPI,
- node_name: str,
- vm_id: int,
- machine_type: int,
-) -> dict[str, Any] | None:
- """Make proper api calls."""
- status = None
-
- try:
- if machine_type == TYPE_VM:
- status = proxmox.nodes(node_name).qemu(vm_id).status.current.get()
- elif machine_type == TYPE_CONTAINER:
- status = proxmox.nodes(node_name).lxc(vm_id).status.current.get()
- except (ResourceException, requests.exceptions.ConnectionError):
- return None
-
- return status
-
-
-class ProxmoxClient:
- """A wrapper for the proxmoxer ProxmoxAPI client."""
-
- _proxmox: ProxmoxAPI
-
- def __init__(
- self,
- host: str,
- port: int,
- user: str,
- realm: str,
- password: str,
- verify_ssl: bool,
- ) -> None:
- """Initialize the ProxmoxClient."""
-
- self._host = host
- self._port = port
- self._user = user
- self._realm = realm
- self._password = password
- self._verify_ssl = verify_ssl
-
- def build_client(self) -> None:
- """Construct the ProxmoxAPI client.
-
- Allows inserting the realm within the `user` value.
- """
-
- if "@" in self._user:
- user_id = self._user
- else:
- user_id = f"{self._user}@{self._realm}"
-
- self._proxmox = ProxmoxAPI(
- self._host,
- port=self._port,
- user=user_id,
- password=self._password,
- verify_ssl=self._verify_ssl,
- )
-
- def get_api_client(self) -> ProxmoxAPI:
- """Return the ProxmoxAPI client."""
- return self._proxmox
diff --git a/homeassistant/components/proxmoxve/common.py b/homeassistant/components/proxmoxve/common.py
new file mode 100644
index 00000000000..4173377377c
--- /dev/null
+++ b/homeassistant/components/proxmoxve/common.py
@@ -0,0 +1,88 @@
+"""Commons for Proxmox VE integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from proxmoxer import ProxmoxAPI
+from proxmoxer.core import ResourceException
+import requests.exceptions
+
+from .const import TYPE_CONTAINER, TYPE_VM
+
+
+class ProxmoxClient:
+ """A wrapper for the proxmoxer ProxmoxAPI client."""
+
+ _proxmox: ProxmoxAPI
+
+ def __init__(
+ self,
+ host: str,
+ port: int,
+ user: str,
+ realm: str,
+ password: str,
+ verify_ssl: bool,
+ ) -> None:
+ """Initialize the ProxmoxClient."""
+
+ self._host = host
+ self._port = port
+ self._user = user
+ self._realm = realm
+ self._password = password
+ self._verify_ssl = verify_ssl
+
+ def build_client(self) -> None:
+ """Construct the ProxmoxAPI client.
+
+ Allows inserting the realm within the `user` value.
+ """
+
+ if "@" in self._user:
+ user_id = self._user
+ else:
+ user_id = f"{self._user}@{self._realm}"
+
+ self._proxmox = ProxmoxAPI(
+ self._host,
+ port=self._port,
+ user=user_id,
+ password=self._password,
+ verify_ssl=self._verify_ssl,
+ )
+
+ def get_api_client(self) -> ProxmoxAPI:
+ """Return the ProxmoxAPI client."""
+ return self._proxmox
+
+
+def parse_api_container_vm(status: dict[str, Any]) -> dict[str, Any]:
+ """Get the container or vm api data and return it formatted in a dictionary.
+
+ It is implemented in this way to allow for more data to be added for sensors
+ in the future.
+ """
+
+ return {"status": status["status"], "name": status["name"]}
+
+
+def call_api_container_vm(
+ proxmox: ProxmoxAPI,
+ node_name: str,
+ vm_id: int,
+ machine_type: int,
+) -> dict[str, Any] | None:
+ """Make proper api calls."""
+ status = None
+
+ try:
+ if machine_type == TYPE_VM:
+ status = proxmox.nodes(node_name).qemu(vm_id).status.current.get()
+ elif machine_type == TYPE_CONTAINER:
+ status = proxmox.nodes(node_name).lxc(vm_id).status.current.get()
+ except (ResourceException, requests.exceptions.ConnectionError):
+ return None
+
+ return status
diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py
index f6e909f13d1..47fa9454deb 100644
--- a/homeassistant/components/proxy/camera.py
+++ b/homeassistant/components/proxy/camera.py
@@ -104,6 +104,15 @@ def _resize_image(image, opts):
new_width = opts.max_width
(old_width, old_height) = img.size
old_size = len(image)
+
+ # If no max_width specified, only apply quality changes if requested
+ if new_width is None:
+ if opts.quality is None:
+ return image
+ imgbuf = io.BytesIO()
+ img.save(imgbuf, "JPEG", optimize=True, quality=quality)
+ return imgbuf.getvalue()
+
if old_width <= new_width:
if opts.quality is None:
_LOGGER.debug("Image is smaller-than/equal-to requested width")
diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json
index 6925b9e2133..02074a18b61 100644
--- a/homeassistant/components/proxy/manifest.json
+++ b/homeassistant/components/proxy/manifest.json
@@ -4,5 +4,5 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/proxy",
"quality_scale": "legacy",
- "requirements": ["Pillow==11.1.0"]
+ "requirements": ["Pillow==11.2.1"]
}
diff --git a/homeassistant/components/prusalink/binary_sensor.py b/homeassistant/components/prusalink/binary_sensor.py
index d40ac8a4cfa..56be36c3e9d 100644
--- a/homeassistant/components/prusalink/binary_sensor.py
+++ b/homeassistant/components/prusalink/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import PrusaLinkUpdateCoordinator
@@ -57,7 +57,7 @@ BINARY_SENSORS: dict[str, tuple[PrusaLinkBinarySensorEntityDescription, ...]] =
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up PrusaLink sensor based on a config entry."""
coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][
diff --git a/homeassistant/components/prusalink/button.py b/homeassistant/components/prusalink/button.py
index 06d356b2ca6..59a63d874ee 100644
--- a/homeassistant/components/prusalink/button.py
+++ b/homeassistant/components/prusalink/button.py
@@ -13,7 +13,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import PrusaLinkUpdateCoordinator
@@ -72,7 +72,7 @@ BUTTONS: dict[str, tuple[PrusaLinkButtonEntityDescription, ...]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up PrusaLink buttons based on a config entry."""
coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][
diff --git a/homeassistant/components/prusalink/camera.py b/homeassistant/components/prusalink/camera.py
index eee655447cc..6aac03ca179 100644
--- a/homeassistant/components/prusalink/camera.py
+++ b/homeassistant/components/prusalink/camera.py
@@ -7,7 +7,7 @@ from pyprusalink.types import PrinterState
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import JobUpdateCoordinator
@@ -17,7 +17,7 @@ from .entity import PrusaLinkEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up PrusaLink camera."""
coordinator: JobUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]["job"]
diff --git a/homeassistant/components/prusalink/sensor.py b/homeassistant/components/prusalink/sensor.py
index 0c746adbe2e..b9588f72a3c 100644
--- a/homeassistant/components/prusalink/sensor.py
+++ b/homeassistant/components/prusalink/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
@@ -205,7 +205,7 @@ SENSORS: dict[str, tuple[PrusaLinkSensorEntityDescription, ...]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up PrusaLink sensor based on a config entry."""
coordinators: dict[str, PrusaLinkUpdateCoordinator] = hass.data[DOMAIN][
diff --git a/homeassistant/components/prusalink/strings.json b/homeassistant/components/prusalink/strings.json
index 7c6f0bbf2dd..036bd2c9c6e 100644
--- a/homeassistant/components/prusalink/strings.json
+++ b/homeassistant/components/prusalink/strings.json
@@ -36,7 +36,7 @@
"printing": "Printing",
"paused": "[%key:common::state::paused%]",
"finished": "Finished",
- "stopped": "Stopped",
+ "stopped": "[%key:common::state::stopped%]",
"error": "Error",
"attention": "Attention",
"ready": "Ready"
@@ -85,7 +85,7 @@
"name": "Z-Height"
},
"nozzle_diameter": {
- "name": "Nozzle Diameter"
+ "name": "Nozzle diameter"
}
},
"button": {
diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py
index 8db24beae20..4de7cbeb463 100644
--- a/homeassistant/components/ps4/media_player.py
+++ b/homeassistant/components/ps4/media_player.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import JsonObjectType
from . import format_unique_id, load_games, save_games
@@ -48,7 +48,7 @@ DEFAULT_RETRIES = 2
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up PS4 from a config entry."""
config = config_entry
diff --git a/homeassistant/components/pterodactyl/__init__.py b/homeassistant/components/pterodactyl/__init__.py
new file mode 100644
index 00000000000..c0e23b271d1
--- /dev/null
+++ b/homeassistant/components/pterodactyl/__init__.py
@@ -0,0 +1,27 @@
+"""The Pterodactyl integration."""
+
+from __future__ import annotations
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import PterodactylConfigEntry, PterodactylCoordinator
+
+_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: PterodactylConfigEntry) -> bool:
+ """Set up Pterodactyl from a config entry."""
+ coordinator = PterodactylCoordinator(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: PterodactylConfigEntry
+) -> bool:
+ """Unload a Pterodactyl config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
diff --git a/homeassistant/components/pterodactyl/api.py b/homeassistant/components/pterodactyl/api.py
new file mode 100644
index 00000000000..2aac359a5c6
--- /dev/null
+++ b/homeassistant/components/pterodactyl/api.py
@@ -0,0 +1,158 @@
+"""API module of the Pterodactyl integration."""
+
+from dataclasses import dataclass
+from enum import StrEnum
+import logging
+
+from pydactyl import PterodactylClient
+from pydactyl.exceptions import BadRequestError, PterodactylApiError
+from requests.exceptions import ConnectionError, HTTPError
+
+from homeassistant.core import HomeAssistant
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class PterodactylAuthorizationError(Exception):
+ """Raised when access to server is unauthorized."""
+
+
+class PterodactylConnectionError(Exception):
+ """Raised when no data can be fechted from the server."""
+
+
+@dataclass
+class PterodactylData:
+ """Data for the Pterodactyl server."""
+
+ name: str
+ uuid: str
+ identifier: str
+ state: str
+ cpu_utilization: float
+ cpu_limit: int
+ disk_usage: int
+ disk_limit: int
+ memory_usage: int
+ memory_limit: int
+ network_inbound: int
+ network_outbound: int
+ uptime: int
+
+
+class PterodactylCommand(StrEnum):
+ """Command enum for the Pterodactyl server."""
+
+ START_SERVER = "start"
+ STOP_SERVER = "stop"
+ RESTART_SERVER = "restart"
+ FORCE_STOP_SERVER = "kill"
+
+
+class PterodactylAPI:
+ """Wrapper for Pterodactyl's API."""
+
+ pterodactyl: PterodactylClient | None
+ identifiers: list[str]
+
+ def __init__(self, hass: HomeAssistant, host: str, api_key: str) -> None:
+ """Initialize the Pterodactyl API."""
+ self.hass = hass
+ self.host = host
+ self.api_key = api_key
+ self.pterodactyl = None
+ self.identifiers = []
+
+ def get_game_servers(self) -> list[str]:
+ """Get all game servers."""
+ paginated_response = self.pterodactyl.client.servers.list_servers() # type: ignore[union-attr]
+
+ return paginated_response.collect()
+
+ async def async_init(self):
+ """Initialize the Pterodactyl API."""
+ self.pterodactyl = PterodactylClient(self.host, self.api_key)
+
+ try:
+ game_servers = await self.hass.async_add_executor_job(self.get_game_servers)
+ except (
+ BadRequestError,
+ PterodactylApiError,
+ ConnectionError,
+ StopIteration,
+ ) as error:
+ raise PterodactylConnectionError(error) from error
+ except HTTPError as error:
+ if error.response.status_code == 401:
+ raise PterodactylAuthorizationError(error) from error
+
+ raise PterodactylConnectionError(error) from error
+ else:
+ for game_server in game_servers:
+ self.identifiers.append(game_server["attributes"]["identifier"])
+
+ _LOGGER.debug("Identifiers of Pterodactyl servers: %s", self.identifiers)
+
+ def get_server_data(self, identifier: str) -> tuple[dict, dict]:
+ """Get all data from the Pterodactyl server."""
+ server = self.pterodactyl.client.servers.get_server(identifier) # type: ignore[union-attr]
+ utilization = self.pterodactyl.client.servers.get_server_utilization( # type: ignore[union-attr]
+ identifier
+ )
+
+ return server, utilization
+
+ async def async_get_data(self) -> dict[str, PterodactylData]:
+ """Update the data from all Pterodactyl servers."""
+ data = {}
+
+ for identifier in self.identifiers:
+ try:
+ server, utilization = await self.hass.async_add_executor_job(
+ self.get_server_data, identifier
+ )
+ except (BadRequestError, PterodactylApiError, ConnectionError) as error:
+ raise PterodactylConnectionError(error) from error
+ except HTTPError as error:
+ if error.response.status_code == 401:
+ raise PterodactylAuthorizationError(error) from error
+
+ raise PterodactylConnectionError(error) from error
+ else:
+ data[identifier] = PterodactylData(
+ name=server["name"],
+ uuid=server["uuid"],
+ identifier=identifier,
+ state=utilization["current_state"],
+ cpu_utilization=utilization["resources"]["cpu_absolute"],
+ cpu_limit=server["limits"]["cpu"],
+ memory_usage=utilization["resources"]["memory_bytes"],
+ memory_limit=server["limits"]["memory"],
+ disk_usage=utilization["resources"]["disk_bytes"],
+ disk_limit=server["limits"]["disk"],
+ network_inbound=utilization["resources"]["network_rx_bytes"],
+ network_outbound=utilization["resources"]["network_tx_bytes"],
+ uptime=utilization["resources"]["uptime"],
+ )
+
+ _LOGGER.debug("%s", data[identifier])
+
+ return data
+
+ async def async_send_command(
+ self, identifier: str, command: PterodactylCommand
+ ) -> None:
+ """Send a command to the Pterodactyl server."""
+ try:
+ await self.hass.async_add_executor_job(
+ self.pterodactyl.client.servers.send_power_action, # type: ignore[union-attr]
+ identifier,
+ command,
+ )
+ except (BadRequestError, PterodactylApiError, ConnectionError) as error:
+ raise PterodactylConnectionError(error) from error
+ except HTTPError as error:
+ if error.response.status_code == 401:
+ raise PterodactylAuthorizationError(error) from error
+
+ raise PterodactylConnectionError(error) from error
diff --git a/homeassistant/components/pterodactyl/binary_sensor.py b/homeassistant/components/pterodactyl/binary_sensor.py
new file mode 100644
index 00000000000..e3615c47499
--- /dev/null
+++ b/homeassistant/components/pterodactyl/binary_sensor.py
@@ -0,0 +1,64 @@
+"""Binary sensor platform of the Pterodactyl integration."""
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import PterodactylConfigEntry, PterodactylCoordinator
+from .entity import PterodactylEntity
+
+KEY_STATUS = "status"
+
+
+BINARY_SENSOR_DESCRIPTIONS = [
+ BinarySensorEntityDescription(
+ key=KEY_STATUS,
+ translation_key=KEY_STATUS,
+ device_class=BinarySensorDeviceClass.RUNNING,
+ ),
+]
+
+# Coordinator is used to centralize the data updates.
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: PterodactylConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Pterodactyl binary sensor platform."""
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ PterodactylBinarySensorEntity(
+ coordinator, identifier, description, config_entry
+ )
+ for identifier in coordinator.api.identifiers
+ for description in BINARY_SENSOR_DESCRIPTIONS
+ )
+
+
+class PterodactylBinarySensorEntity(PterodactylEntity, BinarySensorEntity):
+ """Representation of a Pterodactyl binary sensor base entity."""
+
+ def __init__(
+ self,
+ coordinator: PterodactylCoordinator,
+ identifier: str,
+ description: BinarySensorEntityDescription,
+ config_entry: PterodactylConfigEntry,
+ ) -> None:
+ """Initialize binary sensor base entity."""
+ super().__init__(coordinator, identifier, config_entry)
+ self.entity_description = description
+ self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}"
+
+ @property
+ def is_on(self) -> bool:
+ """Return binary sensor state."""
+ return self.game_server_data.state == "running"
diff --git a/homeassistant/components/pterodactyl/button.py b/homeassistant/components/pterodactyl/button.py
new file mode 100644
index 00000000000..44d3a6d0a82
--- /dev/null
+++ b/homeassistant/components/pterodactyl/button.py
@@ -0,0 +1,106 @@
+"""Button platform for the Pterodactyl integration."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+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 .api import (
+ PterodactylAuthorizationError,
+ PterodactylCommand,
+ PterodactylConnectionError,
+)
+from .coordinator import PterodactylConfigEntry, PterodactylCoordinator
+from .entity import PterodactylEntity
+
+KEY_START_SERVER = "start_server"
+KEY_STOP_SERVER = "stop_server"
+KEY_RESTART_SERVER = "restart_server"
+KEY_FORCE_STOP_SERVER = "force_stop_server"
+
+# Coordinator is used to centralize the data updates.
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class PterodactylButtonEntityDescription(ButtonEntityDescription):
+ """Class describing Pterodactyl button entities."""
+
+ command: PterodactylCommand
+
+
+BUTTON_DESCRIPTIONS = [
+ PterodactylButtonEntityDescription(
+ key=KEY_START_SERVER,
+ translation_key=KEY_START_SERVER,
+ command=PterodactylCommand.START_SERVER,
+ ),
+ PterodactylButtonEntityDescription(
+ key=KEY_STOP_SERVER,
+ translation_key=KEY_STOP_SERVER,
+ command=PterodactylCommand.STOP_SERVER,
+ ),
+ PterodactylButtonEntityDescription(
+ key=KEY_RESTART_SERVER,
+ translation_key=KEY_RESTART_SERVER,
+ command=PterodactylCommand.RESTART_SERVER,
+ ),
+ PterodactylButtonEntityDescription(
+ key=KEY_FORCE_STOP_SERVER,
+ translation_key=KEY_FORCE_STOP_SERVER,
+ command=PterodactylCommand.FORCE_STOP_SERVER,
+ entity_registry_enabled_default=False,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: PterodactylConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Pterodactyl button platform."""
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ PterodactylButtonEntity(coordinator, identifier, description, config_entry)
+ for identifier in coordinator.api.identifiers
+ for description in BUTTON_DESCRIPTIONS
+ )
+
+
+class PterodactylButtonEntity(PterodactylEntity, ButtonEntity):
+ """Representation of a Pterodactyl button entity."""
+
+ entity_description: PterodactylButtonEntityDescription
+
+ def __init__(
+ self,
+ coordinator: PterodactylCoordinator,
+ identifier: str,
+ description: PterodactylButtonEntityDescription,
+ config_entry: PterodactylConfigEntry,
+ ) -> None:
+ """Initialize the button entity."""
+ super().__init__(coordinator, identifier, config_entry)
+ self.entity_description = description
+ self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}"
+
+ async def async_press(self) -> None:
+ """Handle the button press."""
+ try:
+ await self.coordinator.api.async_send_command(
+ self.identifier, self.entity_description.command
+ )
+ except PterodactylConnectionError as err:
+ raise HomeAssistantError(
+ f"Failed to send action '{self.entity_description.key}': Connection error"
+ ) from err
+ except PterodactylAuthorizationError as err:
+ raise HomeAssistantError(
+ f"Failed to send action '{self.entity_description.key}': Unauthorized"
+ ) from err
diff --git a/homeassistant/components/pterodactyl/config_flow.py b/homeassistant/components/pterodactyl/config_flow.py
new file mode 100644
index 00000000000..db03c89f95e
--- /dev/null
+++ b/homeassistant/components/pterodactyl/config_flow.py
@@ -0,0 +1,110 @@
+"""Config flow for the Pterodactyl integration."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+import logging
+from typing import Any
+
+import voluptuous as vol
+from yarl import URL
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_API_KEY, CONF_URL
+
+from .api import (
+ PterodactylAPI,
+ PterodactylAuthorizationError,
+ PterodactylConnectionError,
+)
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_URL = "http://localhost:8080"
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_URL, default=DEFAULT_URL): str,
+ vol.Required(CONF_API_KEY): str,
+ }
+)
+
+STEP_REAUTH_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_API_KEY): str,
+ }
+)
+
+
+class PterodactylConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Pterodactyl."""
+
+ VERSION = 1
+
+ async def async_validate_connection(self, url: str, api_key: str) -> dict[str, str]:
+ """Validate the connection to the Pterodactyl server."""
+ errors: dict[str, str] = {}
+ api = PterodactylAPI(self.hass, url, api_key)
+
+ try:
+ await api.async_init()
+ except PterodactylAuthorizationError:
+ errors["base"] = "invalid_auth"
+ except PterodactylConnectionError:
+ errors["base"] = "cannot_connect"
+ except Exception:
+ _LOGGER.exception("Unexpected exception occurred during config flow")
+ errors["base"] = "unknown"
+
+ return errors
+
+ 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:
+ url = URL(user_input[CONF_URL]).human_repr()
+ api_key = user_input[CONF_API_KEY]
+
+ self._async_abort_entries_match({CONF_URL: url})
+ errors = await self.async_validate_connection(url, api_key)
+
+ if not errors:
+ return self.async_create_entry(title=url, data=user_input)
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Perform re-authentication on an API authentication error."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: Mapping[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Dialog that informs the user that re-authentication is required."""
+ errors: dict[str, str] = {}
+
+ if user_input is not None:
+ reauth_entry = self._get_reauth_entry()
+ url = reauth_entry.data[CONF_URL]
+ api_key = user_input[CONF_API_KEY]
+
+ errors = await self.async_validate_connection(url, api_key)
+
+ if not errors:
+ return self.async_update_reload_and_abort(
+ reauth_entry, data_updates=user_input
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=STEP_REAUTH_DATA_SCHEMA,
+ errors=errors,
+ )
diff --git a/homeassistant/components/pterodactyl/const.py b/homeassistant/components/pterodactyl/const.py
new file mode 100644
index 00000000000..8cf4d0c3963
--- /dev/null
+++ b/homeassistant/components/pterodactyl/const.py
@@ -0,0 +1,3 @@
+"""Constants for the Pterodactyl integration."""
+
+DOMAIN = "pterodactyl"
diff --git a/homeassistant/components/pterodactyl/coordinator.py b/homeassistant/components/pterodactyl/coordinator.py
new file mode 100644
index 00000000000..6d644e96e4c
--- /dev/null
+++ b/homeassistant/components/pterodactyl/coordinator.py
@@ -0,0 +1,71 @@
+"""Data update coordinator of the Pterodactyl integration."""
+
+from __future__ import annotations
+
+from datetime import timedelta
+import logging
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_API_KEY, CONF_URL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .api import (
+ PterodactylAPI,
+ PterodactylAuthorizationError,
+ PterodactylConnectionError,
+ PterodactylData,
+)
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+_LOGGER = logging.getLogger(__name__)
+
+type PterodactylConfigEntry = ConfigEntry[PterodactylCoordinator]
+
+
+class PterodactylCoordinator(DataUpdateCoordinator[dict[str, PterodactylData]]):
+ """Pterodactyl data update coordinator."""
+
+ config_entry: PterodactylConfigEntry
+ api: PterodactylAPI
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: PterodactylConfigEntry,
+ ) -> None:
+ """Initialize coordinator instance."""
+
+ super().__init__(
+ hass=hass,
+ name=config_entry.data[CONF_URL],
+ config_entry=config_entry,
+ logger=_LOGGER,
+ update_interval=SCAN_INTERVAL,
+ )
+
+ async def _async_setup(self) -> None:
+ """Set up the Pterodactyl data coordinator."""
+ self.api = PterodactylAPI(
+ hass=self.hass,
+ host=self.config_entry.data[CONF_URL],
+ api_key=self.config_entry.data[CONF_API_KEY],
+ )
+
+ try:
+ await self.api.async_init()
+ except PterodactylConnectionError as error:
+ raise UpdateFailed(error) from error
+ except PterodactylAuthorizationError as error:
+ raise ConfigEntryAuthFailed(error) from error
+
+ async def _async_update_data(self) -> dict[str, PterodactylData]:
+ """Get updated data from the Pterodactyl server."""
+ try:
+ return await self.api.async_get_data()
+ except PterodactylConnectionError as error:
+ raise UpdateFailed(error) from error
+ except PterodactylAuthorizationError as error:
+ raise ConfigEntryAuthFailed(error) from error
diff --git a/homeassistant/components/pterodactyl/entity.py b/homeassistant/components/pterodactyl/entity.py
new file mode 100644
index 00000000000..49fd65af476
--- /dev/null
+++ b/homeassistant/components/pterodactyl/entity.py
@@ -0,0 +1,47 @@
+"""Base entity for the Pterodactyl integration."""
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_URL
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .api import PterodactylData
+from .const import DOMAIN
+from .coordinator import PterodactylCoordinator
+
+MANUFACTURER = "Pterodactyl"
+
+
+class PterodactylEntity(CoordinatorEntity[PterodactylCoordinator]):
+ """Representation of a Pterodactyl base entity."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: PterodactylCoordinator,
+ identifier: str,
+ config_entry: ConfigEntry,
+ ) -> None:
+ """Initialize base entity."""
+ super().__init__(coordinator)
+
+ self.identifier = identifier
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, identifier)},
+ manufacturer=MANUFACTURER,
+ name=self.game_server_data.name,
+ model=self.game_server_data.name,
+ model_id=self.game_server_data.uuid,
+ configuration_url=f"{config_entry.data[CONF_URL]}/server/{identifier}",
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return binary sensor availability."""
+ return super().available and self.identifier in self.coordinator.data
+
+ @property
+ def game_server_data(self) -> PterodactylData:
+ """Return game server data."""
+ return self.coordinator.data[self.identifier]
diff --git a/homeassistant/components/pterodactyl/icons.json b/homeassistant/components/pterodactyl/icons.json
new file mode 100644
index 00000000000..265a8dcadda
--- /dev/null
+++ b/homeassistant/components/pterodactyl/icons.json
@@ -0,0 +1,47 @@
+{
+ "entity": {
+ "button": {
+ "start_server": {
+ "default": "mdi:play"
+ },
+ "stop_server": {
+ "default": "mdi:stop"
+ },
+ "restart_server": {
+ "default": "mdi:refresh"
+ },
+ "force_stop_server": {
+ "default": "mdi:flash-alert"
+ }
+ },
+ "sensor": {
+ "cpu_utilization": {
+ "default": "mdi:cpu-64-bit"
+ },
+ "cpu_limit": {
+ "default": "mdi:cpu-64-bit"
+ },
+ "memory_usage": {
+ "default": "mdi:memory"
+ },
+ "memory_limit": {
+ "default": "mdi:memory"
+ },
+ "disk_usage": {
+ "default": "mdi:harddisk"
+ },
+ "disk_limit": {
+ "default": "mdi:harddisk"
+ },
+ "network_inbound": {
+ "default": "mdi:download"
+ },
+ "network_outbound": {
+ "default": "mdi:upload"
+ },
+ "uptime": {
+ "default": "mdi:timer"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/pterodactyl/manifest.json b/homeassistant/components/pterodactyl/manifest.json
new file mode 100644
index 00000000000..8ffa21dd186
--- /dev/null
+++ b/homeassistant/components/pterodactyl/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "pterodactyl",
+ "name": "Pterodactyl",
+ "codeowners": ["@elmurato"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/pterodactyl",
+ "iot_class": "local_polling",
+ "quality_scale": "bronze",
+ "requirements": ["py-dactyl==2.0.4"]
+}
diff --git a/homeassistant/components/pterodactyl/quality_scale.yaml b/homeassistant/components/pterodactyl/quality_scale.yaml
new file mode 100644
index 00000000000..80ebb3fc7e3
--- /dev/null
+++ b/homeassistant/components/pterodactyl/quality_scale.yaml
@@ -0,0 +1,93 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration doesn't provide any service actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow: done
+ config-flow-test-coverage: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: Integration doesn't provide any service actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: done
+ comment: Handled by coordinator.
+ entity-unique-id:
+ status: done
+ comment: Using confid entry ID as the dependency pydactyl doesn't provide a unique information.
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup:
+ status: done
+ comment: |
+ Raising ConfigEntryNotReady, if the initialization isn't successful.
+ unique-config-entry:
+ status: done
+ comment: |
+ As there is no unique information available from the dependency pydactyl,
+ the server host is used to identify that the same service is already configured.
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: Integration doesn't provide any service actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: Integration doesn't support any configuration parameters.
+ docs-installation-parameters: todo
+ entity-unavailable:
+ status: done
+ comment: Handled by coordinator.
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: Handled by coordinator.
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery:
+ status: exempt
+ comment: No discovery possible.
+ discovery-update-info:
+ status: exempt
+ comment: |
+ No discovery possible. Users can use the (local or public) hostname instead of an IP address,
+ if static IP addresses cannot be configured.
+ 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: done
+ entity-device-class: done
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: No repair use-cases for this integration.
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession:
+ status: exempt
+ comment: Integration isn't making any HTTP requests.
+ strict-typing: todo
diff --git a/homeassistant/components/pterodactyl/sensor.py b/homeassistant/components/pterodactyl/sensor.py
new file mode 100644
index 00000000000..646b429cd08
--- /dev/null
+++ b/homeassistant/components/pterodactyl/sensor.py
@@ -0,0 +1,183 @@
+"""Sensor platform of the Pterodactyl integration."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+from homeassistant.util import dt as dt_util
+
+from .coordinator import PterodactylConfigEntry, PterodactylCoordinator, PterodactylData
+from .entity import PterodactylEntity
+
+KEY_CPU_UTILIZATION = "cpu_utilization"
+KEY_CPU_LIMIT = "cpu_limit"
+KEY_MEMORY_USAGE = "memory_usage"
+KEY_MEMORY_LIMIT = "memory_limit"
+KEY_DISK_USAGE = "disk_usage"
+KEY_DISK_LIMIT = "disk_limit"
+KEY_NETWORK_INBOUND = "network_inbound"
+KEY_NETWORK_OUTBOUND = "network_outbound"
+KEY_UPTIME = "uptime"
+
+# Coordinator is used to centralize the data updates.
+PARALLEL_UPDATES = 0
+
+
+@dataclass(frozen=True, kw_only=True)
+class PterodactylSensorEntityDescription(SensorEntityDescription):
+ """Class describing Pterodactyl sensor entities."""
+
+ value_fn: Callable[[PterodactylData], StateType | datetime]
+
+
+SENSOR_DESCRIPTIONS = [
+ PterodactylSensorEntityDescription(
+ key=KEY_CPU_UTILIZATION,
+ translation_key=KEY_CPU_UTILIZATION,
+ value_fn=lambda data: data.cpu_utilization,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=0,
+ ),
+ PterodactylSensorEntityDescription(
+ key=KEY_CPU_LIMIT,
+ translation_key=KEY_CPU_LIMIT,
+ value_fn=lambda data: data.cpu_limit,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=0,
+ entity_registry_enabled_default=False,
+ ),
+ PterodactylSensorEntityDescription(
+ key=KEY_MEMORY_USAGE,
+ translation_key=KEY_MEMORY_USAGE,
+ value_fn=lambda data: data.memory_usage,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
+ suggested_display_precision=1,
+ ),
+ PterodactylSensorEntityDescription(
+ key=KEY_MEMORY_LIMIT,
+ translation_key=KEY_MEMORY_LIMIT,
+ value_fn=lambda data: data.memory_limit,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfInformation.MEGABYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
+ suggested_display_precision=1,
+ entity_registry_enabled_default=False,
+ ),
+ PterodactylSensorEntityDescription(
+ key=KEY_DISK_USAGE,
+ translation_key=KEY_DISK_USAGE,
+ value_fn=lambda data: data.disk_usage,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
+ suggested_display_precision=1,
+ ),
+ PterodactylSensorEntityDescription(
+ key=KEY_DISK_LIMIT,
+ translation_key=KEY_DISK_LIMIT,
+ value_fn=lambda data: data.disk_limit,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfInformation.MEGABYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
+ suggested_display_precision=1,
+ entity_registry_enabled_default=False,
+ ),
+ PterodactylSensorEntityDescription(
+ key=KEY_NETWORK_INBOUND,
+ translation_key=KEY_NETWORK_INBOUND,
+ value_fn=lambda data: data.network_inbound,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
+ suggested_display_precision=1,
+ entity_registry_enabled_default=False,
+ ),
+ PterodactylSensorEntityDescription(
+ key=KEY_NETWORK_OUTBOUND,
+ translation_key=KEY_NETWORK_OUTBOUND,
+ value_fn=lambda data: data.network_outbound,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
+ suggested_display_precision=1,
+ entity_registry_enabled_default=False,
+ ),
+ PterodactylSensorEntityDescription(
+ key=KEY_UPTIME,
+ translation_key=KEY_UPTIME,
+ value_fn=(
+ lambda data: dt_util.utcnow() - timedelta(milliseconds=data.uptime)
+ if data.uptime > 0
+ else None
+ ),
+ device_class=SensorDeviceClass.TIMESTAMP,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: PterodactylConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Pterodactyl sensor platform."""
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ PterodactylSensorEntity(coordinator, identifier, description, config_entry)
+ for identifier in coordinator.api.identifiers
+ for description in SENSOR_DESCRIPTIONS
+ )
+
+
+class PterodactylSensorEntity(PterodactylEntity, SensorEntity):
+ """Representation of a Pterodactyl sensor base entity."""
+
+ entity_description: PterodactylSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator: PterodactylCoordinator,
+ identifier: str,
+ description: PterodactylSensorEntityDescription,
+ config_entry: PterodactylConfigEntry,
+ ) -> None:
+ """Initialize sensor base entity."""
+ super().__init__(coordinator, identifier, config_entry)
+ self.entity_description = description
+ self._attr_unique_id = f"{self.game_server_data.uuid}_{description.key}"
+
+ @property
+ def native_value(self) -> StateType | datetime:
+ """Return native value of sensor."""
+ return self.entity_description.value_fn(self.game_server_data)
diff --git a/homeassistant/components/pterodactyl/strings.json b/homeassistant/components/pterodactyl/strings.json
new file mode 100644
index 00000000000..3d01700f189
--- /dev/null
+++ b/homeassistant/components/pterodactyl/strings.json
@@ -0,0 +1,85 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]",
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "url": "The URL of your Pterodactyl server, including the protocol (http:// or https://) and optionally the port number.",
+ "api_key": "The account API key for accessing your Pterodactyl server."
+ }
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "Please update your account API key.",
+ "data": {
+ "api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "[%key:component::pterodactyl::config::step::user::data_description::api_key%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ }
+ },
+ "entity": {
+ "binary_sensor": {
+ "status": {
+ "name": "Status"
+ }
+ },
+ "button": {
+ "start_server": {
+ "name": "Start server"
+ },
+ "stop_server": {
+ "name": "Stop server"
+ },
+ "restart_server": {
+ "name": "Restart server"
+ },
+ "force_stop_server": {
+ "name": "Force stop server"
+ }
+ },
+ "sensor": {
+ "cpu_utilization": {
+ "name": "CPU utilization"
+ },
+ "cpu_limit": {
+ "name": "CPU limit"
+ },
+ "memory_usage": {
+ "name": "Memory usage"
+ },
+ "memory_limit": {
+ "name": "Memory limit"
+ },
+ "disk_usage": {
+ "name": "Disk usage"
+ },
+ "disk_limit": {
+ "name": "Disk limit"
+ },
+ "network_inbound": {
+ "name": "Network inbound"
+ },
+ "network_outbound": {
+ "name": "Network outbound"
+ },
+ "uptime": {
+ "name": "Uptime"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/pure_energie/sensor.py b/homeassistant/components/pure_energie/sensor.py
index 9dd234ac2f6..ad57206adeb 100644
--- a/homeassistant/components/pure_energie/sensor.py
+++ b/homeassistant/components/pure_energie/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import CONF_HOST, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -64,7 +64,7 @@ SENSORS: tuple[PureEnergieSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PureEnergieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Pure Energie Sensors based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py
index 2d4022946b2..78986b34351 100644
--- a/homeassistant/components/purpleair/__init__.py
+++ b/homeassistant/components/purpleair/__init__.py
@@ -2,37 +2,34 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import PurpleAirDataUpdateCoordinator
-
-PLATFORMS = [Platform.SENSOR]
+from .const import PLATFORMS
+from .coordinator import PurpleAirConfigEntry, PurpleAirDataUpdateCoordinator
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Set up PurpleAir from a config entry."""
- coordinator = PurpleAirDataUpdateCoordinator(hass, entry)
+async def async_setup_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool:
+ """Set up PurpleAir config entry."""
+ coordinator = PurpleAirDataUpdateCoordinator(
+ hass,
+ entry,
+ )
+ entry.runtime_data = coordinator
+
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(async_handle_entry_update))
+ entry.async_on_unload(entry.add_update_listener(async_reload_entry))
return True
-async def async_handle_entry_update(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Handle an options update."""
+async def async_reload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None:
+ """Reload config entry."""
await hass.config_entries.async_reload(entry.entry_id)
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+async def async_unload_entry(hass: HomeAssistant, entry: PurpleAirConfigEntry) -> bool:
+ """Unload config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/purpleair/const.py b/homeassistant/components/purpleair/const.py
index 5f1ec84d469..fcb928bd4f3 100644
--- a/homeassistant/components/purpleair/const.py
+++ b/homeassistant/components/purpleair/const.py
@@ -1,10 +1,13 @@
"""Constants for the PurpleAir integration."""
import logging
+from typing import Final
-DOMAIN = "purpleair"
+from homeassistant.const import Platform
-LOGGER = logging.getLogger(__package__)
+LOGGER: Final = logging.getLogger(__package__)
+PLATFORMS: Final = [Platform.SENSOR]
-CONF_READ_KEY = "read_key"
-CONF_SENSOR_INDICES = "sensor_indices"
+DOMAIN: Final[str] = "purpleair"
+
+CONF_SENSOR_INDICES: Final[str] = "sensor_indices"
diff --git a/homeassistant/components/purpleair/coordinator.py b/homeassistant/components/purpleair/coordinator.py
index f1511733cfa..4ed0c0340c6 100644
--- a/homeassistant/components/purpleair/coordinator.py
+++ b/homeassistant/components/purpleair/coordinator.py
@@ -46,12 +46,15 @@ SENSOR_FIELDS_TO_RETRIEVE = [
UPDATE_INTERVAL = timedelta(minutes=2)
+type PurpleAirConfigEntry = ConfigEntry[PurpleAirDataUpdateCoordinator]
+
+
class PurpleAirDataUpdateCoordinator(DataUpdateCoordinator[GetSensorsResponse]):
"""Define a PurpleAir-specific coordinator."""
- config_entry: ConfigEntry
+ config_entry: PurpleAirConfigEntry
- def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, entry: PurpleAirConfigEntry) -> None:
"""Initialize."""
self._api = API(
entry.data[CONF_API_KEY],
diff --git a/homeassistant/components/purpleair/diagnostics.py b/homeassistant/components/purpleair/diagnostics.py
index f7c44b7e9b2..71b83e277d3 100644
--- a/homeassistant/components/purpleair/diagnostics.py
+++ b/homeassistant/components/purpleair/diagnostics.py
@@ -5,7 +5,6 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
@@ -14,8 +13,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import PurpleAirDataUpdateCoordinator
+from .coordinator import PurpleAirConfigEntry
CONF_TITLE = "title"
@@ -30,14 +28,13 @@ TO_REDACT = {
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: PurpleAirConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: PurpleAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
return async_redact_data(
{
"entry": entry.as_dict(),
- "data": coordinator.data.model_dump(),
+ "data": entry.runtime_data.data.model_dump(),
},
TO_REDACT,
)
diff --git a/homeassistant/components/purpleair/entity.py b/homeassistant/components/purpleair/entity.py
index 4f7be1874ed..410fdd9b942 100644
--- a/homeassistant/components/purpleair/entity.py
+++ b/homeassistant/components/purpleair/entity.py
@@ -7,13 +7,12 @@ from typing import Any
from aiopurpleair.models.sensors import SensorModel
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
-from .coordinator import PurpleAirDataUpdateCoordinator
+from .coordinator import PurpleAirConfigEntry, PurpleAirDataUpdateCoordinator
class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]):
@@ -23,12 +22,11 @@ class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]):
def __init__(
self,
- coordinator: PurpleAirDataUpdateCoordinator,
- entry: ConfigEntry,
+ entry: PurpleAirConfigEntry,
sensor_index: int,
) -> None:
"""Initialize."""
- super().__init__(coordinator)
+ super().__init__(entry.runtime_data)
self._sensor_index = sensor_index
diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py
index 9fb0249a360..a85a23b6144 100644
--- a/homeassistant/components/purpleair/sensor.py
+++ b/homeassistant/components/purpleair/sensor.py
@@ -13,7 +13,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
PERCENTAGE,
@@ -25,10 +24,10 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import CONF_SENSOR_INDICES, DOMAIN
-from .coordinator import PurpleAirDataUpdateCoordinator
+from .const import CONF_SENSOR_INDICES
+from .coordinator import PurpleAirConfigEntry
from .entity import PurpleAirEntity
CONCENTRATION_PARTICLES_PER_100_MILLILITERS = f"particles/100{UnitOfVolume.MILLILITERS}"
@@ -165,13 +164,12 @@ SENSOR_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: PurpleAirConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up PurpleAir sensors based on a config entry."""
- coordinator: PurpleAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
- PurpleAirSensorEntity(coordinator, entry, sensor_index, description)
+ PurpleAirSensorEntity(entry, sensor_index, description)
for sensor_index in entry.options[CONF_SENSOR_INDICES]
for description in SENSOR_DESCRIPTIONS
)
@@ -184,13 +182,12 @@ class PurpleAirSensorEntity(PurpleAirEntity, SensorEntity):
def __init__(
self,
- coordinator: PurpleAirDataUpdateCoordinator,
- entry: ConfigEntry,
+ entry: PurpleAirConfigEntry,
sensor_index: int,
description: PurpleAirSensorEntityDescription,
) -> None:
"""Initialize."""
- super().__init__(coordinator, entry, sensor_index)
+ super().__init__(entry, sensor_index)
self._attr_unique_id = f"{self._sensor_index}-{description.key}"
self.entity_description = description
diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py
index 4989fc91d5e..2dbaa8fc713 100644
--- a/homeassistant/components/pushbullet/sensor.py
+++ b/homeassistant/components/pushbullet/sensor.py
@@ -8,7 +8,7 @@ from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .api import PushBulletNotificationProvider
from .const import DATA_UPDATED, DOMAIN
@@ -68,7 +68,9 @@ SENSOR_KEYS: list[str] = [desc.key for desc in SENSOR_TYPES]
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Pushbullet sensors from config entry."""
diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json
index 9dbdad53bcb..dee5f9cda6e 100644
--- a/homeassistant/components/pvoutput/manifest.json
+++ b/homeassistant/components/pvoutput/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/pvoutput",
"integration_type": "device",
"iot_class": "cloud_polling",
- "requirements": ["pvo==2.2.0"]
+ "requirements": ["pvo==2.2.1"]
}
diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py
index ef2bb3eb660..b4ed3f93945 100644
--- a/homeassistant/components/pvoutput/sensor.py
+++ b/homeassistant/components/pvoutput/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_SYSTEM_ID, DOMAIN
@@ -98,7 +98,7 @@ SENSORS: tuple[PVOutputSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a PVOutput sensors based on a config entry."""
coordinator: PVOutputDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/pvoutput/strings.json b/homeassistant/components/pvoutput/strings.json
index 06d98971053..651bb55a2b4 100644
--- a/homeassistant/components/pvoutput/strings.json
+++ b/homeassistant/components/pvoutput/strings.json
@@ -27,19 +27,19 @@
"entity": {
"sensor": {
"energy_consumption": {
- "name": "Energy consumed"
+ "name": "Energy consumption"
},
"energy_generation": {
- "name": "Energy generated"
+ "name": "Energy generation"
},
"efficiency": {
"name": "Efficiency"
},
"power_consumption": {
- "name": "Power consumed"
+ "name": "Power consumption"
},
"power_generation": {
- "name": "Power generated"
+ "name": "Power generation"
}
}
}
diff --git a/homeassistant/components/pvpc_hourly_pricing/sensor.py b/homeassistant/components/pvpc_hourly_pricing/sensor.py
index 9d9fe5b9661..1b92cfc533d 100644
--- a/homeassistant/components/pvpc_hourly_pricing/sensor.py
+++ b/homeassistant/components/pvpc_hourly_pricing/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CURRENCY_EURO, UnitOfEnergy
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_change
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -148,7 +148,9 @@ _PRICE_SENSOR_ATTRIBUTES_MAP = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the electricity price sensor from config_entry."""
coordinator: ElecPricesDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py
index 3dd2fd9b2ba..ca7bbb0c1dc 100644
--- a/homeassistant/components/pyload/__init__.py
+++ b/homeassistant/components/pyload/__init__.py
@@ -2,37 +2,35 @@
from __future__ import annotations
+import logging
+
from aiohttp import CookieJar
-from pyloadapi.api import PyLoadAPI
-from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
+from pyloadapi import PyLoadAPI
+from yarl import URL
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_SSL,
+ CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
-from .const import DOMAIN
from .coordinator import PyLoadConfigEntry, PyLoadCoordinator
+_LOGGER = logging.getLogger(__name__)
+
PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool:
"""Set up pyLoad from a config entry."""
- url = (
- f"{'https' if entry.data[CONF_SSL] else 'http'}://"
- f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}/"
- )
-
session = async_create_clientsession(
hass,
verify_ssl=entry.data[CONF_VERIFY_SSL],
@@ -40,29 +38,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo
)
pyloadapi = PyLoadAPI(
session,
- api_url=url,
+ api_url=URL(entry.data[CONF_URL]),
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
)
- try:
- await pyloadapi.login()
- except CannotConnect as e:
- raise ConfigEntryNotReady(
- translation_domain=DOMAIN,
- translation_key="setup_request_exception",
- ) from e
- except ParserError as e:
- raise ConfigEntryNotReady(
- translation_domain=DOMAIN,
- translation_key="setup_parse_exception",
- ) from e
- except InvalidAuth as e:
- raise ConfigEntryAuthFailed(
- translation_domain=DOMAIN,
- translation_key="setup_authentication_exception",
- translation_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]},
- ) from e
coordinator = PyLoadCoordinator(hass, entry, pyloadapi)
await coordinator.async_config_entry_first_refresh()
@@ -76,3 +56,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bo
async def async_unload_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+
+
+async def async_migrate_entry(hass: HomeAssistant, entry: PyLoadConfigEntry) -> bool:
+ """Migrate config entry."""
+ _LOGGER.debug(
+ "Migrating configuration from version %s.%s", entry.version, entry.minor_version
+ )
+
+ if entry.version == 1 and entry.minor_version == 0:
+ url = URL.build(
+ scheme="https" if entry.data[CONF_SSL] else "http",
+ host=entry.data[CONF_HOST],
+ port=entry.data[CONF_PORT],
+ ).human_repr()
+ hass.config_entries.async_update_entry(
+ entry, data={**entry.data, CONF_URL: url}, minor_version=1, version=1
+ )
+
+ _LOGGER.debug(
+ "Migration to configuration version %s.%s successful",
+ entry.version,
+ entry.minor_version,
+ )
+ return True
diff --git a/homeassistant/components/pyload/button.py b/homeassistant/components/pyload/button.py
index f849200a70e..5ee10a327d1 100644
--- a/homeassistant/components/pyload/button.py
+++ b/homeassistant/components/pyload/button.py
@@ -7,17 +7,19 @@ from dataclasses import dataclass
from enum import StrEnum
from typing import Any
-from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI
+from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import PyLoadConfigEntry
from .entity import BasePyLoadEntity
+PARALLEL_UPDATES = 1
+
@dataclass(kw_only=True, frozen=True)
class PyLoadButtonEntityDescription(ButtonEntityDescription):
@@ -63,7 +65,7 @@ SENSOR_DESCRIPTIONS: tuple[PyLoadButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PyLoadConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up buttons from a config entry."""
diff --git a/homeassistant/components/pyload/config_flow.py b/homeassistant/components/pyload/config_flow.py
index b9bfc579cfc..50d354d345d 100644
--- a/homeassistant/components/pyload/config_flow.py
+++ b/homeassistant/components/pyload/config_flow.py
@@ -7,22 +7,19 @@ import logging
from typing import Any
from aiohttp import CookieJar
-from pyloadapi.api import PyLoadAPI
-from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
+from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI
import voluptuous as vol
+from yarl import URL
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import (
- CONF_HOST,
CONF_NAME,
CONF_PASSWORD,
- CONF_PORT,
- CONF_SSL,
+ CONF_URL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.selector import (
TextSelector,
@@ -30,15 +27,18 @@ from homeassistant.helpers.selector import (
TextSelectorType,
)
-from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN
+from .const import DEFAULT_NAME, DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
- vol.Required(CONF_HOST): str,
- vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Required(CONF_SSL, default=False): cv.boolean,
+ vol.Required(CONF_URL): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.URL,
+ autocomplete="url",
+ ),
+ ),
vol.Required(CONF_VERIFY_SSL, default=True): bool,
vol.Required(CONF_USERNAME): TextSelector(
TextSelectorConfig(
@@ -81,14 +81,9 @@ async def validate_input(hass: HomeAssistant, user_input: dict[str, Any]) -> Non
user_input[CONF_VERIFY_SSL],
cookie_jar=CookieJar(unsafe=True),
)
-
- url = (
- f"{'https' if user_input[CONF_SSL] else 'http'}://"
- f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}/"
- )
pyload = PyLoadAPI(
session,
- api_url=url,
+ api_url=URL(user_input[CONF_URL]),
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
)
@@ -100,6 +95,7 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for pyLoad."""
VERSION = 1
+ MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -107,9 +103,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
- self._async_abort_entries_match(
- {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]}
- )
+ url = URL(user_input[CONF_URL]).human_repr()
+ self._async_abort_entries_match({CONF_URL: url})
try:
await validate_input(self.hass, user_input)
except (CannotConnect, ParserError):
@@ -121,7 +116,14 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "unknown"
else:
title = DEFAULT_NAME
- return self.async_create_entry(title=title, data=user_input)
+
+ return self.async_create_entry(
+ title=title,
+ data={
+ **user_input,
+ CONF_URL: url,
+ },
+ )
return self.async_show_form(
step_id="user",
@@ -145,9 +147,8 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN):
reauth_entry = self._get_reauth_entry()
if user_input is not None:
- new_input = reauth_entry.data | user_input
try:
- await validate_input(self.hass, new_input)
+ await validate_input(self.hass, {**reauth_entry.data, **user_input})
except (CannotConnect, ParserError):
errors["base"] = "cannot_connect"
except InvalidAuth:
@@ -156,7 +157,9 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
- return self.async_update_reload_and_abort(reauth_entry, data=new_input)
+ return self.async_update_reload_and_abort(
+ reauth_entry, data_updates=user_input
+ )
return self.async_show_form(
step_id="reauth_confirm",
@@ -192,15 +195,18 @@ class PyLoadConfigFlow(ConfigFlow, domain=DOMAIN):
else:
return self.async_update_reload_and_abort(
reconfig_entry,
- data=user_input,
+ data={
+ **user_input,
+ CONF_URL: URL(user_input[CONF_URL]).human_repr(),
+ },
reload_even_if_entry_is_unchanged=False,
)
-
+ suggested_values = user_input if user_input else reconfig_entry.data
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA,
- user_input or reconfig_entry.data,
+ suggested_values,
),
description_placeholders={CONF_NAME: reconfig_entry.data[CONF_USERNAME]},
errors=errors,
diff --git a/homeassistant/components/pyload/coordinator.py b/homeassistant/components/pyload/coordinator.py
index 8b2db605c94..7bb2b870520 100644
--- a/homeassistant/components/pyload/coordinator.py
+++ b/homeassistant/components/pyload/coordinator.py
@@ -9,7 +9,7 @@ from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -31,6 +31,7 @@ class PyLoadData:
download: bool
reconnect: bool
captcha: bool | None = None
+ proxy: bool | None = None
free_space: int
@@ -59,14 +60,11 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
async def _async_update_data(self) -> PyLoadData:
"""Fetch data from API endpoint."""
try:
- if not self.version:
- self.version = await self.pyload.version()
return PyLoadData(
**await self.pyload.get_status(),
free_space=await self.pyload.free_space(),
)
-
- except InvalidAuth as e:
+ except InvalidAuth:
try:
await self.pyload.login()
except InvalidAuth as exc:
@@ -75,13 +73,42 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
translation_key="setup_authentication_exception",
translation_placeholders={CONF_USERNAME: self.pyload.username},
) from exc
-
- raise UpdateFailed(
- "Unable to retrieve data due to cookie expiration"
- ) from e
+ _LOGGER.debug(
+ "Unable to retrieve data due to cookie expiration, retrying after 20 seconds"
+ )
+ return self.data
except CannotConnect as e:
raise UpdateFailed(
- "Unable to connect and retrieve data from pyLoad API"
+ translation_domain=DOMAIN,
+ translation_key="setup_request_exception",
) from e
except ParserError as e:
- raise UpdateFailed("Unable to parse data from pyLoad API") from e
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="setup_parse_exception",
+ ) from e
+
+ async def _async_setup(self) -> None:
+ """Set up the coordinator."""
+
+ try:
+ await self.pyload.login()
+ self.version = await self.pyload.version()
+ except CannotConnect as e:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="setup_request_exception",
+ ) from e
+ except ParserError as e:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="setup_parse_exception",
+ ) from e
+ except InvalidAuth as e:
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="setup_authentication_exception",
+ translation_placeholders={
+ CONF_USERNAME: self.config_entry.data[CONF_USERNAME]
+ },
+ ) from e
diff --git a/homeassistant/components/pyload/diagnostics.py b/homeassistant/components/pyload/diagnostics.py
index 105a9a953e2..98fab38da1d 100644
--- a/homeassistant/components/pyload/diagnostics.py
+++ b/homeassistant/components/pyload/diagnostics.py
@@ -5,13 +5,15 @@ from __future__ import annotations
from dataclasses import asdict
from typing import Any
-from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from yarl import URL
+
+from homeassistant.components.diagnostics import REDACTED, async_redact_data
+from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .coordinator import PyLoadConfigEntry, PyLoadData
-TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_HOST}
+TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_URL}
async def async_get_config_entry_diagnostics(
@@ -21,6 +23,9 @@ async def async_get_config_entry_diagnostics(
pyload_data: PyLoadData = config_entry.runtime_data.data
return {
- "config_entry_data": async_redact_data(dict(config_entry.data), TO_REDACT),
+ "config_entry_data": {
+ **async_redact_data(dict(config_entry.data), TO_REDACT),
+ CONF_URL: URL(config_entry.data[CONF_URL]).with_host(REDACTED).human_repr(),
+ },
"pyload_data": asdict(pyload_data),
}
diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json
index e21167cf10b..feaa23af7de 100644
--- a/homeassistant/components/pyload/manifest.json
+++ b/homeassistant/components/pyload/manifest.json
@@ -7,5 +7,6 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["pyloadapi"],
- "requirements": ["PyLoadAPI==1.3.2"]
+ "quality_scale": "platinum",
+ "requirements": ["PyLoadAPI==1.4.2"]
}
diff --git a/homeassistant/components/pyload/quality_scale.yaml b/homeassistant/components/pyload/quality_scale.yaml
new file mode 100644
index 00000000000..a9ce552961b
--- /dev/null
+++ b/homeassistant/components/pyload/quality_scale.yaml
@@ -0,0 +1,82 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: The integration registers no 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: The integration registers no actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: The integration registers no 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: The integration registers no actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: Integration has no configuration parameters
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: The integration is a web service, there are no discoverable devices.
+ discovery:
+ status: exempt
+ comment: The integration is a web service, there are no discoverable devices.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices:
+ status: exempt
+ comment: The integration is a web service, there are no devices.
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: The integration is a web service, there are no devices.
+ 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: The integration has no repairs.
+ stale-devices:
+ status: exempt
+ comment: The integration is a web service, there are no devices.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py
index b36dbb806be..7425c543fe1 100644
--- a/homeassistant/components/pyload/sensor.py
+++ b/homeassistant/components/pyload/sensor.py
@@ -14,13 +14,15 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfDataRate, UnitOfInformation
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import UNIT_DOWNLOADS
from .coordinator import PyLoadConfigEntry, PyLoadData
from .entity import BasePyLoadEntity
+PARALLEL_UPDATES = 0
+
class PyLoadSensorEntity(StrEnum):
"""pyLoad Sensor Entities."""
@@ -85,7 +87,7 @@ SENSOR_DESCRIPTIONS: tuple[PyLoadSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PyLoadConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the pyLoad sensors."""
diff --git a/homeassistant/components/pyload/strings.json b/homeassistant/components/pyload/strings.json
index 0fd9b4befcf..9414f7f7bb8 100644
--- a/homeassistant/components/pyload/strings.json
+++ b/homeassistant/components/pyload/strings.json
@@ -3,30 +3,30 @@
"step": {
"user": {
"data": {
- "host": "[%key:common::config_flow::data::host%]",
+ "url": "[%key:common::config_flow::data::url%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
- "ssl": "[%key:common::config_flow::data::ssl%]",
- "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]",
- "port": "[%key:common::config_flow::data::port%]"
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
- "host": "The hostname or IP address of the device running your pyLoad instance.",
- "port": "pyLoad uses port 8000 by default."
+ "url": "Specify the full URL of your pyLoad web interface, including the protocol (HTTP or HTTPS), hostname or IP address, port (pyLoad uses 8000 by default), and any path prefix if applicable.\nExample: `https://example.com:8000/path`",
+ "username": "The username used to access the pyLoad instance.",
+ "password": "The password associated with the pyLoad account.",
+ "verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection."
}
},
"reconfigure": {
"data": {
- "host": "[%key:common::config_flow::data::host%]",
+ "url": "[%key:common::config_flow::data::url%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
- "ssl": "[%key:common::config_flow::data::ssl%]",
- "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]",
- "port": "[%key:common::config_flow::data::port%]"
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
- "host": "The hostname or IP address of the device running your pyLoad instance.",
- "port": "pyLoad uses port 8000 by default."
+ "url": "[%key:component::pyload::config::step::user::data_description::url%]",
+ "username": "[%key:component::pyload::config::step::user::data_description::username%]",
+ "password": "[%key:component::pyload::config::step::user::data_description::password%]",
+ "verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]"
}
},
"reauth_confirm": {
@@ -34,6 +34,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::pyload::config::step::user::data_description::username%]",
+ "password": "[%key:component::pyload::config::step::user::data_description::password%]"
}
}
},
@@ -91,10 +95,10 @@
},
"exceptions": {
"setup_request_exception": {
- "message": "Unable to connect and retrieve data from pyLoad API, try again later"
+ "message": "Unable to connect and retrieve data from pyLoad API"
},
"setup_parse_exception": {
- "message": "Unable to parse data from pyLoad API, try again later"
+ "message": "Unable to parse data from pyLoad API"
},
"setup_authentication_exception": {
"message": "Authentication failed for {username}, verify your login credentials"
diff --git a/homeassistant/components/pyload/switch.py b/homeassistant/components/pyload/switch.py
index 1187e545f25..46a54451b9a 100644
--- a/homeassistant/components/pyload/switch.py
+++ b/homeassistant/components/pyload/switch.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from enum import StrEnum
from typing import Any
-from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI
+from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -16,12 +16,14 @@ from homeassistant.components.switch import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import PyLoadConfigEntry, PyLoadData
from .entity import BasePyLoadEntity
+PARALLEL_UPDATES = 1
+
class PyLoadSwitch(StrEnum):
"""PyLoad Switch Entities."""
@@ -65,7 +67,7 @@ SENSOR_DESCRIPTIONS: tuple[PyLoadSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: PyLoadConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the pyLoad sensors."""
diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json
index bd9897aa6ba..2f813e35557 100644
--- a/homeassistant/components/qbittorrent/manifest.json
+++ b/homeassistant/components/qbittorrent/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["qbittorrent"],
- "requirements": ["qbittorrent-api==2024.2.59"]
+ "requirements": ["qbittorrent-api==2024.9.67"]
}
diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py
index 67eb856bb83..d565d2f7b5f 100644
--- a/homeassistant/components/qbittorrent/sensor.py
+++ b/homeassistant/components/qbittorrent/sensor.py
@@ -14,10 +14,10 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import STATE_IDLE, UnitOfDataRate
+from homeassistant.const import STATE_IDLE, UnitOfDataRate, UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -27,8 +27,14 @@ from .coordinator import QBittorrentDataCoordinator
_LOGGER = logging.getLogger(__name__)
SENSOR_TYPE_CURRENT_STATUS = "current_status"
+SENSOR_TYPE_CONNECTION_STATUS = "connection_status"
SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed"
SENSOR_TYPE_UPLOAD_SPEED = "upload_speed"
+SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT = "download_speed_limit"
+SENSOR_TYPE_UPLOAD_SPEED_LIMIT = "upload_speed_limit"
+SENSOR_TYPE_ALLTIME_DOWNLOAD = "alltime_download"
+SENSOR_TYPE_ALLTIME_UPLOAD = "alltime_upload"
+SENSOR_TYPE_GLOBAL_RATIO = "global_ratio"
SENSOR_TYPE_ALL_TORRENTS = "all_torrents"
SENSOR_TYPE_PAUSED_TORRENTS = "paused_torrents"
SENSOR_TYPE_ACTIVE_TORRENTS = "active_torrents"
@@ -50,18 +56,54 @@ def get_state(coordinator: QBittorrentDataCoordinator) -> str:
return STATE_IDLE
-def get_dl(coordinator: QBittorrentDataCoordinator) -> int:
+def get_connection_status(coordinator: QBittorrentDataCoordinator) -> str:
+ """Get current download/upload state."""
+ server_state = cast(Mapping, coordinator.data.get("server_state"))
+ return cast(str, server_state.get("connection_status"))
+
+
+def get_download_speed(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current download speed."""
server_state = cast(Mapping, coordinator.data.get("server_state"))
return cast(int, server_state.get("dl_info_speed"))
-def get_up(coordinator: QBittorrentDataCoordinator) -> int:
+def get_upload_speed(coordinator: QBittorrentDataCoordinator) -> int:
"""Get current upload speed."""
server_state = cast(Mapping[str, Any], coordinator.data.get("server_state"))
return cast(int, server_state.get("up_info_speed"))
+def get_download_speed_limit(coordinator: QBittorrentDataCoordinator) -> int:
+ """Get current download speed."""
+ server_state = cast(Mapping, coordinator.data.get("server_state"))
+ return cast(int, server_state.get("dl_rate_limit"))
+
+
+def get_upload_speed_limit(coordinator: QBittorrentDataCoordinator) -> int:
+ """Get current upload speed."""
+ server_state = cast(Mapping[str, Any], coordinator.data.get("server_state"))
+ return cast(int, server_state.get("up_rate_limit"))
+
+
+def get_alltime_download(coordinator: QBittorrentDataCoordinator) -> int:
+ """Get current download speed."""
+ server_state = cast(Mapping, coordinator.data.get("server_state"))
+ return cast(int, server_state.get("alltime_dl"))
+
+
+def get_alltime_upload(coordinator: QBittorrentDataCoordinator) -> int:
+ """Get current download speed."""
+ server_state = cast(Mapping, coordinator.data.get("server_state"))
+ return cast(int, server_state.get("alltime_ul"))
+
+
+def get_global_ratio(coordinator: QBittorrentDataCoordinator) -> float:
+ """Get current download speed."""
+ server_state = cast(Mapping, coordinator.data.get("server_state"))
+ return cast(float, server_state.get("global_ratio"))
+
+
@dataclass(frozen=True, kw_only=True)
class QBittorrentSensorEntityDescription(SensorEntityDescription):
"""Entity description class for qBittorent sensors."""
@@ -77,6 +119,13 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
options=[STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING],
value_fn=get_state,
),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_CONNECTION_STATUS,
+ translation_key="connection_status",
+ device_class=SensorDeviceClass.ENUM,
+ options=["connected", "firewalled", "disconnected"],
+ value_fn=get_connection_status,
+ ),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_DOWNLOAD_SPEED,
translation_key="download_speed",
@@ -85,7 +134,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
- value_fn=get_dl,
+ value_fn=get_download_speed,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_UPLOAD_SPEED,
@@ -95,7 +144,56 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
suggested_display_precision=2,
suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
- value_fn=get_up,
+ value_fn=get_upload_speed,
+ ),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_DOWNLOAD_SPEED_LIMIT,
+ translation_key="download_speed_limit",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.DATA_RATE,
+ native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
+ suggested_display_precision=2,
+ suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
+ value_fn=get_download_speed_limit,
+ entity_registry_enabled_default=False,
+ ),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_UPLOAD_SPEED_LIMIT,
+ translation_key="upload_speed_limit",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.DATA_RATE,
+ native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND,
+ suggested_display_precision=2,
+ suggested_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND,
+ value_fn=get_upload_speed_limit,
+ entity_registry_enabled_default=False,
+ ),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_ALLTIME_DOWNLOAD,
+ translation_key="alltime_download",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ native_unit_of_measurement=UnitOfInformation.BYTES,
+ suggested_display_precision=2,
+ suggested_unit_of_measurement=UnitOfInformation.TEBIBYTES,
+ value_fn=get_alltime_download,
+ ),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_ALLTIME_UPLOAD,
+ translation_key="alltime_upload",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ device_class=SensorDeviceClass.DATA_SIZE,
+ native_unit_of_measurement="B",
+ suggested_display_precision=2,
+ suggested_unit_of_measurement="TiB",
+ value_fn=get_alltime_upload,
+ ),
+ QBittorrentSensorEntityDescription(
+ key=SENSOR_TYPE_GLOBAL_RATIO,
+ translation_key="global_ratio",
+ state_class=SensorStateClass.MEASUREMENT,
+ value_fn=get_global_ratio,
+ entity_registry_enabled_default=False,
),
QBittorrentSensorEntityDescription(
key=SENSOR_TYPE_ALL_TORRENTS,
@@ -120,7 +218,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
key=SENSOR_TYPE_PAUSED_TORRENTS,
translation_key="paused_torrents",
value_fn=lambda coordinator: count_torrents_in_states(
- coordinator, ["pausedDL", "pausedUP"]
+ coordinator, ["stoppedDL", "stoppedUP"]
),
),
)
@@ -129,7 +227,7 @@ SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up qBittorrent sensor entries."""
diff --git a/homeassistant/components/qbittorrent/strings.json b/homeassistant/components/qbittorrent/strings.json
index 9c9ee371737..ef2f45bbc28 100644
--- a/homeassistant/components/qbittorrent/strings.json
+++ b/homeassistant/components/qbittorrent/strings.json
@@ -26,6 +26,21 @@
"upload_speed": {
"name": "Upload speed"
},
+ "download_speed_limit": {
+ "name": "Download speed limit"
+ },
+ "upload_speed_limit": {
+ "name": "Upload speed limit"
+ },
+ "alltime_download": {
+ "name": "All-time download"
+ },
+ "alltime_upload": {
+ "name": "All-time upload"
+ },
+ "global_ratio": {
+ "name": "Global ratio"
+ },
"current_status": {
"name": "Status",
"state": {
@@ -35,6 +50,14 @@
"downloading": "Downloading"
}
},
+ "connection_status": {
+ "name": "Connection status",
+ "state": {
+ "connected": "[%key:common::state::connected%]",
+ "firewalled": "Firewalled",
+ "disconnected": "[%key:common::state::disconnected%]"
+ }
+ },
"active_torrents": {
"name": "Active torrents",
"unit_of_measurement": "torrents"
@@ -86,16 +109,16 @@
},
"exceptions": {
"invalid_device": {
- "message": "No device with id {device_id} was found"
+ "message": "No device with ID {device_id} was found"
},
"invalid_entry_id": {
- "message": "No entry with id {device_id} was found"
+ "message": "No entry with ID {device_id} was found"
},
"login_error": {
- "message": "A login error occured. Please check you username and password."
+ "message": "A login error occurred. Please check your username and password."
},
"cannot_connect": {
- "message": "Can't connect to QBittorrent, please check your configuration."
+ "message": "Can't connect to qBittorrent, please check your configuration."
}
}
}
diff --git a/homeassistant/components/qbittorrent/switch.py b/homeassistant/components/qbittorrent/switch.py
index f12118e5233..dd61f130ca1 100644
--- a/homeassistant/components/qbittorrent/switch.py
+++ b/homeassistant/components/qbittorrent/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -43,7 +43,7 @@ SWITCH_TYPES: tuple[QBittorrentSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up qBittorrent switch entries."""
diff --git a/homeassistant/components/qbus/__init__.py b/homeassistant/components/qbus/__init__.py
index da9dcfe69be..f77f439ecc1 100644
--- a/homeassistant/components/qbus/__init__.py
+++ b/homeassistant/components/qbus/__init__.py
@@ -71,17 +71,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: QbusConfigEntry) -> boo
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
entry.runtime_data.shutdown()
- cleanup(hass, entry)
+ _cleanup(hass, entry)
return unload_ok
-def cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None:
+def _cleanup(hass: HomeAssistant, entry: QbusConfigEntry) -> None:
"""Shutdown if no more entries are loaded."""
- entries = hass.config_entries.async_loaded_entries(DOMAIN)
- count = len(entries)
-
- # During unloading of the entry, it is not marked as unloaded yet. So
- # count can be 1 if it is the last one.
- if count <= 1 and (config_coordinator := hass.data.get(QBUS_KEY)):
+ if not hass.config_entries.async_loaded_entries(DOMAIN) and (
+ config_coordinator := hass.data.get(QBUS_KEY)
+ ):
config_coordinator.shutdown()
diff --git a/homeassistant/components/qbus/climate.py b/homeassistant/components/qbus/climate.py
new file mode 100644
index 00000000000..57d97c046b7
--- /dev/null
+++ b/homeassistant/components/qbus/climate.py
@@ -0,0 +1,172 @@
+"""Support for Qbus thermostat."""
+
+import logging
+from typing import Any
+
+from qbusmqttapi.const import KEY_PROPERTIES_REGIME, KEY_PROPERTIES_SET_TEMPERATURE
+from qbusmqttapi.discovery import QbusMqttOutput
+from qbusmqttapi.state import QbusMqttThermoState, StateType
+
+from homeassistant.components.climate import (
+ ClimateEntity,
+ ClimateEntityFeature,
+ HVACAction,
+ HVACMode,
+)
+from homeassistant.components.mqtt import ReceiveMessage, client as mqtt
+from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers.debounce import Debouncer
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import QbusConfigEntry
+from .entity import QbusEntity, add_new_outputs
+
+PARALLEL_UPDATES = 0
+
+STATE_REQUEST_DELAY = 2
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: QbusConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up climate entities."""
+
+ coordinator = entry.runtime_data
+ added_outputs: list[QbusMqttOutput] = []
+
+ def _check_outputs() -> None:
+ add_new_outputs(
+ coordinator,
+ added_outputs,
+ lambda output: output.type == "thermo",
+ QbusClimate,
+ async_add_entities,
+ )
+
+ _check_outputs()
+ entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
+
+
+class QbusClimate(QbusEntity, ClimateEntity):
+ """Representation of a Qbus climate entity."""
+
+ _attr_hvac_modes = [HVACMode.HEAT]
+ _attr_supported_features = (
+ ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE
+ )
+ _attr_temperature_unit = UnitOfTemperature.CELSIUS
+
+ def __init__(self, mqtt_output: QbusMqttOutput) -> None:
+ """Initialize climate entity."""
+
+ super().__init__(mqtt_output)
+
+ self._attr_hvac_action = HVACAction.IDLE
+ self._attr_hvac_mode = HVACMode.HEAT
+
+ set_temp: dict[str, Any] = mqtt_output.properties.get(
+ KEY_PROPERTIES_SET_TEMPERATURE, {}
+ )
+ current_regime: dict[str, Any] = mqtt_output.properties.get(
+ KEY_PROPERTIES_REGIME, {}
+ )
+
+ self._attr_min_temp: float = set_temp.get("min", 0)
+ self._attr_max_temp: float = set_temp.get("max", 35)
+ self._attr_target_temperature_step: float = set_temp.get("step", 0.5)
+ self._attr_preset_modes: list[str] = current_regime.get("enumValues", [])
+ self._attr_preset_mode: str = (
+ self._attr_preset_modes[0] if len(self._attr_preset_modes) > 0 else ""
+ )
+
+ self._request_state_debouncer: Debouncer | None = None
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+ self._request_state_debouncer = Debouncer(
+ self.hass,
+ _LOGGER,
+ cooldown=STATE_REQUEST_DELAY,
+ immediate=False,
+ function=self._async_request_state,
+ )
+ await super().async_added_to_hass()
+
+ async def async_set_preset_mode(self, preset_mode: str) -> None:
+ """Set new target preset mode."""
+
+ if preset_mode not in self._attr_preset_modes:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_preset",
+ translation_placeholders={
+ "preset": preset_mode,
+ "options": ", ".join(self._attr_preset_modes),
+ },
+ )
+
+ state = QbusMqttThermoState(id=self._mqtt_output.id, type=StateType.STATE)
+ state.write_regime(preset_mode)
+
+ await self._async_publish_output_state(state)
+
+ async def async_set_temperature(self, **kwargs: Any) -> None:
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+
+ if temperature is not None and isinstance(temperature, float):
+ state = QbusMqttThermoState(id=self._mqtt_output.id, type=StateType.STATE)
+ state.write_set_temperature(temperature)
+
+ await self._async_publish_output_state(state)
+
+ async def _state_received(self, msg: ReceiveMessage) -> None:
+ state = self._message_factory.parse_output_state(
+ QbusMqttThermoState, msg.payload
+ )
+
+ if state is None:
+ return
+
+ if preset_mode := state.read_regime():
+ self._attr_preset_mode = preset_mode
+
+ if current_temperature := state.read_current_temperature():
+ self._attr_current_temperature = current_temperature
+
+ if target_temperature := state.read_set_temperature():
+ self._attr_target_temperature = target_temperature
+
+ self._set_hvac_action()
+
+ # When the state type is "event", the payload only contains the changed
+ # property. Request the state to get the full payload. However, changing
+ # temperature step by step could cause a flood of state requests, so we're
+ # holding off a few seconds before requesting the full state.
+ if state.type == StateType.EVENT:
+ assert self._request_state_debouncer is not None
+ await self._request_state_debouncer.async_call()
+
+ self.async_schedule_update_ha_state()
+
+ def _set_hvac_action(self) -> None:
+ if self.target_temperature is None or self.current_temperature is None:
+ self._attr_hvac_action = HVACAction.IDLE
+ return
+
+ self._attr_hvac_action = (
+ HVACAction.HEATING
+ if self.target_temperature > self.current_temperature
+ else HVACAction.IDLE
+ )
+
+ async def _async_request_state(self) -> None:
+ request = self._message_factory.create_state_request([self._mqtt_output.id])
+ await mqtt.async_publish(self.hass, request.topic, request.payload)
diff --git a/homeassistant/components/qbus/const.py b/homeassistant/components/qbus/const.py
index ddfb8963cb7..767a41f48cc 100644
--- a/homeassistant/components/qbus/const.py
+++ b/homeassistant/components/qbus/const.py
@@ -5,7 +5,11 @@ from typing import Final
from homeassistant.const import Platform
DOMAIN: Final = "qbus"
-PLATFORMS: list[Platform] = [Platform.SWITCH]
+PLATFORMS: list[Platform] = [
+ Platform.CLIMATE,
+ Platform.LIGHT,
+ Platform.SWITCH,
+]
CONF_SERIAL_NUMBER: Final = "serial"
diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py
index 39bcddaaf4f..4ab1913c4dc 100644
--- a/homeassistant/components/qbus/entity.py
+++ b/homeassistant/components/qbus/entity.py
@@ -1,6 +1,9 @@
"""Base class for Qbus entities."""
+from __future__ import annotations
+
from abc import ABC, abstractmethod
+from collections.abc import Callable
import re
from qbusmqttapi.discovery import QbusMqttOutput
@@ -10,12 +13,36 @@ from qbusmqttapi.state import QbusMqttState
from homeassistant.components.mqtt import ReceiveMessage, client as mqtt
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MANUFACTURER
+from .coordinator import QbusControllerCoordinator
_REFID_REGEX = re.compile(r"^\d+\/(\d+(?:\/\d+)?)$")
+def add_new_outputs(
+ coordinator: QbusControllerCoordinator,
+ added_outputs: list[QbusMqttOutput],
+ filter_fn: Callable[[QbusMqttOutput], bool],
+ entity_type: type[QbusEntity],
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Call async_add_entities for new outputs."""
+
+ added_ref_ids = {k.ref_id for k in added_outputs}
+
+ new_outputs = [
+ output
+ for output in coordinator.data
+ if filter_fn(output) and output.ref_id not in added_ref_ids
+ ]
+
+ if new_outputs:
+ added_outputs.extend(new_outputs)
+ async_add_entities([entity_type(output) for output in new_outputs])
+
+
def format_ref_id(ref_id: str) -> str | None:
"""Format the Qbus ref_id."""
matches: list[str] = re.findall(_REFID_REGEX, ref_id)
diff --git a/homeassistant/components/qbus/light.py b/homeassistant/components/qbus/light.py
new file mode 100644
index 00000000000..3d2c763b8e3
--- /dev/null
+++ b/homeassistant/components/qbus/light.py
@@ -0,0 +1,97 @@
+"""Support for Qbus light."""
+
+from typing import Any
+
+from qbusmqttapi.discovery import QbusMqttOutput
+from qbusmqttapi.state import QbusMqttAnalogState, StateType
+
+from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
+from homeassistant.components.mqtt import ReceiveMessage
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.util.color import brightness_to_value, value_to_brightness
+
+from .coordinator import QbusConfigEntry
+from .entity import QbusEntity, add_new_outputs
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: QbusConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up light entities."""
+
+ coordinator = entry.runtime_data
+ added_outputs: list[QbusMqttOutput] = []
+
+ def _check_outputs() -> None:
+ add_new_outputs(
+ coordinator,
+ added_outputs,
+ lambda output: output.type == "analog",
+ QbusLight,
+ async_add_entities,
+ )
+
+ _check_outputs()
+ entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
+
+
+class QbusLight(QbusEntity, LightEntity):
+ """Representation of a Qbus light entity."""
+
+ _attr_supported_color_modes = {ColorMode.BRIGHTNESS}
+ _attr_color_mode = ColorMode.BRIGHTNESS
+
+ def __init__(self, mqtt_output: QbusMqttOutput) -> None:
+ """Initialize light entity."""
+
+ super().__init__(mqtt_output)
+
+ self._set_state(0)
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+
+ percentage: int | None = None
+ on: bool | None = None
+
+ state = QbusMqttAnalogState(id=self._mqtt_output.id)
+
+ if brightness is None:
+ on = True
+
+ state.type = StateType.ACTION
+ state.write_on_off(on)
+ else:
+ percentage = round(brightness_to_value((1, 100), brightness))
+
+ state.type = StateType.STATE
+ state.write_percentage(percentage)
+
+ await self._async_publish_output_state(state)
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ state = QbusMqttAnalogState(id=self._mqtt_output.id, type=StateType.ACTION)
+ state.write_on_off(on=False)
+
+ await self._async_publish_output_state(state)
+
+ async def _state_received(self, msg: ReceiveMessage) -> None:
+ output = self._message_factory.parse_output_state(
+ QbusMqttAnalogState, msg.payload
+ )
+
+ if output is not None:
+ percentage = round(output.read_percentage())
+ self._set_state(percentage)
+ self.async_schedule_update_ha_state()
+
+ def _set_state(self, percentage: int = 0) -> None:
+ self._attr_is_on = percentage > 0
+ self._attr_brightness = value_to_brightness((1, 100), percentage)
diff --git a/homeassistant/components/qbus/manifest.json b/homeassistant/components/qbus/manifest.json
index b7d277f3953..17101da7c33 100644
--- a/homeassistant/components/qbus/manifest.json
+++ b/homeassistant/components/qbus/manifest.json
@@ -13,5 +13,5 @@
"cloudapp/QBUSMQTTGW/+/state"
],
"quality_scale": "bronze",
- "requirements": ["qbusmqttapi==1.2.4"]
+ "requirements": ["qbusmqttapi==1.3.0"]
}
diff --git a/homeassistant/components/qbus/strings.json b/homeassistant/components/qbus/strings.json
index b8918497c41..f308c5b3519 100644
--- a/homeassistant/components/qbus/strings.json
+++ b/homeassistant/components/qbus/strings.json
@@ -10,10 +10,15 @@
"abort": {
"already_configured": "Controller already configured",
"discovery_in_progress": "Discovery in progress",
- "not_supported": "Configuration for QBUS happens through MQTT discovery. If your controller is not automatically discovered, check the prerequisites and troubleshooting section of the documention."
+ "not_supported": "Configuration for QBUS happens through MQTT discovery. If your controller is not automatically discovered, check the prerequisites and troubleshooting section of the documentation."
},
"error": {
"no_controller": "No controllers were found"
}
+ },
+ "exceptions": {
+ "invalid_preset": {
+ "message": "Preset mode \"{preset}\" is not valid. Valid preset modes are: {options}."
+ }
}
}
diff --git a/homeassistant/components/qbus/switch.py b/homeassistant/components/qbus/switch.py
index 2413b8f152f..e1feccf4450 100644
--- a/homeassistant/components/qbus/switch.py
+++ b/homeassistant/components/qbus/switch.py
@@ -8,35 +8,32 @@ from qbusmqttapi.state import QbusMqttOnOffState, StateType
from homeassistant.components.mqtt import ReceiveMessage
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import QbusConfigEntry
-from .entity import QbusEntity
+from .entity import QbusEntity, add_new_outputs
PARALLEL_UPDATES = 0
async def async_setup_entry(
- hass: HomeAssistant, entry: QbusConfigEntry, add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: QbusConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch entities."""
- coordinator = entry.runtime_data
+ coordinator = entry.runtime_data
added_outputs: list[QbusMqttOutput] = []
- # Local function that calls add_entities for new entities
def _check_outputs() -> None:
- added_output_ids = {k.id for k in added_outputs}
-
- new_outputs = [
- item
- for item in coordinator.data
- if item.type == "onoff" and item.id not in added_output_ids
- ]
-
- if new_outputs:
- added_outputs.extend(new_outputs)
- add_entities([QbusSwitch(output) for output in new_outputs])
+ add_new_outputs(
+ coordinator,
+ added_outputs,
+ lambda output: output.type == "onoff",
+ QbusSwitch,
+ async_add_entities,
+ )
_check_outputs()
entry.async_on_unload(coordinator.async_add_listener(_check_outputs))
@@ -47,10 +44,7 @@ class QbusSwitch(QbusEntity, SwitchEntity):
_attr_device_class = SwitchDeviceClass.SWITCH
- def __init__(
- self,
- mqtt_output: QbusMqttOutput,
- ) -> None:
+ def __init__(self, mqtt_output: QbusMqttOutput) -> None:
"""Initialize switch entity."""
super().__init__(mqtt_output)
@@ -63,7 +57,6 @@ class QbusSwitch(QbusEntity, SwitchEntity):
state.write_value(True)
await self._async_publish_output_state(state)
- self._attr_is_on = True
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
@@ -71,7 +64,6 @@ class QbusSwitch(QbusEntity, SwitchEntity):
state.write_value(False)
await self._async_publish_output_state(state)
- self._attr_is_on = False
async def _state_received(self, msg: ReceiveMessage) -> None:
output = self._message_factory.parse_output_state(
diff --git a/homeassistant/components/qingping/binary_sensor.py b/homeassistant/components/qingping/binary_sensor.py
index 5f1367fbce8..3431204595a 100644
--- a/homeassistant/components/qingping/binary_sensor.py
+++ b/homeassistant/components/qingping/binary_sensor.py
@@ -18,7 +18,7 @@ from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from . import QingpingConfigEntry
@@ -74,7 +74,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: QingpingConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qingping BLE sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/qingping/sensor.py b/homeassistant/components/qingping/sensor.py
index 3d5f30c61fc..ee2a63b169a 100644
--- a/homeassistant/components/qingping/sensor.py
+++ b/homeassistant/components/qingping/sensor.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from . import QingpingConfigEntry
@@ -142,7 +142,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: QingpingConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Qingping BLE sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/qnap/config_flow.py b/homeassistant/components/qnap/config_flow.py
index 75f41a27f69..504883b55e9 100644
--- a/homeassistant/components/qnap/config_flow.py
+++ b/homeassistant/components/qnap/config_flow.py
@@ -70,8 +70,8 @@ class QnapConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except TypeError:
errors["base"] = "invalid_auth"
- except Exception as error: # noqa: BLE001
- _LOGGER.error(error)
+ except Exception:
+ _LOGGER.exception("Unexpected error")
errors["base"] = "unknown"
else:
unique_id = stats["system"]["serial_number"]
diff --git a/homeassistant/components/qnap/coordinator.py b/homeassistant/components/qnap/coordinator.py
index 297f6569d2b..a6d654ddbbd 100644
--- a/homeassistant/components/qnap/coordinator.py
+++ b/homeassistant/components/qnap/coordinator.py
@@ -2,11 +2,13 @@
from __future__ import annotations
+from contextlib import contextmanager, nullcontext
from datetime import timedelta
import logging
from typing import Any
from qnapstats import QNAPStats
+import urllib3
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -28,6 +30,17 @@ UPDATE_INTERVAL = timedelta(minutes=1)
_LOGGER = logging.getLogger(__name__)
+@contextmanager
+def suppress_insecure_request_warning():
+ """Context manager to suppress InsecureRequestWarning.
+
+ Was added in here to solve the following issue, not being solved upstream.
+ https://github.com/colinodell/python-qnapstats/issues/96
+ """
+ with urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning):
+ yield
+
+
class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
"""Custom coordinator for the qnap integration."""
@@ -42,24 +55,31 @@ class QnapCoordinator(DataUpdateCoordinator[dict[str, dict[str, Any]]]):
)
protocol = "https" if config_entry.data[CONF_SSL] else "http"
+ self._verify_ssl = config_entry.data.get(CONF_VERIFY_SSL)
+
self._api = QNAPStats(
f"{protocol}://{config_entry.data.get(CONF_HOST)}",
config_entry.data.get(CONF_PORT),
config_entry.data.get(CONF_USERNAME),
config_entry.data.get(CONF_PASSWORD),
- verify_ssl=config_entry.data.get(CONF_VERIFY_SSL),
+ verify_ssl=self._verify_ssl,
timeout=config_entry.data.get(CONF_TIMEOUT),
)
def _sync_update(self) -> dict[str, dict[str, Any]]:
"""Get the latest data from the Qnap API."""
- return {
- "system_stats": self._api.get_system_stats(),
- "system_health": self._api.get_system_health(),
- "smart_drive_health": self._api.get_smart_disk_health(),
- "volumes": self._api.get_volumes(),
- "bandwidth": self._api.get_bandwidth(),
- }
+ with (
+ suppress_insecure_request_warning()
+ if not self._verify_ssl
+ else nullcontext()
+ ):
+ return {
+ "system_stats": self._api.get_system_stats(),
+ "system_health": self._api.get_system_health(),
+ "smart_drive_health": self._api.get_smart_disk_health(),
+ "volumes": self._api.get_volumes(),
+ "bandwidth": self._api.get_bandwidth(),
+ }
async def _async_update_data(self) -> dict[str, dict[str, Any]]:
"""Get the latest data from the Qnap API."""
diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py
index 383a4e5f572..381455cb7e1 100644
--- a/homeassistant/components/qnap/sensor.py
+++ b/homeassistant/components/qnap/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -248,7 +248,7 @@ SENSOR_KEYS: list[str] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
coordinator = QnapCoordinator(hass, config_entry)
diff --git a/homeassistant/components/qnap_qsw/binary_sensor.py b/homeassistant/components/qnap_qsw/binary_sensor.py
index a9c025b86ce..c1f77d068df 100644
--- a/homeassistant/components/qnap_qsw/binary_sensor.py
+++ b/homeassistant/components/qnap_qsw/binary_sensor.py
@@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED
from .const import ATTR_MESSAGE, DOMAIN, QSW_COORD_DATA
@@ -78,7 +78,9 @@ PORT_BINARY_SENSOR_TYPES: Final[tuple[QswBinarySensorEntityDescription, ...]] =
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add QNAP QSW binary sensors from a config_entry."""
coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA]
diff --git a/homeassistant/components/qnap_qsw/button.py b/homeassistant/components/qnap_qsw/button.py
index 091c6786a92..02cf96766f2 100644
--- a/homeassistant/components/qnap_qsw/button.py
+++ b/homeassistant/components/qnap_qsw/button.py
@@ -16,7 +16,7 @@ from homeassistant.components.button import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, QSW_COORD_DATA, QSW_REBOOT
from .coordinator import QswDataCoordinator
@@ -41,7 +41,9 @@ BUTTON_TYPES: Final[tuple[QswButtonDescription, ...]] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add QNAP QSW buttons from a config_entry."""
coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA]
diff --git a/homeassistant/components/qnap_qsw/sensor.py b/homeassistant/components/qnap_qsw/sensor.py
index e7f2c18638f..af02c121656 100644
--- a/homeassistant/components/qnap_qsw/sensor.py
+++ b/homeassistant/components/qnap_qsw/sensor.py
@@ -44,7 +44,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED, StateType
from homeassistant.util import dt as dt_util
@@ -286,7 +286,9 @@ PORT_SENSOR_TYPES: Final[tuple[QswSensorEntityDescription, ...]] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add QNAP QSW sensors from a config_entry."""
coordinator: QswDataCoordinator = hass.data[DOMAIN][entry.entry_id][QSW_COORD_DATA]
diff --git a/homeassistant/components/qnap_qsw/update.py b/homeassistant/components/qnap_qsw/update.py
index ac789235271..c5cef729849 100644
--- a/homeassistant/components/qnap_qsw/update.py
+++ b/homeassistant/components/qnap_qsw/update.py
@@ -20,7 +20,7 @@ from homeassistant.components.update import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, QSW_COORD_FW, QSW_UPDATE
from .coordinator import QswFirmwareCoordinator
@@ -36,7 +36,9 @@ UPDATE_TYPES: Final[tuple[UpdateEntityDescription, ...]] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add QNAP QSW updates from a config_entry."""
coordinator: QswFirmwareCoordinator = hass.data[DOMAIN][entry.entry_id][
diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json
index cd3ee8eca42..e29e95abc62 100644
--- a/homeassistant/components/qrcode/manifest.json
+++ b/homeassistant/components/qrcode/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "calculated",
"loggers": ["pyzbar"],
"quality_scale": "legacy",
- "requirements": ["Pillow==11.1.0", "pyzbar==0.1.7"]
+ "requirements": ["Pillow==11.2.1", "pyzbar==0.1.7"]
}
diff --git a/homeassistant/components/qwikswitch/entity.py b/homeassistant/components/qwikswitch/entity.py
index 3a2ec5a9206..ff7a1d2e98a 100644
--- a/homeassistant/components/qwikswitch/entity.py
+++ b/homeassistant/components/qwikswitch/entity.py
@@ -35,7 +35,7 @@ class QSEntity(Entity):
"""Receive update packet from QSUSB. Match dispather_send signature."""
self.async_write_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Listen for updates from QSUSb via dispatcher."""
self.async_on_remove(
async_dispatcher_connect(self.hass, self.qsid, self.update_packet)
diff --git a/homeassistant/components/rabbitair/config_flow.py b/homeassistant/components/rabbitair/config_flow.py
index f4487a73b58..43959e1e42c 100644
--- a/homeassistant/components/rabbitair/config_flow.py
+++ b/homeassistant/components/rabbitair/config_flow.py
@@ -74,8 +74,8 @@ class RabbitAirConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_host"
except TimeoutConnect:
errors["base"] = "timeout_connect"
- except Exception as err: # noqa: BLE001
- _LOGGER.debug("Unexpected exception: %s", err)
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
user_input[CONF_MAC] = info["mac"]
diff --git a/homeassistant/components/rabbitair/fan.py b/homeassistant/components/rabbitair/fan.py
index cfbee0be67c..4c13f3a8b02 100644
--- a/homeassistant/components/rabbitair/fan.py
+++ b/homeassistant/components/rabbitair/fan.py
@@ -9,7 +9,7 @@ from rabbitair import Mode, Model, Speed
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
@@ -39,7 +39,9 @@ PRESET_MODES = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a config entry."""
coordinator: RabbitAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py
index 189a08e998d..3bf0f716c6d 100644
--- a/homeassistant/components/rachio/binary_sensor.py
+++ b/homeassistant/components/rachio/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN as DOMAIN_RACHIO,
@@ -46,7 +46,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Rachio binary sensors."""
entities = await hass.async_add_executor_job(_create_entities, hass, config_entry)
diff --git a/homeassistant/components/rachio/calendar.py b/homeassistant/components/rachio/calendar.py
index 5c7e13c748a..91ad29fac9f 100644
--- a/homeassistant/components/rachio/calendar.py
+++ b/homeassistant/components/rachio/calendar.py
@@ -12,7 +12,7 @@ from homeassistant.components.calendar import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry for Rachio smart hose timer calendar."""
person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id]
diff --git a/homeassistant/components/rachio/strings.json b/homeassistant/components/rachio/strings.json
index 308403d805d..d51a1d5f920 100644
--- a/homeassistant/components/rachio/strings.json
+++ b/homeassistant/components/rachio/strings.json
@@ -3,7 +3,7 @@
"step": {
"user": {
"title": "Connect to your Rachio device",
- "description": "You will need the API Key from https://app.rach.io/. Go to Settings, then select 'GET API KEY'.",
+ "description": "You will need the API key from https://app.rach.io/. Go to Settings, then select 'GET API KEY'.",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
}
@@ -70,7 +70,7 @@
},
"start_watering": {
"name": "Start watering",
- "description": "Start a single zone, a schedule or any number of smart hose timers.",
+ "description": "Starts a single zone, a schedule or any number of smart hose timers.",
"fields": {
"duration": {
"name": "Duration",
@@ -80,7 +80,7 @@
},
"pause_watering": {
"name": "Pause watering",
- "description": "Pause any currently running zones or schedules.",
+ "description": "Pauses any currently running zones or schedules.",
"fields": {
"devices": {
"name": "Devices",
@@ -94,7 +94,7 @@
},
"resume_watering": {
"name": "Resume watering",
- "description": "Resume any paused zone runs or schedules.",
+ "description": "Resumes any paused zone runs or schedules.",
"fields": {
"devices": {
"name": "[%key:component::rachio::services::pause_watering::fields::devices::name%]",
@@ -104,7 +104,7 @@
},
"stop_watering": {
"name": "Stop watering",
- "description": "Stop any currently running zones or schedules.",
+ "description": "Stops any currently running zones or schedules.",
"fields": {
"devices": {
"name": "[%key:component::rachio::services::pause_watering::fields::devices::name%]",
diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py
index 92e7c0ea2ba..25cdeac62f7 100644
--- a/homeassistant/components/rachio/switch.py
+++ b/homeassistant/components/rachio/switch.py
@@ -16,7 +16,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_timestamp
@@ -102,7 +102,7 @@ START_MULTIPLE_ZONES_SCHEMA = vol.Schema(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Rachio switches."""
zone_entities = []
diff --git a/homeassistant/components/radarr/binary_sensor.py b/homeassistant/components/radarr/binary_sensor.py
index 62f78cc9d6f..f09e6015b53 100644
--- a/homeassistant/components/radarr/binary_sensor.py
+++ b/homeassistant/components/radarr/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import HEALTH_ISSUES
from .coordinator import RadarrConfigEntry
@@ -28,7 +28,7 @@ BINARY_SENSOR_TYPE = BinarySensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: RadarrConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Radarr sensors based on a config entry."""
coordinator = entry.runtime_data.health
diff --git a/homeassistant/components/radarr/calendar.py b/homeassistant/components/radarr/calendar.py
index 2976c7b6fea..00df27f21bd 100644
--- a/homeassistant/components/radarr/calendar.py
+++ b/homeassistant/components/radarr/calendar.py
@@ -7,7 +7,7 @@ from datetime import datetime
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import CalendarUpdateCoordinator, RadarrConfigEntry, RadarrEvent
from .entity import RadarrEntity
@@ -21,7 +21,7 @@ CALENDAR_TYPE = EntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: RadarrConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Radarr calendar entity."""
coordinator = entry.runtime_data.calendar
diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py
index e37fd51a494..a6d29ee9d1d 100644
--- a/homeassistant/components/radarr/sensor.py
+++ b/homeassistant/components/radarr/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory, UnitOfInformation
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import RadarrConfigEntry, RadarrDataUpdateCoordinator, T
from .entity import RadarrEntity
@@ -81,14 +81,12 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = {
"movie": RadarrSensorEntityDescription[int](
key="movies",
translation_key="movies",
- native_unit_of_measurement="Movies",
entity_registry_enabled_default=False,
value_fn=lambda data, _: data,
),
"queue": RadarrSensorEntityDescription[int](
key="queue",
translation_key="queue",
- native_unit_of_measurement="Movies",
entity_registry_enabled_default=False,
state_class=SensorStateClass.TOTAL,
value_fn=lambda data, _: data,
@@ -116,7 +114,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: RadarrConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Radarr sensors based on a config entry."""
entities: list[RadarrSensor[Any]] = []
diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json
index ec1baf6ffd8..268d7955c1b 100644
--- a/homeassistant/components/radarr/strings.json
+++ b/homeassistant/components/radarr/strings.json
@@ -43,10 +43,12 @@
},
"sensor": {
"movies": {
- "name": "Movies"
+ "name": "Movies",
+ "unit_of_measurement": "movies"
},
"queue": {
- "name": "Queue"
+ "name": "Queue",
+ "unit_of_measurement": "[%key:component::radarr::entity::sensor::movies::unit_of_measurement%]"
},
"start_time": {
"name": "Start time"
diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py
index af52c5fcea3..09ac5b42b60 100644
--- a/homeassistant/components/radiotherm/climate.py
+++ b/homeassistant/components/radiotherm/climate.py
@@ -20,7 +20,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .coordinator import RadioThermUpdateCoordinator
@@ -93,7 +93,7 @@ def round_temp(temperature):
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up climate for a radiotherm device."""
coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/radiotherm/switch.py b/homeassistant/components/radiotherm/switch.py
index e7b463e3def..2952e1e5817 100644
--- a/homeassistant/components/radiotherm/switch.py
+++ b/homeassistant/components/radiotherm/switch.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RadioThermUpdateCoordinator
@@ -19,7 +19,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches for a radiotherm device."""
coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py
index 5722b8852dd..0b27c7e33c4 100644
--- a/homeassistant/components/rainbird/binary_sensor.py
+++ b/homeassistant/components/rainbird/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import RainbirdUpdateCoordinator
@@ -27,7 +27,7 @@ RAIN_SENSOR_ENTITY_DESCRIPTION = BinarySensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RainbirdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird binary_sensor."""
coordinator = config_entry.runtime_data.coordinator
diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py
index 160fe70c61e..c48ca438146 100644
--- a/homeassistant/components/rainbird/calendar.py
+++ b/homeassistant/components/rainbird/calendar.py
@@ -9,7 +9,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RainbirdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird irrigation calendar."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py
index d8081a796b9..7f1dfe74752 100644
--- a/homeassistant/components/rainbird/number.py
+++ b/homeassistant/components/rainbird/number.py
@@ -10,7 +10,7 @@ from homeassistant.components.number import NumberEntity
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import RainbirdUpdateCoordinator
@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RainbirdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird number platform."""
async_add_entities(
diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py
index 4725a33bc9a..9fab1af0a23 100644
--- a/homeassistant/components/rainbird/sensor.py
+++ b/homeassistant/components/rainbird/sensor.py
@@ -6,7 +6,7 @@ import logging
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -25,7 +25,7 @@ RAIN_DELAY_ENTITY_DESCRIPTION = SensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RainbirdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird sensor."""
async_add_entities(
diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py
index f622a1b9b2c..f188350138e 100644
--- a/homeassistant/components/rainbird/switch.py
+++ b/homeassistant/components/rainbird/switch.py
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -32,7 +32,7 @@ SERVICE_SCHEMA_IRRIGATION: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RainbirdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry for a Rain Bird irrigation switches."""
coordinator = config_entry.runtime_data.coordinator
diff --git a/homeassistant/components/raincloud/entity.py b/homeassistant/components/raincloud/entity.py
index 337324d96eb..b45684ac72b 100644
--- a/homeassistant/components/raincloud/entity.py
+++ b/homeassistant/components/raincloud/entity.py
@@ -45,7 +45,7 @@ class RainCloudEntity(Entity):
"""Return the name of the sensor."""
return self._name
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self.async_on_remove(
async_dispatcher_connect(
diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py
index 8c4c5927998..6f4cbf4f02c 100644
--- a/homeassistant/components/rainforest_eagle/sensor.py
+++ b/homeassistant/components/rainforest_eagle/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -45,7 +45,9 @@ SENSORS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
@@ -57,7 +59,7 @@ async def async_setup_entry(
coordinator,
SensorEntityDescription(
key="zigbee:Price",
- translation_key="meter_price",
+ translation_key="energy_price",
native_unit_of_measurement=f"{coordinator.data['zigbee:PriceCurrency']}/{UnitOfEnergy.KILO_WATT_HOUR}",
state_class=SensorStateClass.MEASUREMENT,
),
diff --git a/homeassistant/components/rainforest_eagle/strings.json b/homeassistant/components/rainforest_eagle/strings.json
index 7b5054bfb0f..08e237d5af0 100644
--- a/homeassistant/components/rainforest_eagle/strings.json
+++ b/homeassistant/components/rainforest_eagle/strings.json
@@ -5,7 +5,7 @@
"data": {
"host": "[%key:common::config_flow::data::host%]",
"cloud_id": "Cloud ID",
- "install_code": "Installation Code"
+ "install_code": "Installation code"
},
"data_description": {
"host": "The hostname or IP address of your Rainforest gateway."
@@ -24,16 +24,16 @@
"entity": {
"sensor": {
"power_demand": {
- "name": "Meter power demand"
+ "name": "Power demand"
},
"total_energy_delivered": {
- "name": "Total meter energy delivered"
+ "name": "Total energy delivered"
},
"total_energy_received": {
- "name": "Total meter energy received"
+ "name": "Total energy received"
},
- "meter_price": {
- "name": "Meter price"
+ "energy_price": {
+ "name": "Energy price"
}
}
}
diff --git a/homeassistant/components/rainforest_raven/sensor.py b/homeassistant/components/rainforest_raven/sensor.py
index 1025e92ef86..658689c7e6c 100644
--- a/homeassistant/components/rainforest_raven/sensor.py
+++ b/homeassistant/components/rainforest_raven/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -80,7 +80,7 @@ DIAGNOSTICS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RAVEnConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a config entry."""
coordinator = entry.runtime_data
@@ -101,7 +101,7 @@ async def async_setup_entry(
coordinator,
RAVEnSensorEntityDescription(
message_key="PriceCluster",
- translation_key="meter_price",
+ translation_key="energy_price",
key="price",
native_unit_of_measurement=f"{meter_data['PriceCluster']['currency'].value}/{UnitOfEnergy.KILO_WATT_HOUR}",
state_class=SensorStateClass.MEASUREMENT,
diff --git a/homeassistant/components/rainforest_raven/strings.json b/homeassistant/components/rainforest_raven/strings.json
index fb667d64d3f..bc2653aea87 100644
--- a/homeassistant/components/rainforest_raven/strings.json
+++ b/homeassistant/components/rainforest_raven/strings.json
@@ -12,7 +12,7 @@
"step": {
"meters": {
"data": {
- "mac": "Meter MAC Addresses"
+ "mac": "Meter MAC addresses"
}
},
"user": {
@@ -24,27 +24,27 @@
},
"entity": {
"sensor": {
- "meter_price": {
- "name": "Meter price",
+ "energy_price": {
+ "name": "Energy price",
"state_attributes": {
"rate_label": { "name": "Rate" },
"tier": { "name": "Tier" }
}
},
"power_demand": {
- "name": "Meter power demand"
+ "name": "Power demand"
},
"signal_strength": {
- "name": "Meter signal strength",
+ "name": "Signal strength",
"state_attributes": {
"channel": { "name": "Channel" }
}
},
"total_energy_delivered": {
- "name": "Total meter energy delivered"
+ "name": "Total energy delivered"
},
"total_energy_received": {
- "name": "Total meter energy received"
+ "name": "Total energy received"
}
}
}
diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py
index 4d486c9c6aa..65648b8d44f 100644
--- a/homeassistant/components/rainmachine/__init__.py
+++ b/homeassistant/components/rainmachine/__init__.py
@@ -13,7 +13,7 @@ from regenmaschine.controller import Controller
from regenmaschine.errors import RainMachineError, UnknownAPICallError
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_IP_ADDRESS,
@@ -465,12 +465,7 @@ async def async_unload_entry(
) -> bool:
"""Unload an RainMachine config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state is ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# If this is the last loaded instance of RainMachine, deregister any services
# defined during integration setup:
for service_name in (
diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py
index 4ba9b58d596..610505e2b7f 100644
--- a/homeassistant/components/rainmachine/binary_sensor.py
+++ b/homeassistant/components/rainmachine/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RainMachineConfigEntry
from .const import DATA_PROVISION_SETTINGS, DATA_RESTRICTIONS_CURRENT
@@ -94,7 +94,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RainMachineConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up RainMachine binary sensors based on a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/rainmachine/button.py b/homeassistant/components/rainmachine/button.py
index 2f68c6a8a9c..e4ed00930dd 100644
--- a/homeassistant/components/rainmachine/button.py
+++ b/homeassistant/components/rainmachine/button.py
@@ -17,7 +17,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RainMachineConfigEntry
from .const import DATA_PROVISION_SETTINGS
@@ -53,7 +53,7 @@ BUTTON_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RainMachineConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up RainMachine buttons based on a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/rainmachine/select.py b/homeassistant/components/rainmachine/select.py
index 1d9225a5bb2..5b23a5d79ef 100644
--- a/homeassistant/components/rainmachine/select.py
+++ b/homeassistant/components/rainmachine/select.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM, UnitSystem
from . import RainMachineConfigEntry, RainMachineData
@@ -83,7 +83,7 @@ SELECT_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RainMachineConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up RainMachine selects based on a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py
index 64f9ecf3990..4677a6d8bca 100644
--- a/homeassistant/components/rainmachine/sensor.py
+++ b/homeassistant/components/rainmachine/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfVolume
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utc_from_timestamp, utcnow
from . import RainMachineConfigEntry, RainMachineData
@@ -153,7 +153,7 @@ SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RainMachineConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up RainMachine sensors based on a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py
index 2a065f18976..9b62b15d196 100644
--- a/homeassistant/components/rainmachine/switch.py
+++ b/homeassistant/components/rainmachine/switch.py
@@ -17,7 +17,7 @@ from homeassistant.const import ATTR_ID, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import RainMachineConfigEntry, RainMachineData, async_update_programs_and_zones
@@ -174,7 +174,7 @@ RESTRICTIONS_SWITCH_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RainMachineConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up RainMachine switches based on a config entry."""
platform = entity_platform.async_get_current_platform()
diff --git a/homeassistant/components/rainmachine/update.py b/homeassistant/components/rainmachine/update.py
index 39156b05cd4..312937184e4 100644
--- a/homeassistant/components/rainmachine/update.py
+++ b/homeassistant/components/rainmachine/update.py
@@ -16,7 +16,7 @@ from homeassistant.components.update import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RainMachineConfigEntry
from .const import DATA_MACHINE_FIRMWARE_UPDATE_STATUS
@@ -60,7 +60,7 @@ UPDATE_DESCRIPTION = RainMachineUpdateEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: RainMachineConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Rainmachine update based on a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py
index fadc966bc3d..1af85b43486 100644
--- a/homeassistant/components/random/binary_sensor.py
+++ b/homeassistant/components/random/binary_sensor.py
@@ -17,7 +17,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
DEFAULT_NAME = "Random binary sensor"
@@ -44,7 +47,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
async_add_entities(
diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py
index 590b391c3a0..6ea296c791e 100644
--- a/homeassistant/components/random/sensor.py
+++ b/homeassistant/components/random/sensor.py
@@ -22,7 +22,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DEFAULT_MAX, DEFAULT_MIN
@@ -57,7 +60,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
diff --git a/homeassistant/components/rapt_ble/sensor.py b/homeassistant/components/rapt_ble/sensor.py
index fd88cbcb54c..01aeedbd344 100644
--- a/homeassistant/components/rapt_ble/sensor.py
+++ b/homeassistant/components/rapt_ble/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
@@ -99,7 +99,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the RAPT Pill BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/rdw/binary_sensor.py b/homeassistant/components/rdw/binary_sensor.py
index 5360ce4a7fe..58e1c2e8237 100644
--- a/homeassistant/components/rdw/binary_sensor.py
+++ b/homeassistant/components/rdw/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -49,7 +49,7 @@ BINARY_SENSORS: tuple[RDWBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up RDW binary sensors based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/rdw/sensor.py b/homeassistant/components/rdw/sensor.py
index 2c9c9addcfb..4133082bcf4 100644
--- a/homeassistant/components/rdw/sensor.py
+++ b/homeassistant/components/rdw/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -51,7 +51,7 @@ SENSORS: tuple[RDWSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up RDW sensors based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/recollect_waste/calendar.py b/homeassistant/components/recollect_waste/calendar.py
index 3a76451358e..8145a93a2b7 100644
--- a/homeassistant/components/recollect_waste/calendar.py
+++ b/homeassistant/components/recollect_waste/calendar.py
@@ -9,7 +9,7 @@ from aiorecollect.client import PickupEvent
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
@@ -35,7 +35,9 @@ def async_get_calendar_event_from_pickup_event(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ReCollect Waste sensors based on a config entry."""
coordinator: DataUpdateCoordinator[list[PickupEvent]] = hass.data[DOMAIN][
diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py
index 36658fb5008..69b1772b9fa 100644
--- a/homeassistant/components/recollect_waste/sensor.py
+++ b/homeassistant/components/recollect_waste/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, LOGGER
@@ -39,7 +39,9 @@ SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ReCollect Waste sensors based on a config entry."""
coordinator: DataUpdateCoordinator[list[PickupEvent]] = hass.data[DOMAIN][
diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py
index 5a95ace92cb..c0bffbe9615 100644
--- a/homeassistant/components/recorder/__init__.py
+++ b/homeassistant/components/recorder/__init__.py
@@ -149,9 +149,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
commit_interval = conf[CONF_COMMIT_INTERVAL]
db_max_retries = conf[CONF_DB_MAX_RETRIES]
db_retry_wait = conf[CONF_DB_RETRY_WAIT]
- db_url = conf.get(CONF_DB_URL) or DEFAULT_URL.format(
- hass_config_path=hass.config.path(DEFAULT_DB_FILE)
- )
+ db_url = conf.get(CONF_DB_URL) or get_default_url(hass)
exclude = conf[CONF_EXCLUDE]
exclude_event_types: set[EventType[Any] | str] = set(
exclude.get(CONF_EVENT_TYPES, [])
@@ -172,12 +170,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
exclude_event_types=exclude_event_types,
)
get_instance.cache_clear()
+ entity_registry.async_setup(hass)
instance.async_initialize()
instance.async_register()
instance.start()
async_register_services(hass, instance)
websocket_api.async_setup(hass)
- entity_registry.async_setup(hass)
await _async_setup_integration_platform(hass, instance)
@@ -200,3 +198,8 @@ async def _async_setup_integration_platform(
instance.queue_task(AddRecorderPlatformTask(domain, platform))
await async_process_integration_platforms(hass, DOMAIN, _process_recorder_platform)
+
+
+def get_default_url(hass: HomeAssistant) -> str:
+ """Return the default URL."""
+ return DEFAULT_URL.format(hass_config_path=hass.config.path(DEFAULT_DB_FILE))
diff --git a/homeassistant/components/recorder/auto_repairs/schema.py b/homeassistant/components/recorder/auto_repairs/schema.py
index 1373f466bc2..cf3addd4f20 100644
--- a/homeassistant/components/recorder/auto_repairs/schema.py
+++ b/homeassistant/components/recorder/auto_repairs/schema.py
@@ -175,7 +175,7 @@ def _validate_db_schema_precision(
# Mark the session as read_only to ensure that the test data is not committed
# to the database and we always rollback when the scope is exited
with session_scope(session=instance.get_session(), read_only=True) as session:
- db_object = table_object(**{column: PRECISE_NUMBER for column in columns})
+ db_object = table_object(**dict.fromkeys(columns, PRECISE_NUMBER))
table = table_object.__tablename__
try:
session.add(db_object)
@@ -184,7 +184,7 @@ def _validate_db_schema_precision(
_check_columns(
schema_errors=schema_errors,
stored={column: getattr(db_object, column) for column in columns},
- expected={column: PRECISE_NUMBER for column in columns},
+ expected=dict.fromkeys(columns, PRECISE_NUMBER),
columns=columns,
table_name=table,
supports="double precision",
diff --git a/homeassistant/components/recorder/backup.py b/homeassistant/components/recorder/backup.py
index d47cbe92bd4..eeebe328007 100644
--- a/homeassistant/components/recorder/backup.py
+++ b/homeassistant/components/recorder/backup.py
@@ -2,7 +2,7 @@
from logging import getLogger
-from homeassistant.core import HomeAssistant
+from homeassistant.core import CoreState, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .util import async_migration_in_progress, get_instance
@@ -14,6 +14,8 @@ async def async_pre_backup(hass: HomeAssistant) -> None:
"""Perform operations before a backup starts."""
_LOGGER.info("Backup start notification, locking database for writes")
instance = get_instance(hass)
+ if hass.state is not CoreState.running:
+ raise HomeAssistantError("Home Assistant is not running")
if async_migration_in_progress(hass):
raise HomeAssistantError("Database migration in progress")
await instance.lock_database()
diff --git a/homeassistant/components/recorder/basic_websocket_api.py b/homeassistant/components/recorder/basic_websocket_api.py
index 9cbc77b30c0..ce9aa452fae 100644
--- a/homeassistant/components/recorder/basic_websocket_api.py
+++ b/homeassistant/components/recorder/basic_websocket_api.py
@@ -8,7 +8,9 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import recorder as recorder_helper
+from . import get_default_url
from .util import get_instance
@@ -23,30 +25,28 @@ def async_setup(hass: HomeAssistant) -> None:
vol.Required("type"): "recorder/info",
}
)
-@callback
-def ws_info(
+@websocket_api.async_response
+async def ws_info(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Return status of the recorder."""
- if instance := get_instance(hass):
- backlog = instance.backlog
- migration_in_progress = instance.migration_in_progress
- migration_is_live = instance.migration_is_live
- recording = instance.recording
- # We avoid calling is_alive() as it can block waiting
- # for the thread state lock which will block the event loop.
- is_running = instance.is_running
- max_backlog = instance.max_backlog
- else:
- backlog = None
- migration_in_progress = False
- migration_is_live = False
- recording = False
- is_running = False
- max_backlog = None
+ # Wait for db_connected to ensure the recorder instance is created and the
+ # migration flags are set.
+ await hass.data[recorder_helper.DATA_RECORDER].db_connected
+ instance = get_instance(hass)
+ backlog = instance.backlog
+ db_in_default_location = instance.db_url == get_default_url(hass)
+ migration_in_progress = instance.migration_in_progress
+ migration_is_live = instance.migration_is_live
+ recording = instance.recording
+ # We avoid calling is_alive() as it can block waiting
+ # for the thread state lock which will block the event loop.
+ is_running = instance.is_running
+ max_backlog = instance.max_backlog
recorder_info = {
"backlog": backlog,
+ "db_in_default_location": db_in_default_location,
"max_backlog": max_backlog,
"migration_in_progress": migration_in_progress,
"migration_is_live": migration_is_live,
diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py
index c91845e8436..4797eecda0f 100644
--- a/homeassistant/components/recorder/const.py
+++ b/homeassistant/components/recorder/const.py
@@ -30,6 +30,12 @@ CONF_DB_INTEGRITY_CHECK = "db_integrity_check"
MAX_QUEUE_BACKLOG_MIN_VALUE = 65000
MIN_AVAILABLE_MEMORY_FOR_QUEUE_BACKLOG = 256 * 1024**2
+# As soon as we have more than 999 ids, split the query as the
+# MySQL optimizer handles it poorly and will no longer
+# do an index only scan with a group-by
+# https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459
+MAX_IDS_FOR_INDEXED_GROUP_BY = 999
+
# The maximum number of rows (events) we purge in one delete statement
DEFAULT_MAX_BIND_VARS = 4000
@@ -48,8 +54,14 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
STATES_META_SCHEMA_VERSION = 38
LAST_REPORTED_SCHEMA_VERSION = 43
+CIRCULAR_MEAN_SCHEMA_VERSION = 49
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
+LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43
+# https://github.com/home-assistant/core/pull/120779
+# fixed the foreign keys in the states table but it did
+# not bump the schema version which means only databases
+# created with schema 44 and later do not need the rebuild.
INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics"
INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids"
diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py
index 05a5731e791..34fa6a62d44 100644
--- a/homeassistant/components/recorder/core.py
+++ b/homeassistant/components/recorder/core.py
@@ -43,6 +43,7 @@ from homeassistant.helpers.event import (
async_track_time_interval,
async_track_utc_time_change,
)
+from homeassistant.helpers.recorder import DATA_RECORDER
from homeassistant.helpers.start import async_at_started
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
@@ -78,7 +79,13 @@ from .db_schema import (
StatisticsShortTerm,
)
from .executor import DBInterruptibleThreadPoolExecutor
-from .models import DatabaseEngine, StatisticData, StatisticMetaData, UnsupportedDialect
+from .models import (
+ DatabaseEngine,
+ StatisticData,
+ StatisticMeanType,
+ StatisticMetaData,
+ UnsupportedDialect,
+)
from .pool import POOL_SIZE, MutexPool, RecorderPool
from .table_managers.event_data import EventDataManager
from .table_managers.event_types import EventTypeManager
@@ -122,8 +129,6 @@ from .util import (
_LOGGER = logging.getLogger(__name__)
-DEFAULT_URL = "sqlite:///{hass_config_path}"
-
# Controls how often we clean up
# States and Events objects
EXPIRE_AFTER_COMMITS = 120
@@ -183,7 +188,7 @@ class Recorder(threading.Thread):
self.db_retry_wait = db_retry_wait
self.database_engine: DatabaseEngine | None = None
# Database connection is ready, but non-live migration may be in progress
- db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected
+ db_connected: asyncio.Future[bool] = hass.data[DATA_RECORDER].db_connected
self.async_db_connected: asyncio.Future[bool] = db_connected
# Database is ready to use but live migration may be in progress
self.async_db_ready: asyncio.Future[bool] = hass.loop.create_future()
@@ -612,6 +617,17 @@ class Recorder(threading.Thread):
table: type[Statistics | StatisticsShortTerm],
) -> None:
"""Schedule import of statistics."""
+ if "mean_type" not in metadata:
+ # Backwards compatibility for old metadata format
+ # Can be removed after 2026.4
+ metadata["mean_type"] = ( # type: ignore[unreachable]
+ StatisticMeanType.ARITHMETIC
+ if metadata.get("has_mean")
+ else StatisticMeanType.NONE
+ )
+ # Remove deprecated has_mean as it's not needed anymore in core
+ metadata.pop("has_mean", None)
+
self.queue_task(ImportStatisticsTask(metadata, stats, table))
@callback
@@ -1291,11 +1307,17 @@ class Recorder(threading.Thread):
async def async_block_till_done(self) -> None:
"""Async version of block_till_done."""
+ if future := self.async_get_commit_future():
+ await future
+
+ @callback
+ def async_get_commit_future(self) -> asyncio.Future[None] | None:
+ """Return a future that will wait for the next commit or None if nothing pending."""
if self._queue.empty() and not self._event_session_has_pending_writes:
- return
- event = asyncio.Event()
- self.queue_task(SynchronizeTask(event))
- await event.wait()
+ return None
+ future: asyncio.Future[None] = self.hass.loop.create_future()
+ self.queue_task(SynchronizeTask(future))
+ return future
def block_till_done(self) -> None:
"""Block till all events processed.
diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py
index d1a2405406e..6566cadf64c 100644
--- a/homeassistant/components/recorder/db_schema.py
+++ b/homeassistant/components/recorder/db_schema.py
@@ -58,6 +58,7 @@ from .const import ALL_DOMAIN_EXCLUDE_ATTRS, SupportedDialect
from .models import (
StatisticData,
StatisticDataTimestamp,
+ StatisticMeanType,
StatisticMetaData,
bytes_to_ulid_or_none,
bytes_to_uuid_hex_or_none,
@@ -77,7 +78,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
-SCHEMA_VERSION = 48
+SCHEMA_VERSION = 50
_LOGGER = logging.getLogger(__name__)
@@ -203,11 +204,11 @@ UINT_32_TYPE = BigInteger().with_variant(
"mariadb",
)
JSON_VARIANT_CAST = Text().with_variant(
- postgresql.JSON(none_as_null=True), # type: ignore[no-untyped-call]
+ postgresql.JSON(none_as_null=True),
"postgresql",
)
JSONB_VARIANT_CAST = Text().with_variant(
- postgresql.JSONB(none_as_null=True), # type: ignore[no-untyped-call]
+ postgresql.JSONB(none_as_null=True),
"postgresql",
)
DATETIME_TYPE = (
@@ -719,6 +720,7 @@ class StatisticsBase:
start: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN)
start_ts: Mapped[float | None] = mapped_column(TIMESTAMP_TYPE, index=True)
mean: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
+ mean_weight: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
min: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
max: Mapped[float | None] = mapped_column(DOUBLE_TYPE)
last_reset: Mapped[datetime | None] = mapped_column(UNUSED_LEGACY_DATETIME_COLUMN)
@@ -740,6 +742,7 @@ class StatisticsBase:
start=None,
start_ts=stats["start"].timestamp(),
mean=stats.get("mean"),
+ mean_weight=stats.get("mean_weight"),
min=stats.get("min"),
max=stats.get("max"),
last_reset=None,
@@ -763,6 +766,7 @@ class StatisticsBase:
start=None,
start_ts=stats["start_ts"],
mean=stats.get("mean"),
+ mean_weight=stats.get("mean_weight"),
min=stats.get("min"),
max=stats.get("max"),
last_reset=None,
@@ -848,6 +852,9 @@ class _StatisticsMeta:
has_mean: Mapped[bool | None] = mapped_column(Boolean)
has_sum: Mapped[bool | None] = mapped_column(Boolean)
name: Mapped[str | None] = mapped_column(String(255))
+ mean_type: Mapped[StatisticMeanType] = mapped_column(
+ SmallInteger, nullable=False, default=StatisticMeanType.NONE.value
+ ) # See StatisticMeanType
@staticmethod
def from_meta(meta: StatisticMetaData) -> StatisticsMeta:
diff --git a/homeassistant/components/recorder/entity_registry.py b/homeassistant/components/recorder/entity_registry.py
index 07f8f2f88de..30a3a1b8239 100644
--- a/homeassistant/components/recorder/entity_registry.py
+++ b/homeassistant/components/recorder/entity_registry.py
@@ -4,8 +4,9 @@ import logging
from typing import TYPE_CHECKING
from homeassistant.core import Event, HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.start import async_at_start
+from homeassistant.helpers.event import async_has_entity_registry_updated_listeners
from .core import Recorder
from .util import filter_unique_constraint_integrity_error, get_instance, session_scope
@@ -40,16 +41,17 @@ def async_setup(hass: HomeAssistant) -> None:
"""Handle entity_id changed filter."""
return event_data["action"] == "update" and "old_entity_id" in event_data
- @callback
- def _setup_entity_registry_event_handler(hass: HomeAssistant) -> None:
- """Subscribe to event registry events."""
- hass.bus.async_listen(
- er.EVENT_ENTITY_REGISTRY_UPDATED,
- _async_entity_id_changed,
- event_filter=entity_registry_changed_filter,
+ if async_has_entity_registry_updated_listeners(hass):
+ raise HomeAssistantError(
+ "The recorder entity registry listener must be installed"
+ " before async_track_entity_registry_updated_event is called"
)
- async_at_start(hass, _setup_entity_registry_event_handler)
+ hass.bus.async_listen(
+ er.EVENT_ENTITY_REGISTRY_UPDATED,
+ _async_entity_id_changed,
+ event_filter=entity_registry_changed_filter,
+ )
def update_states_metadata(
diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py
index 8958913bce6..566e30713f0 100644
--- a/homeassistant/components/recorder/history/modern.py
+++ b/homeassistant/components/recorder/history/modern.py
@@ -6,11 +6,12 @@ from collections.abc import Callable, Iterable, Iterator
from datetime import datetime
from itertools import groupby
from operator import itemgetter
-from typing import Any, cast
+from typing import TYPE_CHECKING, Any, cast
from sqlalchemy import (
CompoundSelect,
Select,
+ StatementLambdaElement,
Subquery,
and_,
func,
@@ -26,8 +27,9 @@ from homeassistant.const import COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.recorder import get_instance
from homeassistant.util import dt as dt_util
+from homeassistant.util.collection import chunked_or_all
-from ..const import LAST_REPORTED_SCHEMA_VERSION
+from ..const import LAST_REPORTED_SCHEMA_VERSION, MAX_IDS_FOR_INDEXED_GROUP_BY
from ..db_schema import (
SHARED_ATTR_OR_LEGACY_ATTRIBUTES,
StateAttributes,
@@ -149,6 +151,7 @@ def _significant_states_stmt(
no_attributes: bool,
include_start_time_state: bool,
run_start_ts: float | None,
+ slow_dependent_subquery: bool,
) -> Select | CompoundSelect:
"""Query the database for significant state changes."""
include_last_changed = not significant_changes_only
@@ -187,6 +190,7 @@ def _significant_states_stmt(
metadata_ids,
no_attributes,
include_last_changed,
+ slow_dependent_subquery,
).subquery(),
no_attributes,
include_last_changed,
@@ -257,7 +261,68 @@ def get_significant_states_with_session(
start_time_ts = start_time.timestamp()
end_time_ts = datetime_to_timestamp_or_none(end_time)
single_metadata_id = metadata_ids[0] if len(metadata_ids) == 1 else None
- stmt = lambda_stmt(
+ rows: list[Row] = []
+ if TYPE_CHECKING:
+ assert instance.database_engine is not None
+ slow_dependent_subquery = instance.database_engine.optimizer.slow_dependent_subquery
+ if include_start_time_state and slow_dependent_subquery:
+ # https://github.com/home-assistant/core/issues/137178
+ # If we include the start time state we need to limit the
+ # number of metadata_ids we query for at a time to avoid
+ # hitting limits in the MySQL optimizer that prevent
+ # the start time state query from using an index-only optimization
+ # to find the start time state.
+ iter_metadata_ids = chunked_or_all(metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY)
+ else:
+ iter_metadata_ids = (metadata_ids,)
+ for metadata_ids_chunk in iter_metadata_ids:
+ stmt = _generate_significant_states_with_session_stmt(
+ start_time_ts,
+ end_time_ts,
+ single_metadata_id,
+ metadata_ids_chunk,
+ metadata_ids_in_significant_domains,
+ significant_changes_only,
+ no_attributes,
+ include_start_time_state,
+ oldest_ts,
+ slow_dependent_subquery,
+ )
+ row_chunk = cast(
+ list[Row],
+ execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False),
+ )
+ if rows:
+ rows += row_chunk
+ else:
+ # If we have no rows yet, we can just assign the chunk
+ # as this is the common case since its rare that
+ # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit
+ rows = row_chunk
+ return _sorted_states_to_dict(
+ rows,
+ start_time_ts if include_start_time_state else None,
+ entity_ids,
+ entity_id_to_metadata_id,
+ minimal_response,
+ compressed_state_format,
+ no_attributes=no_attributes,
+ )
+
+
+def _generate_significant_states_with_session_stmt(
+ start_time_ts: float,
+ end_time_ts: float | None,
+ single_metadata_id: int | None,
+ metadata_ids: list[int],
+ metadata_ids_in_significant_domains: list[int],
+ significant_changes_only: bool,
+ no_attributes: bool,
+ include_start_time_state: bool,
+ oldest_ts: float | None,
+ slow_dependent_subquery: bool,
+) -> StatementLambdaElement:
+ return lambda_stmt(
lambda: _significant_states_stmt(
start_time_ts,
end_time_ts,
@@ -268,6 +333,7 @@ def get_significant_states_with_session(
no_attributes,
include_start_time_state,
oldest_ts,
+ slow_dependent_subquery,
),
track_on=[
bool(single_metadata_id),
@@ -276,17 +342,9 @@ def get_significant_states_with_session(
significant_changes_only,
no_attributes,
include_start_time_state,
+ slow_dependent_subquery,
],
)
- return _sorted_states_to_dict(
- execute_stmt_lambda_element(session, stmt, None, end_time, orm_rows=False),
- start_time_ts if include_start_time_state else None,
- entity_ids,
- entity_id_to_metadata_id,
- minimal_response,
- compressed_state_format,
- no_attributes=no_attributes,
- )
def get_full_significant_states_with_session(
@@ -554,13 +612,14 @@ def get_last_state_changes(
)
-def _get_start_time_state_for_entities_stmt(
+def _get_start_time_state_for_entities_stmt_dependent_sub_query(
epoch_time: float,
metadata_ids: list[int],
no_attributes: bool,
include_last_changed: bool,
) -> Select:
"""Baked query to get states for specific entities."""
+ # Engine has a fast dependent subquery optimizer
# This query is the result of significant research in
# https://github.com/home-assistant/core/issues/132865
# A reverse index scan with a limit 1 is the fastest way to get the
@@ -570,7 +629,9 @@ def _get_start_time_state_for_entities_stmt(
# before a specific point in time for all entities.
stmt = (
_stmt_and_join_attributes_for_start_state(
- no_attributes, include_last_changed, False
+ no_attributes=no_attributes,
+ include_last_changed=include_last_changed,
+ include_last_reported=False,
)
.select_from(StatesMeta)
.join(
@@ -600,6 +661,55 @@ def _get_start_time_state_for_entities_stmt(
)
+def _get_start_time_state_for_entities_stmt_group_by(
+ epoch_time: float,
+ metadata_ids: list[int],
+ no_attributes: bool,
+ include_last_changed: bool,
+) -> Select:
+ """Baked query to get states for specific entities."""
+ # Simple group-by for MySQL, must use less
+ # than 1000 metadata_ids in the IN clause for MySQL
+ # or it will optimize poorly. Callers are responsible
+ # for ensuring that the number of metadata_ids is less
+ # than 1000.
+ most_recent_states_for_entities_by_date = (
+ select(
+ States.metadata_id.label("max_metadata_id"),
+ func.max(States.last_updated_ts).label("max_last_updated"),
+ )
+ .filter(
+ (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids)
+ )
+ .group_by(States.metadata_id)
+ .subquery()
+ )
+ stmt = (
+ _stmt_and_join_attributes_for_start_state(
+ no_attributes=no_attributes,
+ include_last_changed=include_last_changed,
+ include_last_reported=False,
+ )
+ .join(
+ most_recent_states_for_entities_by_date,
+ and_(
+ States.metadata_id
+ == most_recent_states_for_entities_by_date.c.max_metadata_id,
+ States.last_updated_ts
+ == most_recent_states_for_entities_by_date.c.max_last_updated,
+ ),
+ )
+ .filter(
+ (States.last_updated_ts < epoch_time) & States.metadata_id.in_(metadata_ids)
+ )
+ )
+ if no_attributes:
+ return stmt
+ return stmt.outerjoin(
+ StateAttributes, (States.attributes_id == StateAttributes.attributes_id)
+ )
+
+
def _get_oldest_possible_ts(
hass: HomeAssistant, utc_point_in_time: datetime
) -> float | None:
@@ -620,6 +730,7 @@ def _get_start_time_state_stmt(
metadata_ids: list[int],
no_attributes: bool,
include_last_changed: bool,
+ slow_dependent_subquery: bool,
) -> Select:
"""Return the states at a specific point in time."""
if single_metadata_id:
@@ -634,7 +745,15 @@ def _get_start_time_state_stmt(
)
# We have more than one entity to look at so we need to do a query on states
# since the last recorder run started.
- return _get_start_time_state_for_entities_stmt(
+ if slow_dependent_subquery:
+ return _get_start_time_state_for_entities_stmt_group_by(
+ epoch_time,
+ metadata_ids,
+ no_attributes,
+ include_last_changed,
+ )
+
+ return _get_start_time_state_for_entities_stmt_dependent_sub_query(
epoch_time,
metadata_ids,
no_attributes,
diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json
index 0b8532bedea..82fdeaca045 100644
--- a/homeassistant/components/recorder/manifest.json
+++ b/homeassistant/components/recorder/manifest.json
@@ -7,8 +7,8 @@
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": [
- "SQLAlchemy==2.0.38",
- "fnv-hash-fast==1.2.2",
+ "SQLAlchemy==2.0.40",
+ "fnv-hash-fast==1.4.0",
"psutil-home-assistant==0.0.1"
]
}
diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py
index c6cdd6d317f..58af15c2aa7 100644
--- a/homeassistant/components/recorder/migration.py
+++ b/homeassistant/components/recorder/migration.py
@@ -9,7 +9,7 @@ from dataclasses import dataclass, replace as dataclass_replace
from datetime import timedelta
import logging
from time import time
-from typing import TYPE_CHECKING, Any, cast, final
+from typing import TYPE_CHECKING, Any, TypedDict, cast, final
from uuid import UUID
import sqlalchemy
@@ -52,6 +52,7 @@ from .auto_repairs.statistics.schema import (
from .const import (
CONTEXT_ID_AS_BINARY_SCHEMA_VERSION,
EVENT_TYPE_IDS_SCHEMA_VERSION,
+ LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION,
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION,
STATES_META_SCHEMA_VERSION,
SupportedDialect,
@@ -80,7 +81,7 @@ from .db_schema import (
StatisticsRuns,
StatisticsShortTerm,
)
-from .models import process_timestamp
+from .models import StatisticMeanType, process_timestamp
from .models.time import datetime_to_timestamp_or_none
from .queries import (
batch_cleanup_entity_ids,
@@ -143,24 +144,32 @@ class _ColumnTypesForDialect:
big_int_type: str
timestamp_type: str
context_bin_type: str
+ small_int_type: str
+ double_type: str
_MYSQL_COLUMN_TYPES = _ColumnTypesForDialect(
big_int_type="INTEGER(20)",
timestamp_type=DOUBLE_PRECISION_TYPE_SQL,
context_bin_type=f"BLOB({CONTEXT_ID_BIN_MAX_LENGTH})",
+ small_int_type="SMALLINT",
+ double_type=DOUBLE_PRECISION_TYPE_SQL,
)
_POSTGRESQL_COLUMN_TYPES = _ColumnTypesForDialect(
big_int_type="INTEGER",
timestamp_type=DOUBLE_PRECISION_TYPE_SQL,
context_bin_type="BYTEA",
+ small_int_type="SMALLINT",
+ double_type=DOUBLE_PRECISION_TYPE_SQL,
)
_SQLITE_COLUMN_TYPES = _ColumnTypesForDialect(
big_int_type="INTEGER",
timestamp_type="FLOAT",
context_bin_type="BLOB",
+ small_int_type="INTEGER",
+ double_type="FLOAT",
)
_COLUMN_TYPES_FOR_DIALECT: dict[SupportedDialect | None, _ColumnTypesForDialect] = {
@@ -711,6 +720,11 @@ def _modify_columns(
raise
+class _FKAlterDict(TypedDict):
+ old_fk: ForeignKeyConstraint
+ columns: list[str]
+
+
def _update_states_table_with_foreign_key_options(
session_maker: Callable[[], Session], engine: Engine
) -> None:
@@ -728,7 +742,7 @@ def _update_states_table_with_foreign_key_options(
inspector = sqlalchemy.inspect(engine)
tmp_states_table = Table(TABLE_STATES, MetaData())
- alters = [
+ alters: list[_FKAlterDict] = [
{
"old_fk": ForeignKeyConstraint(
(), (), name=foreign_key["name"], table=tmp_states_table
@@ -754,14 +768,14 @@ def _update_states_table_with_foreign_key_options(
with session_scope(session=session_maker()) as session:
try:
connection = session.connection()
- connection.execute(DropConstraint(alter["old_fk"])) # type: ignore[no-untyped-call]
+ connection.execute(DropConstraint(alter["old_fk"]))
for fkc in states_key_constraints:
if fkc.column_keys == alter["columns"]:
# AddConstraint mutates the constraint passed to it, we need to
# undo that to avoid changing the behavior of the table schema.
# https://github.com/sqlalchemy/sqlalchemy/blob/96f1172812f858fead45cdc7874abac76f45b339/lib/sqlalchemy/sql/ddl.py#L746-L748
create_rule = fkc._create_rule # noqa: SLF001
- add_constraint = AddConstraint(fkc) # type: ignore[no-untyped-call]
+ add_constraint = AddConstraint(fkc)
fkc._create_rule = create_rule # noqa: SLF001
connection.execute(add_constraint)
except (InternalError, OperationalError):
@@ -799,7 +813,7 @@ def _drop_foreign_key_constraints(
with session_scope(session=session_maker()) as session:
try:
connection = session.connection()
- connection.execute(DropConstraint(drop)) # type: ignore[no-untyped-call]
+ connection.execute(DropConstraint(drop))
except (InternalError, OperationalError):
_LOGGER.exception(
"Could not drop foreign constraints in %s table on %s",
@@ -844,7 +858,7 @@ def _restore_foreign_key_constraints(
# undo that to avoid changing the behavior of the table schema.
# https://github.com/sqlalchemy/sqlalchemy/blob/96f1172812f858fead45cdc7874abac76f45b339/lib/sqlalchemy/sql/ddl.py#L746-L748
create_rule = constraint._create_rule # noqa: SLF001
- add_constraint = AddConstraint(constraint) # type: ignore[no-untyped-call]
+ add_constraint = AddConstraint(constraint)
constraint._create_rule = create_rule # noqa: SLF001
try:
_add_constraint(session_maker, add_constraint, table, column)
@@ -1987,6 +2001,42 @@ class _SchemaVersion48Migrator(_SchemaVersionMigrator, target_version=48):
_migrate_columns_to_timestamp(self.instance, self.session_maker, self.engine)
+class _SchemaVersion49Migrator(_SchemaVersionMigrator, target_version=49):
+ def _apply_update(self) -> None:
+ """Version specific update method."""
+ _add_columns(
+ self.session_maker,
+ "statistics_meta",
+ [
+ f"mean_type {self.column_types.small_int_type} NOT NULL DEFAULT {StatisticMeanType.NONE.value}"
+ ],
+ )
+
+ for table in ("statistics", "statistics_short_term"):
+ _add_columns(
+ self.session_maker,
+ table,
+ [f"mean_weight {self.column_types.double_type}"],
+ )
+
+ with session_scope(session=self.session_maker()) as session:
+ connection = session.connection()
+ connection.execute(
+ text(
+ "UPDATE statistics_meta SET mean_type=:mean_type WHERE has_mean=true"
+ ),
+ {"mean_type": StatisticMeanType.ARITHMETIC.value},
+ )
+
+
+class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
+ def _apply_update(self) -> None:
+ """Version specific update method."""
+ with session_scope(session=self.session_maker()) as session:
+ connection = session.connection()
+ connection.execute(text("UPDATE statistics_meta SET has_mean=NULL"))
+
+
def _migrate_statistics_columns_to_timestamp_removing_duplicates(
hass: HomeAssistant,
instance: Recorder,
@@ -2490,9 +2540,10 @@ class BaseMigration(ABC):
if self.initial_schema_version > self.max_initial_schema_version:
_LOGGER.debug(
"Data migration '%s' not needed, database created with version %s "
- "after migrator was added",
+ "after migrator was added in version %s",
self.migration_id,
self.initial_schema_version,
+ self.max_initial_schema_version,
)
return False
if self.start_schema_version < self.required_schema_version:
@@ -2868,7 +2919,14 @@ class EventIDPostMigration(BaseRunTimeMigration):
"""Migration to remove old event_id index from states."""
migration_id = "event_id_post_migration"
- max_initial_schema_version = LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION - 1
+ # Note we don't subtract 1 from the max_initial_schema_version
+ # in this case because we need to run this migration on databases
+ # version >= 43 because the schema was not bumped when the table
+ # rebuild was added in
+ # https://github.com/home-assistant/core/pull/120779
+ # which means its only safe to assume version 44 and later
+ # do not need the table rebuild
+ max_initial_schema_version = LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION
task = MigrationTask
migration_version = 2
diff --git a/homeassistant/components/recorder/models/__init__.py b/homeassistant/components/recorder/models/__init__.py
index ea7a6c86854..8f76982a900 100644
--- a/homeassistant/components/recorder/models/__init__.py
+++ b/homeassistant/components/recorder/models/__init__.py
@@ -17,6 +17,7 @@ from .statistics import (
RollingWindowStatisticPeriod,
StatisticData,
StatisticDataTimestamp,
+ StatisticMeanType,
StatisticMetaData,
StatisticPeriod,
StatisticResult,
@@ -37,6 +38,7 @@ __all__ = [
"RollingWindowStatisticPeriod",
"StatisticData",
"StatisticDataTimestamp",
+ "StatisticMeanType",
"StatisticMetaData",
"StatisticPeriod",
"StatisticResult",
diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py
index b86fd299793..2a4924edab3 100644
--- a/homeassistant/components/recorder/models/database.py
+++ b/homeassistant/components/recorder/models/database.py
@@ -37,3 +37,13 @@ class DatabaseOptimizer:
# https://wiki.postgresql.org/wiki/Loose_indexscan
# https://github.com/home-assistant/core/issues/126084
slow_range_in_select: bool
+
+ # MySQL 8.x+ can end up with a file-sort on a dependent subquery
+ # which makes the query painfully slow.
+ # https://github.com/home-assistant/core/issues/137178
+ # The solution is to use multiple indexed group-by queries instead
+ # of the subquery as long as the group by does not exceed
+ # 999 elements since as soon as we hit 1000 elements MySQL
+ # will no longer use the group_index_range optimization.
+ # https://github.com/home-assistant/core/issues/132865#issuecomment-2543160459
+ slow_dependent_subquery: bool
diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py
index 919ee078a99..28459cfef07 100644
--- a/homeassistant/components/recorder/models/state.py
+++ b/homeassistant/components/recorder/models/state.py
@@ -104,7 +104,7 @@ class LazyState(State):
return self._last_updated_ts
@cached_property
- def last_changed_timestamp(self) -> float: # type: ignore[override]
+ def last_changed_timestamp(self) -> float:
"""Last changed timestamp."""
ts = self._last_changed_ts or self._last_updated_ts
if TYPE_CHECKING:
@@ -112,7 +112,7 @@ class LazyState(State):
return ts
@cached_property
- def last_reported_timestamp(self) -> float: # type: ignore[override]
+ def last_reported_timestamp(self) -> float:
"""Last reported timestamp."""
ts = self._last_reported_ts or self._last_updated_ts
if TYPE_CHECKING:
diff --git a/homeassistant/components/recorder/models/statistics.py b/homeassistant/components/recorder/models/statistics.py
index ad4d82067c4..08da12d6b17 100644
--- a/homeassistant/components/recorder/models/statistics.py
+++ b/homeassistant/components/recorder/models/statistics.py
@@ -3,7 +3,8 @@
from __future__ import annotations
from datetime import datetime, timedelta
-from typing import Literal, TypedDict
+from enum import IntEnum
+from typing import Literal, NotRequired, TypedDict
class StatisticResult(TypedDict):
@@ -36,6 +37,7 @@ class StatisticMixIn(TypedDict, total=False):
min: float
max: float
mean: float
+ mean_weight: float
class StatisticData(StatisticDataBase, StatisticMixIn, total=False):
@@ -50,10 +52,20 @@ class StatisticDataTimestamp(StatisticDataTimestampBase, StatisticMixIn, total=F
last_reset_ts: float | None
+class StatisticMeanType(IntEnum):
+ """Statistic mean type."""
+
+ NONE = 0
+ ARITHMETIC = 1
+ CIRCULAR = 2
+
+
class StatisticMetaData(TypedDict):
"""Statistic meta data class."""
- has_mean: bool
+ # has_mean is deprecated, use mean_type instead. has_mean will be removed in 2026.4
+ has_mean: NotRequired[bool]
+ mean_type: StatisticMeanType
has_sum: bool
name: str | None
source: str
diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py
index 2b6640270ed..80c0028ef7a 100644
--- a/homeassistant/components/recorder/statistics.py
+++ b/homeassistant/components/recorder/statistics.py
@@ -9,12 +9,23 @@ from datetime import datetime, timedelta
from functools import lru_cache, partial
from itertools import chain, groupby
import logging
+import math
from operator import itemgetter
import re
from time import time as time_time
-from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
+from typing import TYPE_CHECKING, Any, Literal, Required, TypedDict, cast
-from sqlalchemy import Select, and_, bindparam, func, lambda_stmt, select, text
+from sqlalchemy import (
+ Label,
+ Select,
+ and_,
+ bindparam,
+ case,
+ func,
+ lambda_stmt,
+ select,
+ text,
+)
from sqlalchemy.engine.row import Row
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm.session import Session
@@ -24,9 +35,12 @@ import voluptuous as vol
from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant, callback, valid_entity_id
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.recorder import DATA_RECORDER
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from homeassistant.util import dt as dt_util
+from homeassistant.util.collection import chunked_or_all
+from homeassistant.util.enum import try_parse_enum
from homeassistant.util.unit_conversion import (
AreaConverter,
BaseUnitConverter,
@@ -58,6 +72,7 @@ from .const import (
INTEGRATION_PLATFORM_LIST_STATISTIC_IDS,
INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES,
INTEGRATION_PLATFORM_VALIDATE_STATISTICS,
+ MAX_IDS_FOR_INDEXED_GROUP_BY,
SupportedDialect,
)
from .db_schema import (
@@ -71,6 +86,7 @@ from .db_schema import (
from .models import (
StatisticData,
StatisticDataTimestamp,
+ StatisticMeanType,
StatisticMetaData,
StatisticResult,
datetime_to_timestamp_or_none,
@@ -110,11 +126,53 @@ QUERY_STATISTICS_SHORT_TERM = (
StatisticsShortTerm.sum,
)
+
+def query_circular_mean(table: type[StatisticsBase]) -> tuple[Label, Label]:
+ """Return the sqlalchemy function for circular mean and the mean_weight.
+
+ The result must be modulo 360 to normalize the result [0, 360].
+ """
+ # Postgres doesn't support modulo for double precision and
+ # the other dbs return the remainder instead of the modulo
+ # meaning negative values are possible. For these reason
+ # we need to normalize the result to be in the range [0, 360)
+ # in Python.
+ # https://en.wikipedia.org/wiki/Circular_mean
+ radians = func.radians(table.mean)
+ weighted_sum_sin = func.sum(func.sin(radians) * table.mean_weight)
+ weighted_sum_cos = func.sum(func.cos(radians) * table.mean_weight)
+ weight = func.sqrt(
+ func.power(weighted_sum_sin, 2) + func.power(weighted_sum_cos, 2)
+ )
+ return (
+ func.degrees(func.atan2(weighted_sum_sin, weighted_sum_cos)).label("mean"),
+ weight.label("mean_weight"),
+ )
+
+
QUERY_STATISTICS_SUMMARY_MEAN = (
StatisticsShortTerm.metadata_id,
- func.avg(StatisticsShortTerm.mean),
func.min(StatisticsShortTerm.min),
func.max(StatisticsShortTerm.max),
+ case(
+ (
+ StatisticsMeta.mean_type == StatisticMeanType.ARITHMETIC,
+ func.avg(StatisticsShortTerm.mean),
+ ),
+ (
+ StatisticsMeta.mean_type == StatisticMeanType.CIRCULAR,
+ query_circular_mean(StatisticsShortTerm)[0],
+ ),
+ else_=None,
+ ),
+ case(
+ (
+ StatisticsMeta.mean_type == StatisticMeanType.CIRCULAR,
+ query_circular_mean(StatisticsShortTerm)[1],
+ ),
+ else_=None,
+ ),
+ StatisticsMeta.mean_type,
)
QUERY_STATISTICS_SUMMARY_SUM = (
@@ -133,31 +191,28 @@ QUERY_STATISTICS_SUMMARY_SUM = (
STATISTIC_UNIT_TO_UNIT_CONVERTER: dict[str | None, type[BaseUnitConverter]] = {
- **{unit: AreaConverter for unit in AreaConverter.VALID_UNITS},
- **{
- unit: BloodGlucoseConcentrationConverter
- for unit in BloodGlucoseConcentrationConverter.VALID_UNITS
- },
- **{unit: ConductivityConverter for unit in ConductivityConverter.VALID_UNITS},
- **{unit: DataRateConverter for unit in DataRateConverter.VALID_UNITS},
- **{unit: DistanceConverter for unit in DistanceConverter.VALID_UNITS},
- **{unit: DurationConverter for unit in DurationConverter.VALID_UNITS},
- **{unit: ElectricCurrentConverter for unit in ElectricCurrentConverter.VALID_UNITS},
- **{
- unit: ElectricPotentialConverter
- for unit in ElectricPotentialConverter.VALID_UNITS
- },
- **{unit: EnergyConverter for unit in EnergyConverter.VALID_UNITS},
- **{unit: EnergyDistanceConverter for unit in EnergyDistanceConverter.VALID_UNITS},
- **{unit: InformationConverter for unit in InformationConverter.VALID_UNITS},
- **{unit: MassConverter for unit in MassConverter.VALID_UNITS},
- **{unit: PowerConverter for unit in PowerConverter.VALID_UNITS},
- **{unit: PressureConverter for unit in PressureConverter.VALID_UNITS},
- **{unit: SpeedConverter for unit in SpeedConverter.VALID_UNITS},
- **{unit: TemperatureConverter for unit in TemperatureConverter.VALID_UNITS},
- **{unit: UnitlessRatioConverter for unit in UnitlessRatioConverter.VALID_UNITS},
- **{unit: VolumeConverter for unit in VolumeConverter.VALID_UNITS},
- **{unit: VolumeFlowRateConverter for unit in VolumeFlowRateConverter.VALID_UNITS},
+ **dict.fromkeys(AreaConverter.VALID_UNITS, AreaConverter),
+ **dict.fromkeys(
+ BloodGlucoseConcentrationConverter.VALID_UNITS,
+ BloodGlucoseConcentrationConverter,
+ ),
+ **dict.fromkeys(ConductivityConverter.VALID_UNITS, ConductivityConverter),
+ **dict.fromkeys(DataRateConverter.VALID_UNITS, DataRateConverter),
+ **dict.fromkeys(DistanceConverter.VALID_UNITS, DistanceConverter),
+ **dict.fromkeys(DurationConverter.VALID_UNITS, DurationConverter),
+ **dict.fromkeys(ElectricCurrentConverter.VALID_UNITS, ElectricCurrentConverter),
+ **dict.fromkeys(ElectricPotentialConverter.VALID_UNITS, ElectricPotentialConverter),
+ **dict.fromkeys(EnergyConverter.VALID_UNITS, EnergyConverter),
+ **dict.fromkeys(EnergyDistanceConverter.VALID_UNITS, EnergyDistanceConverter),
+ **dict.fromkeys(InformationConverter.VALID_UNITS, InformationConverter),
+ **dict.fromkeys(MassConverter.VALID_UNITS, MassConverter),
+ **dict.fromkeys(PowerConverter.VALID_UNITS, PowerConverter),
+ **dict.fromkeys(PressureConverter.VALID_UNITS, PressureConverter),
+ **dict.fromkeys(SpeedConverter.VALID_UNITS, SpeedConverter),
+ **dict.fromkeys(TemperatureConverter.VALID_UNITS, TemperatureConverter),
+ **dict.fromkeys(UnitlessRatioConverter.VALID_UNITS, UnitlessRatioConverter),
+ **dict.fromkeys(VolumeConverter.VALID_UNITS, VolumeConverter),
+ **dict.fromkeys(VolumeFlowRateConverter.VALID_UNITS, VolumeFlowRateConverter),
}
@@ -180,6 +235,26 @@ def mean(values: list[float]) -> float | None:
return sum(values) / len(values)
+DEG_TO_RAD = math.pi / 180
+RAD_TO_DEG = 180 / math.pi
+
+
+def weighted_circular_mean(
+ values: Iterable[tuple[float, float]],
+) -> tuple[float, float]:
+ """Return the weighted circular mean and the weight of the values."""
+ weighted_sin_sum, weighted_cos_sum = 0.0, 0.0
+ for x, weight in values:
+ rad_x = x * DEG_TO_RAD
+ weighted_sin_sum += math.sin(rad_x) * weight
+ weighted_cos_sum += math.cos(rad_x) * weight
+
+ return (
+ (RAD_TO_DEG * math.atan2(weighted_sin_sum, weighted_cos_sum)) % 360,
+ math.sqrt(weighted_sin_sum**2 + weighted_cos_sum**2),
+ )
+
+
_LOGGER = logging.getLogger(__name__)
@@ -226,6 +301,7 @@ class StatisticsRow(BaseStatisticsRow, total=False):
min: float | None
max: float | None
mean: float | None
+ mean_weight: float | None
change: float | None
@@ -372,11 +448,19 @@ def _compile_hourly_statistics_summary_mean_stmt(
start_time_ts: float, end_time_ts: float
) -> StatementLambdaElement:
"""Generate the summary mean statement for hourly statistics."""
+ # Due the fact that we support different mean type (See StatisticMeanType)
+ # we need to join here with the StatisticsMeta table to get the mean type
+ # and then use a case statement to compute the mean based on the mean type.
+ # As we use the StatisticsMeta.mean_type in the select case statement we need
+ # to group by it as well.
return lambda_stmt(
lambda: select(*QUERY_STATISTICS_SUMMARY_MEAN)
.filter(StatisticsShortTerm.start_ts >= start_time_ts)
.filter(StatisticsShortTerm.start_ts < end_time_ts)
- .group_by(StatisticsShortTerm.metadata_id)
+ .join(
+ StatisticsMeta, and_(StatisticsShortTerm.metadata_id == StatisticsMeta.id)
+ )
+ .group_by(StatisticsShortTerm.metadata_id, StatisticsMeta.mean_type)
.order_by(StatisticsShortTerm.metadata_id)
)
@@ -418,10 +502,17 @@ def _compile_hourly_statistics(session: Session, start: datetime) -> None:
if stats:
for stat in stats:
- metadata_id, _mean, _min, _max = stat
+ metadata_id, _min, _max, _mean, _mean_weight, _mean_type = stat
+ if (
+ try_parse_enum(StatisticMeanType, _mean_type)
+ is StatisticMeanType.CIRCULAR
+ ):
+ # Normalize the circular mean to be in the range [0, 360)
+ _mean = _mean % 360
summary[metadata_id] = {
"start_ts": start_time_ts,
"mean": _mean,
+ "mean_weight": _mean_weight,
"min": _min,
"max": _max,
}
@@ -561,7 +652,9 @@ def _compile_statistics(
platform_stats: list[StatisticResult] = []
current_metadata: dict[str, tuple[int, StatisticMetaData]] = {}
# Collect statistics from all platforms implementing support
- for domain, platform in instance.hass.data[DOMAIN].recorder_platforms.items():
+ for domain, platform in instance.hass.data[
+ DATA_RECORDER
+ ].recorder_platforms.items():
if not (
platform_compile_statistics := getattr(
platform, INTEGRATION_PLATFORM_COMPILE_STATISTICS, None
@@ -599,7 +692,7 @@ def _compile_statistics(
if start.minute == 50:
# Once every hour, update issues
- for platform in instance.hass.data[DOMAIN].recorder_platforms.values():
+ for platform in instance.hass.data[DATA_RECORDER].recorder_platforms.values():
if not (
platform_update_issues := getattr(
platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None
@@ -825,7 +918,7 @@ def _statistic_by_id_from_metadata(
"display_unit_of_measurement": get_display_unit(
hass, meta["statistic_id"], meta["unit_of_measurement"]
),
- "has_mean": meta["has_mean"],
+ "mean_type": meta["mean_type"],
"has_sum": meta["has_sum"],
"name": meta["name"],
"source": meta["source"],
@@ -844,7 +937,9 @@ def _flatten_list_statistic_ids_metadata_result(
{
"statistic_id": _id,
"display_unit_of_measurement": info["display_unit_of_measurement"],
- "has_mean": info["has_mean"],
+ "has_mean": info["mean_type"]
+ == StatisticMeanType.ARITHMETIC, # Can be removed with 2026.4
+ "mean_type": info["mean_type"],
"has_sum": info["has_sum"],
"name": info.get("name"),
"source": info["source"],
@@ -882,7 +977,7 @@ def list_statistic_ids(
# the integrations for the missing ones.
#
# Query all integrations with a registered recorder platform
- for platform in hass.data[DOMAIN].recorder_platforms.values():
+ for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
if not (
platform_list_statistic_ids := getattr(
platform, INTEGRATION_PLATFORM_LIST_STATISTIC_IDS, None
@@ -899,7 +994,7 @@ def list_statistic_ids(
continue
result[key] = {
"display_unit_of_measurement": meta["unit_of_measurement"],
- "has_mean": meta["has_mean"],
+ "mean_type": meta["mean_type"],
"has_sum": meta["has_sum"],
"name": meta["name"],
"source": meta["source"],
@@ -917,6 +1012,7 @@ def _reduce_statistics(
period_start_end: Callable[[float], tuple[float, float]],
period: timedelta,
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
+ metadata: dict[str, tuple[int, StatisticMetaData]],
) -> dict[str, list[StatisticsRow]]:
"""Reduce hourly statistics to daily or monthly statistics."""
result: dict[str, list[StatisticsRow]] = defaultdict(list)
@@ -929,7 +1025,7 @@ def _reduce_statistics(
_want_sum = "sum" in types
for statistic_id, stat_list in stats.items():
max_values: list[float] = []
- mean_values: list[float] = []
+ mean_values: list[tuple[float, float]] = []
min_values: list[float] = []
prev_stat: StatisticsRow = stat_list[0]
fake_entry: StatisticsRow = {"start": stat_list[-1]["start"] + period_seconds}
@@ -944,7 +1040,16 @@ def _reduce_statistics(
"end": end,
}
if _want_mean:
- row["mean"] = mean(mean_values) if mean_values else None
+ row["mean"] = None
+ row["mean_weight"] = None
+ if mean_values:
+ match metadata[statistic_id][1]["mean_type"]:
+ case StatisticMeanType.ARITHMETIC:
+ row["mean"] = mean([x[0] for x in mean_values])
+ case StatisticMeanType.CIRCULAR:
+ row["mean"], row["mean_weight"] = (
+ weighted_circular_mean(mean_values)
+ )
mean_values.clear()
if _want_min:
row["min"] = min(min_values) if min_values else None
@@ -961,8 +1066,10 @@ def _reduce_statistics(
result[statistic_id].append(row)
if _want_max and (_max := statistic.get("max")) is not None:
max_values.append(_max)
- if _want_mean and (_mean := statistic.get("mean")) is not None:
- mean_values.append(_mean)
+ if _want_mean:
+ if (_mean := statistic.get("mean")) is not None:
+ _mean_weight = statistic.get("mean_weight") or 0.0
+ mean_values.append((_mean, _mean_weight))
if _want_min and (_min := statistic.get("min")) is not None:
min_values.append(_min)
prev_stat = statistic
@@ -1009,11 +1116,12 @@ def reduce_day_ts_factory() -> tuple[
def _reduce_statistics_per_day(
stats: dict[str, list[StatisticsRow]],
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
+ metadata: dict[str, tuple[int, StatisticMetaData]],
) -> dict[str, list[StatisticsRow]]:
"""Reduce hourly statistics to daily statistics."""
_same_day_ts, _day_start_end_ts = reduce_day_ts_factory()
return _reduce_statistics(
- stats, _same_day_ts, _day_start_end_ts, timedelta(days=1), types
+ stats, _same_day_ts, _day_start_end_ts, timedelta(days=1), types, metadata
)
@@ -1057,11 +1165,12 @@ def reduce_week_ts_factory() -> tuple[
def _reduce_statistics_per_week(
stats: dict[str, list[StatisticsRow]],
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
+ metadata: dict[str, tuple[int, StatisticMetaData]],
) -> dict[str, list[StatisticsRow]]:
"""Reduce hourly statistics to weekly statistics."""
_same_week_ts, _week_start_end_ts = reduce_week_ts_factory()
return _reduce_statistics(
- stats, _same_week_ts, _week_start_end_ts, timedelta(days=7), types
+ stats, _same_week_ts, _week_start_end_ts, timedelta(days=7), types, metadata
)
@@ -1110,11 +1219,12 @@ def reduce_month_ts_factory() -> tuple[
def _reduce_statistics_per_month(
stats: dict[str, list[StatisticsRow]],
types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
+ metadata: dict[str, tuple[int, StatisticMetaData]],
) -> dict[str, list[StatisticsRow]]:
"""Reduce hourly statistics to monthly statistics."""
_same_month_ts, _month_start_end_ts = reduce_month_ts_factory()
return _reduce_statistics(
- stats, _same_month_ts, _month_start_end_ts, timedelta(days=31), types
+ stats, _same_month_ts, _month_start_end_ts, timedelta(days=31), types, metadata
)
@@ -1158,27 +1268,41 @@ def _generate_max_mean_min_statistic_in_sub_period_stmt(
return stmt
+class _MaxMinMeanStatisticSubPeriod(TypedDict, total=False):
+ max: float
+ mean_acc: float
+ min: float
+ duration: float
+ circular_means: Required[list[tuple[float, float]]]
+
+
def _get_max_mean_min_statistic_in_sub_period(
session: Session,
- result: dict[str, float],
+ result: _MaxMinMeanStatisticSubPeriod,
start_time: datetime | None,
end_time: datetime | None,
table: type[StatisticsBase],
types: set[Literal["max", "mean", "min", "change"]],
- metadata_id: int,
+ metadata: tuple[int, StatisticMetaData],
) -> None:
"""Return max, mean and min during the period."""
# Calculate max, mean, min
+ mean_type = metadata[1]["mean_type"]
columns = select()
if "max" in types:
columns = columns.add_columns(func.max(table.max))
if "mean" in types:
- columns = columns.add_columns(func.avg(table.mean))
- columns = columns.add_columns(func.count(table.mean))
+ match mean_type:
+ case StatisticMeanType.ARITHMETIC:
+ columns = columns.add_columns(func.avg(table.mean))
+ columns = columns.add_columns(func.count(table.mean))
+ case StatisticMeanType.CIRCULAR:
+ columns = columns.add_columns(*query_circular_mean(table))
if "min" in types:
columns = columns.add_columns(func.min(table.min))
+
stmt = _generate_max_mean_min_statistic_in_sub_period_stmt(
- columns, start_time, end_time, table, metadata_id
+ columns, start_time, end_time, table, metadata[0]
)
stats = cast(Sequence[Row[Any]], execute_stmt_lambda_element(session, stmt))
if not stats:
@@ -1186,11 +1310,21 @@ def _get_max_mean_min_statistic_in_sub_period(
if "max" in types and (new_max := stats[0].max) is not None:
old_max = result.get("max")
result["max"] = max(new_max, old_max) if old_max is not None else new_max
- if "mean" in types and stats[0].avg is not None:
+ if "mean" in types:
# https://github.com/sqlalchemy/sqlalchemy/issues/9127
- duration = stats[0].count * table.duration.total_seconds() # type: ignore[operator]
- result["duration"] = result.get("duration", 0.0) + duration
- result["mean_acc"] = result.get("mean_acc", 0.0) + stats[0].avg * duration
+ match mean_type:
+ case StatisticMeanType.ARITHMETIC:
+ duration = stats[0].count * table.duration.total_seconds() # type: ignore[operator]
+ if stats[0].avg is not None:
+ result["duration"] = result.get("duration", 0.0) + duration
+ result["mean_acc"] = (
+ result.get("mean_acc", 0.0) + stats[0].avg * duration
+ )
+ case StatisticMeanType.CIRCULAR:
+ if (new_circular_mean := stats[0].mean) is not None and (
+ weight := stats[0].mean_weight
+ ) is not None:
+ result["circular_means"].append((new_circular_mean, weight))
if "min" in types and (new_min := stats[0].min) is not None:
old_min = result.get("min")
result["min"] = min(new_min, old_min) if old_min is not None else new_min
@@ -1205,15 +1339,15 @@ def _get_max_mean_min_statistic(
tail_start_time: datetime | None,
tail_end_time: datetime | None,
tail_only: bool,
- metadata_id: int,
+ metadata: tuple[int, StatisticMetaData],
types: set[Literal["max", "mean", "min", "change"]],
) -> dict[str, float | None]:
"""Return max, mean and min during the period.
- The mean is a time weighted average, combining hourly and 5-minute statistics if
+ The mean is time weighted, combining hourly and 5-minute statistics if
necessary.
"""
- max_mean_min: dict[str, float] = {}
+ max_mean_min = _MaxMinMeanStatisticSubPeriod(circular_means=[])
result: dict[str, float | None] = {}
if tail_start_time is not None:
@@ -1225,7 +1359,7 @@ def _get_max_mean_min_statistic(
tail_end_time,
StatisticsShortTerm,
types,
- metadata_id,
+ metadata,
)
if not tail_only:
@@ -1236,7 +1370,7 @@ def _get_max_mean_min_statistic(
main_end_time,
Statistics,
types,
- metadata_id,
+ metadata,
)
if head_start_time is not None:
@@ -1247,16 +1381,23 @@ def _get_max_mean_min_statistic(
head_end_time,
StatisticsShortTerm,
types,
- metadata_id,
+ metadata,
)
if "max" in types:
result["max"] = max_mean_min.get("max")
if "mean" in types:
- if "mean_acc" not in max_mean_min:
- result["mean"] = None
- else:
- result["mean"] = max_mean_min["mean_acc"] / max_mean_min["duration"]
+ mean_value = None
+ match metadata[1]["mean_type"]:
+ case StatisticMeanType.CIRCULAR:
+ if circular_means := max_mean_min["circular_means"]:
+ mean_value = weighted_circular_mean(circular_means)[0]
+ case StatisticMeanType.ARITHMETIC:
+ if (mean_value := max_mean_min.get("mean_acc")) is not None and (
+ duration := max_mean_min.get("duration")
+ ) is not None:
+ mean_value = mean_value / duration
+ result["mean"] = mean_value
if "min" in types:
result["min"] = max_mean_min.get("min")
return result
@@ -1557,7 +1698,7 @@ def statistic_during_period(
tail_start_time,
tail_end_time,
tail_only,
- metadata_id,
+ metadata,
types,
)
@@ -1604,12 +1745,12 @@ def statistic_during_period(
_type_column_mapping = {
- "last_reset": "last_reset_ts",
- "max": "max",
- "mean": "mean",
- "min": "min",
- "state": "state",
- "sum": "sum",
+ "last_reset": ("last_reset_ts",),
+ "max": ("max",),
+ "mean": ("mean", "mean_weight"),
+ "min": ("min",),
+ "state": ("state",),
+ "sum": ("sum",),
}
@@ -1621,12 +1762,13 @@ def _generate_select_columns_for_types_stmt(
track_on: list[str | None] = [
table.__tablename__, # type: ignore[attr-defined]
]
- for key, column in _type_column_mapping.items():
- if key in types:
- columns = columns.add_columns(getattr(table, column))
- track_on.append(column)
- else:
- track_on.append(None)
+ for key, type_columns in _type_column_mapping.items():
+ for column in type_columns:
+ if key in types:
+ columns = columns.add_columns(getattr(table, column))
+ track_on.append(column)
+ else:
+ track_on.append(None)
return lambda_stmt(lambda: columns, track_on=track_on)
@@ -1640,7 +1782,7 @@ def _extract_metadata_and_discard_impossible_columns(
has_sum = False
for metadata_id, stats_metadata in metadata.values():
metadata_ids.append(metadata_id)
- has_mean |= stats_metadata["has_mean"]
+ has_mean |= stats_metadata["mean_type"] is not StatisticMeanType.NONE
has_sum |= stats_metadata["has_sum"]
if not has_mean:
types.discard("mean")
@@ -1666,6 +1808,7 @@ def _augment_result_with_change(
drop_sum = "sum" not in _types
prev_sums = {}
if tmp := _statistics_at_time(
+ get_instance(hass),
session,
{metadata[statistic_id][0] for statistic_id in result},
table,
@@ -1795,19 +1938,25 @@ def _statistics_during_period_with_session(
)
if period == "day":
- result = _reduce_statistics_per_day(result, types)
+ result = _reduce_statistics_per_day(result, types, metadata)
if period == "week":
- result = _reduce_statistics_per_week(result, types)
+ result = _reduce_statistics_per_week(result, types, metadata)
if period == "month":
- result = _reduce_statistics_per_month(result, types)
+ result = _reduce_statistics_per_month(result, types, metadata)
if "change" in _types:
_augment_result_with_change(
hass, session, start_time, units, _types, table, metadata, result
)
+ # filter out mean_weight as it is only needed to reduce statistics
+ # and not needed in the result
+ for stats_rows in result.values():
+ for row in stats_rows:
+ row.pop("mean_weight", None)
+
# Return statistics combined with metadata
return result
@@ -2024,7 +2173,39 @@ def get_latest_short_term_statistics_with_session(
)
-def _generate_statistics_at_time_stmt(
+def _generate_statistics_at_time_stmt_group_by(
+ table: type[StatisticsBase],
+ metadata_ids: set[int],
+ start_time_ts: float,
+ types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]],
+) -> StatementLambdaElement:
+ """Create the statement for finding the statistics for a given time."""
+ # Simple group-by for MySQL, must use less
+ # than 1000 metadata_ids in the IN clause for MySQL
+ # or it will optimize poorly. Callers are responsible
+ # for ensuring that the number of metadata_ids is less
+ # than 1000.
+ return _generate_select_columns_for_types_stmt(table, types) + (
+ lambda q: q.join(
+ most_recent_statistic_ids := (
+ select(
+ func.max(table.start_ts).label("max_start_ts"),
+ table.metadata_id.label("max_metadata_id"),
+ )
+ .filter(table.start_ts < start_time_ts)
+ .filter(table.metadata_id.in_(metadata_ids))
+ .group_by(table.metadata_id)
+ .subquery()
+ ),
+ and_(
+ table.start_ts == most_recent_statistic_ids.c.max_start_ts,
+ table.metadata_id == most_recent_statistic_ids.c.max_metadata_id,
+ ),
+ )
+ )
+
+
+def _generate_statistics_at_time_stmt_dependent_sub_query(
table: type[StatisticsBase],
metadata_ids: set[int],
start_time_ts: float,
@@ -2038,8 +2219,7 @@ def _generate_statistics_at_time_stmt(
# databases. Since all databases support this query as a join
# condition we can use it as a subquery to get the last start_time_ts
# before a specific point in time for all entities.
- stmt = _generate_select_columns_for_types_stmt(table, types)
- stmt += (
+ return _generate_select_columns_for_types_stmt(table, types) + (
lambda q: q.select_from(StatisticsMeta)
.join(
table,
@@ -2061,10 +2241,10 @@ def _generate_statistics_at_time_stmt(
)
.where(table.metadata_id.in_(metadata_ids))
)
- return stmt
def _statistics_at_time(
+ instance: Recorder,
session: Session,
metadata_ids: set[int],
table: type[StatisticsBase],
@@ -2073,8 +2253,41 @@ def _statistics_at_time(
) -> Sequence[Row] | None:
"""Return last known statistics, earlier than start_time, for the metadata_ids."""
start_time_ts = start_time.timestamp()
- stmt = _generate_statistics_at_time_stmt(table, metadata_ids, start_time_ts, types)
- return cast(Sequence[Row], execute_stmt_lambda_element(session, stmt))
+ if TYPE_CHECKING:
+ assert instance.database_engine is not None
+ if not instance.database_engine.optimizer.slow_dependent_subquery:
+ stmt = _generate_statistics_at_time_stmt_dependent_sub_query(
+ table=table,
+ metadata_ids=metadata_ids,
+ start_time_ts=start_time_ts,
+ types=types,
+ )
+ return cast(list[Row], execute_stmt_lambda_element(session, stmt))
+ rows: list[Row] = []
+ # https://github.com/home-assistant/core/issues/132865
+ # If we include the start time state we need to limit the
+ # number of metadata_ids we query for at a time to avoid
+ # hitting limits in the MySQL optimizer that prevent
+ # the start time state query from using an index-only optimization
+ # to find the start time state.
+ for metadata_ids_chunk in chunked_or_all(
+ metadata_ids, MAX_IDS_FOR_INDEXED_GROUP_BY
+ ):
+ stmt = _generate_statistics_at_time_stmt_group_by(
+ table=table,
+ metadata_ids=metadata_ids_chunk,
+ start_time_ts=start_time_ts,
+ types=types,
+ )
+ row_chunk = cast(list[Row], execute_stmt_lambda_element(session, stmt))
+ if rows:
+ rows += row_chunk
+ else:
+ # If we have no rows yet, we can just assign the chunk
+ # as this is the common case since its rare that
+ # we exceed the MAX_IDS_FOR_INDEXED_GROUP_BY limit
+ rows = row_chunk
+ return rows
def _build_sum_converted_stats(
@@ -2191,7 +2404,12 @@ def _sorted_statistics_to_dict(
field_map["last_reset"] = field_map.pop("last_reset_ts")
sum_idx = field_map["sum"] if "sum" in types else None
sum_only = len(types) == 1 and sum_idx is not None
- row_mapping = tuple((key, field_map[key]) for key in types if key in field_map)
+ row_mapping = tuple(
+ (column, field_map[column])
+ for key in types
+ for column in ({key, *_type_column_mapping.get(key, ())})
+ if column in field_map
+ )
# Append all statistic entries, and optionally do unit conversion
table_duration_seconds = table.duration.total_seconds()
for meta_id, db_rows in stats_by_meta_id.items():
@@ -2232,7 +2450,7 @@ def _sorted_statistics_to_dict(
def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]:
"""Validate statistics."""
platform_validation: dict[str, list[ValidationIssue]] = {}
- for platform in hass.data[DOMAIN].recorder_platforms.values():
+ for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
if platform_validate_statistics := getattr(
platform, INTEGRATION_PLATFORM_VALIDATE_STATISTICS, None
):
@@ -2243,7 +2461,7 @@ def validate_statistics(hass: HomeAssistant) -> dict[str, list[ValidationIssue]]
def update_statistics_issues(hass: HomeAssistant) -> None:
"""Update statistics issues."""
with session_scope(hass=hass, read_only=True) as session:
- for platform in hass.data[DOMAIN].recorder_platforms.values():
+ for platform in hass.data[DATA_RECORDER].recorder_platforms.values():
if platform_update_statistics_issues := getattr(
platform, INTEGRATION_PLATFORM_UPDATE_STATISTICS_ISSUES, None
):
diff --git a/homeassistant/components/recorder/strings.json b/homeassistant/components/recorder/strings.json
index 43c2ecdc14f..0c8d47548bf 100644
--- a/homeassistant/components/recorder/strings.json
+++ b/homeassistant/components/recorder/strings.json
@@ -43,15 +43,15 @@
"fields": {
"entity_id": {
"name": "Entities to remove",
- "description": "List of entities for which the data is to be removed from the recorder database."
+ "description": "List of entities for which the data is to be removed from the Recorder database."
},
"domains": {
"name": "Domains to remove",
- "description": "List of domains for which the data needs to be removed from the recorder database."
+ "description": "List of domains for which the data needs to be removed from the Recorder database."
},
"entity_globs": {
"name": "Entity globs to remove",
- "description": "List of glob patterns used to select the entities for which the data is to be removed from the recorder database."
+ "description": "List of glob patterns used to select the entities for which the data is to be removed from the Recorder database."
},
"keep_days": {
"name": "[%key:component::recorder::services::purge::fields::keep_days::name%]",
diff --git a/homeassistant/components/recorder/table_managers/statistics_meta.py b/homeassistant/components/recorder/table_managers/statistics_meta.py
index 77fc34518db..634e9565c12 100644
--- a/homeassistant/components/recorder/table_managers/statistics_meta.py
+++ b/homeassistant/components/recorder/table_managers/statistics_meta.py
@@ -4,16 +4,18 @@ from __future__ import annotations
import logging
import threading
-from typing import TYPE_CHECKING, Final, Literal
+from typing import TYPE_CHECKING, Any, Final, Literal
from lru import LRU
from sqlalchemy import lambda_stmt, select
+from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.orm.session import Session
from sqlalchemy.sql.expression import true
from sqlalchemy.sql.lambdas import StatementLambdaElement
+from ..const import CIRCULAR_MEAN_SCHEMA_VERSION
from ..db_schema import StatisticsMeta
-from ..models import StatisticMetaData
+from ..models import StatisticMeanType, StatisticMetaData
from ..util import execute_stmt_lambda_element
if TYPE_CHECKING:
@@ -28,7 +30,6 @@ QUERY_STATISTIC_META = (
StatisticsMeta.statistic_id,
StatisticsMeta.source,
StatisticsMeta.unit_of_measurement,
- StatisticsMeta.has_mean,
StatisticsMeta.has_sum,
StatisticsMeta.name,
)
@@ -37,24 +38,38 @@ INDEX_ID: Final = 0
INDEX_STATISTIC_ID: Final = 1
INDEX_SOURCE: Final = 2
INDEX_UNIT_OF_MEASUREMENT: Final = 3
-INDEX_HAS_MEAN: Final = 4
-INDEX_HAS_SUM: Final = 5
-INDEX_NAME: Final = 6
+INDEX_HAS_SUM: Final = 4
+INDEX_NAME: Final = 5
+INDEX_MEAN_TYPE: Final = 6
def _generate_get_metadata_stmt(
statistic_ids: set[str] | None = None,
statistic_type: Literal["mean", "sum"] | None = None,
statistic_source: str | None = None,
+ schema_version: int = 0,
) -> StatementLambdaElement:
- """Generate a statement to fetch metadata."""
- stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META))
+ """Generate a statement to fetch metadata with the passed filters.
+
+ Depending on the schema version, either mean_type (added in version 49) or has_mean column is used.
+ """
+ columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META)
+ if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
+ columns.append(StatisticsMeta.mean_type)
+ else:
+ columns.append(StatisticsMeta.has_mean)
+ stmt = lambda_stmt(lambda: select(*columns))
if statistic_ids:
stmt += lambda q: q.where(StatisticsMeta.statistic_id.in_(statistic_ids))
if statistic_source is not None:
stmt += lambda q: q.where(StatisticsMeta.source == statistic_source)
if statistic_type == "mean":
- stmt += lambda q: q.where(StatisticsMeta.has_mean == true())
+ if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
+ stmt += lambda q: q.where(
+ StatisticsMeta.mean_type != StatisticMeanType.NONE
+ )
+ else:
+ stmt += lambda q: q.where(StatisticsMeta.has_mean == true())
elif statistic_type == "sum":
stmt += lambda q: q.where(StatisticsMeta.has_sum == true())
return stmt
@@ -100,14 +115,34 @@ class StatisticsMetaManager:
for row in execute_stmt_lambda_element(
session,
_generate_get_metadata_stmt(
- statistic_ids, statistic_type, statistic_source
+ statistic_ids,
+ statistic_type,
+ statistic_source,
+ self.recorder.schema_version,
),
orm_rows=False,
):
statistic_id = row[INDEX_STATISTIC_ID]
row_id = row[INDEX_ID]
+ if self.recorder.schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
+ try:
+ mean_type = StatisticMeanType(row[INDEX_MEAN_TYPE])
+ except ValueError:
+ _LOGGER.warning(
+ "Invalid mean type found for statistic_id: %s, mean_type: %s. Skipping",
+ statistic_id,
+ row[INDEX_MEAN_TYPE],
+ )
+ continue
+ else:
+ mean_type = (
+ StatisticMeanType.ARITHMETIC
+ if row[INDEX_MEAN_TYPE]
+ else StatisticMeanType.NONE
+ )
meta = {
- "has_mean": row[INDEX_HAS_MEAN],
+ "has_mean": mean_type is StatisticMeanType.ARITHMETIC,
+ "mean_type": mean_type,
"has_sum": row[INDEX_HAS_SUM],
"name": row[INDEX_NAME],
"source": row[INDEX_SOURCE],
@@ -157,9 +192,18 @@ class StatisticsMetaManager:
This call is not thread-safe and must be called from the
recorder thread.
"""
+ if "mean_type" not in new_metadata:
+ # To maintain backward compatibility after adding 'mean_type' in schema version 49,
+ # we must still check for its presence. Even though type hints suggest it should always exist,
+ # custom integrations might omit it, so we need to guard against that.
+ new_metadata["mean_type"] = ( # type: ignore[unreachable]
+ StatisticMeanType.ARITHMETIC
+ if new_metadata["has_mean"]
+ else StatisticMeanType.NONE
+ )
metadata_id, old_metadata = old_metadata_dict[statistic_id]
if not (
- old_metadata["has_mean"] != new_metadata["has_mean"]
+ old_metadata["mean_type"] != new_metadata["mean_type"]
or old_metadata["has_sum"] != new_metadata["has_sum"]
or old_metadata["name"] != new_metadata["name"]
or old_metadata["unit_of_measurement"]
@@ -170,7 +214,7 @@ class StatisticsMetaManager:
self._assert_in_recorder_thread()
session.query(StatisticsMeta).filter_by(statistic_id=statistic_id).update(
{
- StatisticsMeta.has_mean: new_metadata["has_mean"],
+ StatisticsMeta.mean_type: new_metadata["mean_type"],
StatisticsMeta.has_sum: new_metadata["has_sum"],
StatisticsMeta.name: new_metadata["name"],
StatisticsMeta.unit_of_measurement: new_metadata["unit_of_measurement"],
diff --git a/homeassistant/components/recorder/tasks.py b/homeassistant/components/recorder/tasks.py
index fa10c12aa68..f5ad7f2a3d9 100644
--- a/homeassistant/components/recorder/tasks.py
+++ b/homeassistant/components/recorder/tasks.py
@@ -11,11 +11,11 @@ import logging
import threading
from typing import TYPE_CHECKING, Any
+from homeassistant.helpers.recorder import DATA_RECORDER
from homeassistant.helpers.typing import UndefinedType
from homeassistant.util.event_type import EventType
from . import entity_registry, purge, statistics
-from .const import DOMAIN
from .db_schema import Statistics, StatisticsShortTerm
from .models import StatisticData, StatisticMetaData
from .util import periodic_db_cleanups, session_scope
@@ -308,7 +308,7 @@ class AddRecorderPlatformTask(RecorderTask):
hass = instance.hass
domain = self.domain
platform = self.platform
- platforms: dict[str, Any] = hass.data[DOMAIN].recorder_platforms
+ platforms: dict[str, Any] = hass.data[DATA_RECORDER].recorder_platforms
platforms[domain] = platform
@@ -317,13 +317,18 @@ class SynchronizeTask(RecorderTask):
"""Ensure all pending data has been committed."""
# commit_before is the default
- event: asyncio.Event
+ future: asyncio.Future
def run(self, instance: Recorder) -> None:
"""Handle the task."""
# Does not use a tracked task to avoid
# blocking shutdown if the recorder is broken
- instance.hass.loop.call_soon_threadsafe(self.event.set)
+ instance.hass.loop.call_soon_threadsafe(self._set_result_if_not_done)
+
+ def _set_result_if_not_done(self) -> None:
+ """Set the result if not done."""
+ if not self.future.done():
+ self.future.set_result(None)
@dataclass(slots=True)
diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py
index a686c7c6498..0acaf0aa68f 100644
--- a/homeassistant/components/recorder/util.py
+++ b/homeassistant/components/recorder/util.py
@@ -464,6 +464,7 @@ def setup_connection_for_dialect(
"""Execute statements needed for dialect connection."""
version: AwesomeVersion | None = None
slow_range_in_select = False
+ slow_dependent_subquery = False
if dialect_name == SupportedDialect.SQLITE:
if first_connection:
old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined]
@@ -505,9 +506,8 @@ def setup_connection_for_dialect(
result = query_on_connection(dbapi_connection, "SELECT VERSION()")
version_string = result[0][0]
version = _extract_version_from_server_response(version_string)
- is_maria_db = "mariadb" in version_string.lower()
- if is_maria_db:
+ if "mariadb" in version_string.lower():
if not version or version < MIN_VERSION_MARIA_DB:
_raise_if_version_unsupported(
version or version_string, "MariaDB", MIN_VERSION_MARIA_DB
@@ -523,19 +523,21 @@ def setup_connection_for_dialect(
instance.hass,
version,
)
-
+ slow_range_in_select = bool(
+ not version
+ or version < MARIADB_WITH_FIXED_IN_QUERIES_105
+ or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106
+ or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107
+ or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108
+ )
elif not version or version < MIN_VERSION_MYSQL:
_raise_if_version_unsupported(
version or version_string, "MySQL", MIN_VERSION_MYSQL
)
-
- slow_range_in_select = bool(
- not version
- or version < MARIADB_WITH_FIXED_IN_QUERIES_105
- or MARIA_DB_106 <= version < MARIADB_WITH_FIXED_IN_QUERIES_106
- or MARIA_DB_107 <= version < MARIADB_WITH_FIXED_IN_QUERIES_107
- or MARIA_DB_108 <= version < MARIADB_WITH_FIXED_IN_QUERIES_108
- )
+ else:
+ # MySQL
+ # https://github.com/home-assistant/core/issues/137178
+ slow_dependent_subquery = True
# Ensure all times are using UTC to avoid issues with daylight savings
execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'")
@@ -565,7 +567,10 @@ def setup_connection_for_dialect(
return DatabaseEngine(
dialect=SupportedDialect(dialect_name),
version=version,
- optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select),
+ optimizer=DatabaseOptimizer(
+ slow_range_in_select=slow_range_in_select,
+ slow_dependent_subquery=slow_dependent_subquery,
+ ),
max_bind_vars=DEFAULT_MAX_BIND_VARS,
)
diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py
index 03d9e725170..f4058943971 100644
--- a/homeassistant/components/recorder/websocket_api.py
+++ b/homeassistant/components/recorder/websocket_api.py
@@ -37,7 +37,7 @@ from homeassistant.util.unit_conversion import (
VolumeFlowRateConverter,
)
-from .models import StatisticPeriod
+from .models import StatisticMeanType, StatisticPeriod
from .statistics import (
STATISTIC_UNIT_TO_UNIT_CONVERTER,
async_add_external_statistics,
@@ -297,13 +297,13 @@ async def ws_list_statistic_ids(
async def ws_validate_statistics(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
- """Fetch a list of available statistic_id."""
+ """Validate statistics and return issues found."""
instance = get_instance(hass)
- statistic_ids = await instance.async_add_executor_job(
+ validation_issues = await instance.async_add_executor_job(
validate_statistics,
hass,
)
- connection.send_result(msg["id"], statistic_ids)
+ connection.send_result(msg["id"], validation_issues)
@websocket_api.websocket_command(
@@ -532,6 +532,10 @@ def ws_import_statistics(
) -> None:
"""Import statistics."""
metadata = msg["metadata"]
+ # The WS command will be changed in a follow up PR
+ metadata["mean_type"] = (
+ StatisticMeanType.ARITHMETIC if metadata["has_mean"] else StatisticMeanType.NONE
+ )
stats = msg["stats"]
if valid_entity_id(metadata["statistic_id"]):
diff --git a/homeassistant/components/refoss/sensor.py b/homeassistant/components/refoss/sensor.py
index 7065470657f..92090a192e8 100644
--- a/homeassistant/components/refoss/sensor.py
+++ b/homeassistant/components/refoss/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .bridge import RefossDataUpdateCoordinator
@@ -94,7 +94,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = {
key="energy",
translation_key="this_month_energy",
device_class=SensorDeviceClass.ENERGY,
- state_class=SensorStateClass.TOTAL,
+ state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_display_precision=2,
subkey="mConsume",
@@ -104,7 +104,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = {
key="energy_returned",
translation_key="this_month_energy_returned",
device_class=SensorDeviceClass.ENERGY,
- state_class=SensorStateClass.TOTAL,
+ state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
suggested_display_precision=2,
subkey="mConsume",
@@ -117,7 +117,7 @@ SENSORS: dict[str, tuple[RefossSensorEntityDescription, ...]] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Refoss device from a config entry."""
diff --git a/homeassistant/components/refoss/switch.py b/homeassistant/components/refoss/switch.py
index aed132ecc3a..1d465f7f319 100644
--- a/homeassistant/components/refoss/switch.py
+++ b/homeassistant/components/refoss/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .bridge import RefossDataUpdateCoordinator
from .const import _LOGGER, COORDINATORS, DISPATCH_DEVICE_DISCOVERED, DOMAIN
@@ -20,7 +20,7 @@ from .entity import RefossEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Refoss device from a config entry."""
diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py
index 0d1c54efb56..df9eec0622f 100644
--- a/homeassistant/components/remember_the_milk/__init__.py
+++ b/homeassistant/components/remember_the_milk/__init__.py
@@ -1,33 +1,25 @@
"""Support to interact with Remember The Milk."""
-import json
-import logging
-import os
-
from rtmapi import Rtm
import voluptuous as vol
from homeassistant.components import configurator
-from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN
+from homeassistant.const import CONF_API_KEY, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.typing import ConfigType
+from .const import LOGGER
from .entity import RememberTheMilkEntity
+from .storage import RememberTheMilkConfiguration
# httplib2 is a transitive dependency from RtmAPI. If this dependency is not
# set explicitly, the library does not work.
-_LOGGER = logging.getLogger(__name__)
DOMAIN = "remember_the_milk"
-DEFAULT_NAME = DOMAIN
CONF_SHARED_SECRET = "shared_secret"
-CONF_ID_MAP = "id_map"
-CONF_LIST_ID = "list_id"
-CONF_TIMESERIES_ID = "timeseries_id"
-CONF_TASK_ID = "task_id"
RTM_SCHEMA = vol.Schema(
{
@@ -41,7 +33,6 @@ CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])}, extra=vol.ALLOW_EXTRA
)
-CONFIG_FILE_NAME = ".remember_the_milk.conf"
SERVICE_CREATE_TASK = "create_task"
SERVICE_COMPLETE_TASK = "complete_task"
@@ -54,17 +45,17 @@ SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({vol.Required(CONF_ID): cv.string})
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Remember the milk component."""
- component = EntityComponent[RememberTheMilkEntity](_LOGGER, DOMAIN, hass)
+ component = EntityComponent[RememberTheMilkEntity](LOGGER, DOMAIN, hass)
stored_rtm_config = RememberTheMilkConfiguration(hass)
for rtm_config in config[DOMAIN]:
account_name = rtm_config[CONF_NAME]
- _LOGGER.debug("Adding Remember the milk account %s", account_name)
+ LOGGER.debug("Adding Remember the milk account %s", account_name)
api_key = rtm_config[CONF_API_KEY]
shared_secret = rtm_config[CONF_SHARED_SECRET]
token = stored_rtm_config.get_token(account_name)
if token:
- _LOGGER.debug("found token for account %s", account_name)
+ LOGGER.debug("found token for account %s", account_name)
_create_instance(
hass,
account_name,
@@ -79,13 +70,19 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
hass, account_name, api_key, shared_secret, stored_rtm_config, component
)
- _LOGGER.debug("Finished adding all Remember the milk accounts")
+ LOGGER.debug("Finished adding all Remember the milk accounts")
return True
def _create_instance(
- hass, account_name, api_key, shared_secret, token, stored_rtm_config, component
-):
+ hass: HomeAssistant,
+ account_name: str,
+ api_key: str,
+ shared_secret: str,
+ token: str,
+ stored_rtm_config: RememberTheMilkConfiguration,
+ component: EntityComponent[RememberTheMilkEntity],
+) -> None:
entity = RememberTheMilkEntity(
account_name, api_key, shared_secret, token, stored_rtm_config
)
@@ -105,26 +102,30 @@ def _create_instance(
def _register_new_account(
- hass, account_name, api_key, shared_secret, stored_rtm_config, component
-):
- request_id = None
+ hass: HomeAssistant,
+ account_name: str,
+ api_key: str,
+ shared_secret: str,
+ stored_rtm_config: RememberTheMilkConfiguration,
+ component: EntityComponent[RememberTheMilkEntity],
+) -> None:
api = Rtm(api_key, shared_secret, "write", None)
url, frob = api.authenticate_desktop()
- _LOGGER.debug("Sent authentication request to server")
+ LOGGER.debug("Sent authentication request to server")
def register_account_callback(fields: list[dict[str, str]]) -> None:
"""Call for register the configurator."""
api.retrieve_token(frob)
token = api.token
if api.token is None:
- _LOGGER.error("Failed to register, please try again")
+ LOGGER.error("Failed to register, please try again")
configurator.notify_errors(
hass, request_id, "Failed to register, please try again."
)
return
stored_rtm_config.set_token(account_name, token)
- _LOGGER.debug("Retrieved new token from server")
+ LOGGER.debug("Retrieved new token from server")
_create_instance(
hass,
@@ -152,89 +153,3 @@ def _register_new_account(
link_url=url,
submit_caption="login completed",
)
-
-
-class RememberTheMilkConfiguration:
- """Internal configuration data for RememberTheMilk class.
-
- This class stores the authentication token it get from the backend.
- """
-
- def __init__(self, hass):
- """Create new instance of configuration."""
- self._config_file_path = hass.config.path(CONFIG_FILE_NAME)
- if not os.path.isfile(self._config_file_path):
- self._config = {}
- return
- try:
- _LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
- with open(self._config_file_path, encoding="utf8") as config_file:
- self._config = json.load(config_file)
- except ValueError:
- _LOGGER.error(
- "Failed to load configuration file, creating a new one: %s",
- self._config_file_path,
- )
- self._config = {}
-
- def save_config(self):
- """Write the configuration to a file."""
- with open(self._config_file_path, "w", encoding="utf8") as config_file:
- json.dump(self._config, config_file)
-
- def get_token(self, profile_name):
- """Get the server token for a profile."""
- if profile_name in self._config:
- return self._config[profile_name][CONF_TOKEN]
- return None
-
- def set_token(self, profile_name, token):
- """Store a new server token for a profile."""
- self._initialize_profile(profile_name)
- self._config[profile_name][CONF_TOKEN] = token
- self.save_config()
-
- def delete_token(self, profile_name):
- """Delete a token for a profile.
-
- Usually called when the token has expired.
- """
- self._config.pop(profile_name, None)
- self.save_config()
-
- def _initialize_profile(self, profile_name):
- """Initialize the data structures for a profile."""
- if profile_name not in self._config:
- self._config[profile_name] = {}
- if CONF_ID_MAP not in self._config[profile_name]:
- self._config[profile_name][CONF_ID_MAP] = {}
-
- def get_rtm_id(self, profile_name, hass_id):
- """Get the RTM ids for a Home Assistant task ID.
-
- The id of a RTM tasks consists of the tuple:
- list id, timeseries id and the task id.
- """
- self._initialize_profile(profile_name)
- ids = self._config[profile_name][CONF_ID_MAP].get(hass_id)
- if ids is None:
- return None
- return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID]
-
- def set_rtm_id(self, profile_name, hass_id, list_id, time_series_id, rtm_task_id):
- """Add/Update the RTM task ID for a Home Assistant task IS."""
- self._initialize_profile(profile_name)
- id_tuple = {
- CONF_LIST_ID: list_id,
- CONF_TIMESERIES_ID: time_series_id,
- CONF_TASK_ID: rtm_task_id,
- }
- self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple
- self.save_config()
-
- def delete_rtm_id(self, profile_name, hass_id):
- """Delete a key mapping."""
- self._initialize_profile(profile_name)
- if hass_id in self._config[profile_name][CONF_ID_MAP]:
- del self._config[profile_name][CONF_ID_MAP][hass_id]
- self.save_config()
diff --git a/homeassistant/components/remember_the_milk/const.py b/homeassistant/components/remember_the_milk/const.py
new file mode 100644
index 00000000000..2fccbf3ee52
--- /dev/null
+++ b/homeassistant/components/remember_the_milk/const.py
@@ -0,0 +1,5 @@
+"""Constants for the Remember The Milk integration."""
+
+import logging
+
+LOGGER = logging.getLogger(__package__)
diff --git a/homeassistant/components/remember_the_milk/entity.py b/homeassistant/components/remember_the_milk/entity.py
index 8fa52b6c06c..be69d16f72f 100644
--- a/homeassistant/components/remember_the_milk/entity.py
+++ b/homeassistant/components/remember_the_milk/entity.py
@@ -1,20 +1,26 @@
"""Support to interact with Remember The Milk."""
-import logging
-
from rtmapi import Rtm, RtmRequestFailedException
from homeassistant.const import CONF_ID, CONF_NAME, STATE_OK
from homeassistant.core import ServiceCall
from homeassistant.helpers.entity import Entity
-_LOGGER = logging.getLogger(__name__)
+from .const import LOGGER
+from .storage import RememberTheMilkConfiguration
class RememberTheMilkEntity(Entity):
"""Representation of an interface to Remember The Milk."""
- def __init__(self, name, api_key, shared_secret, token, rtm_config):
+ def __init__(
+ self,
+ name: str,
+ api_key: str,
+ shared_secret: str,
+ token: str,
+ rtm_config: RememberTheMilkConfiguration,
+ ) -> None:
"""Create new instance of Remember The Milk component."""
self._name = name
self._api_key = api_key
@@ -22,11 +28,11 @@ class RememberTheMilkEntity(Entity):
self._token = token
self._rtm_config = rtm_config
self._rtm_api = Rtm(api_key, shared_secret, "delete", token)
- self._token_valid = None
+ self._token_valid = False
self._check_token()
- _LOGGER.debug("Instance created for account %s", self._name)
+ LOGGER.debug("Instance created for account %s", self._name)
- def _check_token(self):
+ def _check_token(self) -> bool:
"""Check if the API token is still valid.
If it is not valid any more, delete it from the configuration. This
@@ -34,7 +40,7 @@ class RememberTheMilkEntity(Entity):
"""
valid = self._rtm_api.token_valid()
if not valid:
- _LOGGER.error(
+ LOGGER.error(
"Token for account %s is invalid. You need to register again!",
self.name,
)
@@ -60,20 +66,21 @@ class RememberTheMilkEntity(Entity):
result = self._rtm_api.rtm.timelines.create()
timeline = result.timeline.value
- if hass_id is None or rtm_id is None:
+ if rtm_id is None:
result = self._rtm_api.rtm.tasks.add(
timeline=timeline, name=task_name, parse="1"
)
- _LOGGER.debug(
+ LOGGER.debug(
"Created new task '%s' in account %s", task_name, self.name
)
- self._rtm_config.set_rtm_id(
- self._name,
- hass_id,
- result.list.id,
- result.list.taskseries.id,
- result.list.taskseries.task.id,
- )
+ if hass_id is not None:
+ self._rtm_config.set_rtm_id(
+ self._name,
+ hass_id,
+ result.list.id,
+ result.list.taskseries.id,
+ result.list.taskseries.task.id,
+ )
else:
self._rtm_api.rtm.tasks.setName(
name=task_name,
@@ -82,14 +89,14 @@ class RememberTheMilkEntity(Entity):
task_id=rtm_id[2],
timeline=timeline,
)
- _LOGGER.debug(
+ LOGGER.debug(
"Updated task with id '%s' in account %s to name %s",
hass_id,
self.name,
task_name,
)
except RtmRequestFailedException as rtm_exception:
- _LOGGER.error(
+ LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
rtm_exception,
@@ -100,7 +107,7 @@ class RememberTheMilkEntity(Entity):
hass_id = call.data[CONF_ID]
rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
if rtm_id is None:
- _LOGGER.error(
+ LOGGER.error(
(
"Could not find task with ID %s in account %s. "
"So task could not be closed"
@@ -119,23 +126,21 @@ class RememberTheMilkEntity(Entity):
timeline=timeline,
)
self._rtm_config.delete_rtm_id(self._name, hass_id)
- _LOGGER.debug(
- "Completed task with id %s in account %s", hass_id, self._name
- )
+ LOGGER.debug("Completed task with id %s in account %s", hass_id, self._name)
except RtmRequestFailedException as rtm_exception:
- _LOGGER.error(
+ LOGGER.error(
"Error creating new Remember The Milk task for account %s: %s",
self._name,
rtm_exception,
)
@property
- def name(self):
+ def name(self) -> str:
"""Return the name of the device."""
return self._name
@property
- def state(self):
+ def state(self) -> str:
"""Return the state of the device."""
if not self._token_valid:
return "API token invalid"
diff --git a/homeassistant/components/remember_the_milk/storage.py b/homeassistant/components/remember_the_milk/storage.py
new file mode 100644
index 00000000000..593abb7da2c
--- /dev/null
+++ b/homeassistant/components/remember_the_milk/storage.py
@@ -0,0 +1,116 @@
+"""Store RTM configuration in Home Assistant storage."""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import cast
+
+from homeassistant.const import CONF_TOKEN
+from homeassistant.core import HomeAssistant
+
+from .const import LOGGER
+
+CONFIG_FILE_NAME = ".remember_the_milk.conf"
+CONF_ID_MAP = "id_map"
+CONF_LIST_ID = "list_id"
+CONF_TASK_ID = "task_id"
+CONF_TIMESERIES_ID = "timeseries_id"
+
+
+class RememberTheMilkConfiguration:
+ """Internal configuration data for Remember The Milk."""
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Create new instance of configuration."""
+ self._config_file_path = hass.config.path(CONFIG_FILE_NAME)
+ self._config = {}
+ LOGGER.debug("Loading configuration from file: %s", self._config_file_path)
+ try:
+ self._config = json.loads(
+ Path(self._config_file_path).read_text(encoding="utf8")
+ )
+ except FileNotFoundError:
+ LOGGER.debug("Missing configuration file: %s", self._config_file_path)
+ except OSError:
+ LOGGER.debug(
+ "Failed to read from configuration file, %s, using empty configuration",
+ self._config_file_path,
+ )
+ except ValueError:
+ LOGGER.error(
+ "Failed to parse configuration file, %s, using empty configuration",
+ self._config_file_path,
+ )
+
+ def _save_config(self) -> None:
+ """Write the configuration to a file."""
+ Path(self._config_file_path).write_text(
+ json.dumps(self._config), encoding="utf8"
+ )
+
+ def get_token(self, profile_name: str) -> str | None:
+ """Get the server token for a profile."""
+ if profile_name in self._config:
+ return cast(str, self._config[profile_name][CONF_TOKEN])
+ return None
+
+ def set_token(self, profile_name: str, token: str) -> None:
+ """Store a new server token for a profile."""
+ self._initialize_profile(profile_name)
+ self._config[profile_name][CONF_TOKEN] = token
+ self._save_config()
+
+ def delete_token(self, profile_name: str) -> None:
+ """Delete a token for a profile.
+
+ Usually called when the token has expired.
+ """
+ self._config.pop(profile_name, None)
+ self._save_config()
+
+ def _initialize_profile(self, profile_name: str) -> None:
+ """Initialize the data structures for a profile."""
+ if profile_name not in self._config:
+ self._config[profile_name] = {}
+ if CONF_ID_MAP not in self._config[profile_name]:
+ self._config[profile_name][CONF_ID_MAP] = {}
+
+ def get_rtm_id(
+ self, profile_name: str, hass_id: str
+ ) -> tuple[str, str, str] | None:
+ """Get the RTM ids for a Home Assistant task ID.
+
+ The id of a RTM tasks consists of the tuple:
+ list id, timeseries id and the task id.
+ """
+ self._initialize_profile(profile_name)
+ ids = self._config[profile_name][CONF_ID_MAP].get(hass_id)
+ if ids is None:
+ return None
+ return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID]
+
+ def set_rtm_id(
+ self,
+ profile_name: str,
+ hass_id: str,
+ list_id: str,
+ time_series_id: str,
+ rtm_task_id: str,
+ ) -> None:
+ """Add/Update the RTM task ID for a Home Assistant task IS."""
+ self._initialize_profile(profile_name)
+ id_tuple = {
+ CONF_LIST_ID: list_id,
+ CONF_TIMESERIES_ID: time_series_id,
+ CONF_TASK_ID: rtm_task_id,
+ }
+ self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple
+ self._save_config()
+
+ def delete_rtm_id(self, profile_name: str, hass_id: str) -> None:
+ """Delete a key mapping."""
+ self._initialize_profile(profile_name)
+ if hass_id in self._config[profile_name][CONF_ID_MAP]:
+ del self._config[profile_name][CONF_ID_MAP][hass_id]
+ self._save_config()
diff --git a/homeassistant/components/remote_calendar/__init__.py b/homeassistant/components/remote_calendar/__init__.py
new file mode 100644
index 00000000000..910eeae8268
--- /dev/null
+++ b/homeassistant/components/remote_calendar/__init__.py
@@ -0,0 +1,33 @@
+"""The Remote Calendar integration."""
+
+import logging
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .const import DOMAIN
+from .coordinator import RemoteCalendarConfigEntry, RemoteCalendarDataUpdateCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+
+PLATFORMS: list[Platform] = [Platform.CALENDAR]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: RemoteCalendarConfigEntry
+) -> bool:
+ """Set up Remote Calendar from a config entry."""
+ hass.data.setdefault(DOMAIN, {})
+ coordinator = RemoteCalendarDataUpdateCoordinator(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: RemoteCalendarConfigEntry
+) -> bool:
+ """Handle unload of an entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/remote_calendar/calendar.py b/homeassistant/components/remote_calendar/calendar.py
new file mode 100644
index 00000000000..bd83a5f18cc
--- /dev/null
+++ b/homeassistant/components/remote_calendar/calendar.py
@@ -0,0 +1,92 @@
+"""Calendar platform for a Remote Calendar."""
+
+from datetime import datetime
+import logging
+
+from ical.event import Event
+
+from homeassistant.components.calendar import CalendarEntity, CalendarEvent
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.util import dt as dt_util
+
+from . import RemoteCalendarConfigEntry
+from .const import CONF_CALENDAR_NAME
+from .coordinator import RemoteCalendarDataUpdateCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: RemoteCalendarConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the remote calendar platform."""
+ coordinator = entry.runtime_data
+ entity = RemoteCalendarEntity(coordinator, entry)
+ async_add_entities([entity])
+
+
+class RemoteCalendarEntity(
+ CoordinatorEntity[RemoteCalendarDataUpdateCoordinator], CalendarEntity
+):
+ """A calendar entity backed by a remote iCalendar url."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: RemoteCalendarDataUpdateCoordinator,
+ entry: RemoteCalendarConfigEntry,
+ ) -> None:
+ """Initialize RemoteCalendarEntity."""
+ super().__init__(coordinator)
+ self._attr_name = entry.data[CONF_CALENDAR_NAME]
+ self._attr_unique_id = entry.entry_id
+
+ @property
+ def event(self) -> CalendarEvent | None:
+ """Return the next upcoming event."""
+ now = dt_util.now()
+ events = self.coordinator.data.timeline_tz(now.tzinfo).active_after(now)
+ if event := next(events, None):
+ return _get_calendar_event(event)
+ return None
+
+ async def async_get_events(
+ self, hass: HomeAssistant, start_date: datetime, end_date: datetime
+ ) -> list[CalendarEvent]:
+ """Get all events in a specific time frame."""
+ events = self.coordinator.data.timeline_tz(start_date.tzinfo).overlapping(
+ start_date,
+ end_date,
+ )
+ return [_get_calendar_event(event) for event in events]
+
+
+def _get_calendar_event(event: Event) -> CalendarEvent:
+ """Return a CalendarEvent from an API event."""
+
+ return CalendarEvent(
+ summary=event.summary,
+ start=(
+ dt_util.as_local(event.start)
+ if isinstance(event.start, datetime)
+ else event.start
+ ),
+ end=(
+ dt_util.as_local(event.end)
+ if isinstance(event.end, datetime)
+ else event.end
+ ),
+ description=event.description,
+ uid=event.uid,
+ rrule=event.rrule.as_rrule_str() if event.rrule else None,
+ recurrence_id=event.recurrence_id,
+ location=event.location,
+ )
diff --git a/homeassistant/components/remote_calendar/config_flow.py b/homeassistant/components/remote_calendar/config_flow.py
new file mode 100644
index 00000000000..802a7eb7cea
--- /dev/null
+++ b/homeassistant/components/remote_calendar/config_flow.py
@@ -0,0 +1,85 @@
+"""Config flow for Remote Calendar integration."""
+
+from http import HTTPStatus
+import logging
+from typing import Any
+
+from httpx import HTTPError, InvalidURL
+from ical.calendar_stream import IcsCalendarStream
+from ical.exceptions import CalendarParseError
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_URL
+from homeassistant.helpers.httpx_client import get_async_client
+
+from .const import CONF_CALENDAR_NAME, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_CALENDAR_NAME): str,
+ vol.Required(CONF_URL): str,
+ }
+)
+
+
+class RemoteCalendarConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Remote Calendar."""
+
+ VERSION = 1
+
+ async def async_step_user(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle the initial step."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA
+ )
+ errors: dict = {}
+ _LOGGER.debug("User input: %s", user_input)
+ self._async_abort_entries_match(
+ {CONF_CALENDAR_NAME: user_input[CONF_CALENDAR_NAME]}
+ )
+ if user_input[CONF_URL].startswith("webcal://"):
+ user_input[CONF_URL] = user_input[CONF_URL].replace(
+ "webcal://", "https://", 1
+ )
+ self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
+ client = get_async_client(self.hass)
+ try:
+ res = await client.get(user_input[CONF_URL], follow_redirects=True)
+ if res.status_code == HTTPStatus.FORBIDDEN:
+ errors["base"] = "forbidden"
+ return self.async_show_form(
+ step_id="user",
+ data_schema=STEP_USER_DATA_SCHEMA,
+ errors=errors,
+ )
+ res.raise_for_status()
+ except (HTTPError, InvalidURL) as err:
+ errors["base"] = "cannot_connect"
+ _LOGGER.debug("An error occurred: %s", err)
+ else:
+ try:
+ await self.hass.async_add_executor_job(
+ IcsCalendarStream.calendar_from_ics, res.text
+ )
+ except CalendarParseError as err:
+ errors["base"] = "invalid_ics_file"
+ _LOGGER.error("Error reading the calendar information: %s", err.message)
+ _LOGGER.debug(
+ "Additional calendar error detail: %s", str(err.detailed_error)
+ )
+ else:
+ return self.async_create_entry(
+ title=user_input[CONF_CALENDAR_NAME], data=user_input
+ )
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=STEP_USER_DATA_SCHEMA,
+ errors=errors,
+ )
diff --git a/homeassistant/components/remote_calendar/const.py b/homeassistant/components/remote_calendar/const.py
new file mode 100644
index 00000000000..060d7633111
--- /dev/null
+++ b/homeassistant/components/remote_calendar/const.py
@@ -0,0 +1,4 @@
+"""Constants for the Remote Calendar integration."""
+
+DOMAIN = "remote_calendar"
+CONF_CALENDAR_NAME = "calendar_name"
diff --git a/homeassistant/components/remote_calendar/coordinator.py b/homeassistant/components/remote_calendar/coordinator.py
new file mode 100644
index 00000000000..6caec297c1a
--- /dev/null
+++ b/homeassistant/components/remote_calendar/coordinator.py
@@ -0,0 +1,71 @@
+"""Data UpdateCoordinator for the Remote Calendar integration."""
+
+from datetime import timedelta
+import logging
+
+from httpx import HTTPError, InvalidURL
+from ical.calendar import Calendar
+from ical.calendar_stream import IcsCalendarStream
+from ical.exceptions import CalendarParseError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_URL
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+type RemoteCalendarConfigEntry = ConfigEntry[RemoteCalendarDataUpdateCoordinator]
+
+_LOGGER = logging.getLogger(__name__)
+SCAN_INTERVAL = timedelta(days=1)
+
+
+class RemoteCalendarDataUpdateCoordinator(DataUpdateCoordinator[Calendar]):
+ """Class to manage fetching calendar data."""
+
+ config_entry: RemoteCalendarConfigEntry
+ ics: str
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: RemoteCalendarConfigEntry,
+ ) -> None:
+ """Initialize data updater."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
+ always_update=True,
+ )
+ self._client = get_async_client(hass)
+ self._url = config_entry.data[CONF_URL]
+
+ async def _async_update_data(self) -> Calendar:
+ """Update data from the url."""
+ try:
+ res = await self._client.get(self._url, follow_redirects=True)
+ res.raise_for_status()
+ except (HTTPError, InvalidURL) as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="unable_to_fetch",
+ translation_placeholders={"err": str(err)},
+ ) from err
+ try:
+ # calendar_from_ics will dynamically load packages
+ # the first time it is called, so we need to do it
+ # in a separate thread to avoid blocking the event loop
+ self.ics = res.text
+ return await self.hass.async_add_executor_job(
+ IcsCalendarStream.calendar_from_ics, self.ics
+ )
+ except CalendarParseError as err:
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="unable_to_parse",
+ translation_placeholders={"err": str(err)},
+ ) from err
diff --git a/homeassistant/components/remote_calendar/diagnostics.py b/homeassistant/components/remote_calendar/diagnostics.py
new file mode 100644
index 00000000000..5ebfb3d3812
--- /dev/null
+++ b/homeassistant/components/remote_calendar/diagnostics.py
@@ -0,0 +1,25 @@
+"""Provides diagnostics for the remote calendar."""
+
+import datetime
+from typing import Any
+
+from ical.diagnostics import redact_ics
+
+from homeassistant.core import HomeAssistant
+from homeassistant.util import dt as dt_util
+
+from . import RemoteCalendarConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: RemoteCalendarConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ coordinator = entry.runtime_data
+ payload: dict[str, Any] = {
+ "now": dt_util.now().isoformat(),
+ "timezone": str(dt_util.get_default_time_zone()),
+ "system_timezone": str(datetime.datetime.now().astimezone().tzinfo),
+ }
+ payload["ics"] = "\n".join(redact_ics(coordinator.ics))
+ return payload
diff --git a/homeassistant/components/remote_calendar/manifest.json b/homeassistant/components/remote_calendar/manifest.json
new file mode 100644
index 00000000000..da078395484
--- /dev/null
+++ b/homeassistant/components/remote_calendar/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "remote_calendar",
+ "name": "Remote Calendar",
+ "codeowners": ["@Thomas55555"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/remote_calendar",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "loggers": ["ical"],
+ "quality_scale": "silver",
+ "requirements": ["ical==9.1.0"]
+}
diff --git a/homeassistant/components/remote_calendar/quality_scale.yaml b/homeassistant/components/remote_calendar/quality_scale.yaml
new file mode 100644
index 00000000000..964b63d7116
--- /dev/null
+++ b/homeassistant/components/remote_calendar/quality_scale.yaml
@@ -0,0 +1,98 @@
+rules:
+ # Bronze
+ config-flow: done
+ test-before-configure: done
+ unique-config-entry:
+ status: exempt
+ comment: |
+ No unique identifier.
+ config-flow-test-coverage: done
+ runtime-data: done
+ test-before-setup: done
+ appropriate-polling: done
+ entity-unique-id: done
+ has-entity-name: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ dependency-transparency: done
+ action-setup:
+ status: exempt
+ comment: |
+ There are no actions.
+ common-modules: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ docs-actions:
+ status: exempt
+ comment: No actions available.
+ brands: done
+ # Silver
+ config-entry-unloading: done
+ log-when-unavailable: done
+ entity-unavailable: done
+ action-exceptions:
+ status: exempt
+ comment: |
+ There are no actions.
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ There is no authentication required.
+ parallel-updates: done
+ test-coverage: done
+ integration-owner: done
+ docs-installation-parameters: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: no configuration options
+
+ # Gold
+ devices:
+ status: exempt
+ comment: No devices. One URL is always assigned to one calendar.
+ diagnostics: done
+ discovery-update-info:
+ status: todo
+ comment: No discovery protocol available.
+ discovery:
+ status: exempt
+ comment: No discovery protocol available.
+ 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:
+ status: exempt
+ comment: No devices. One URL is always assigned to one calendar.
+ entity-category: done
+ entity-device-class:
+ status: exempt
+ comment: No devices classes for calendars.
+ entity-disabled-by-default:
+ status: exempt
+ comment: Only one entity per entry.
+ entity-translations:
+ status: exempt
+ comment: Entity name is defined by the user, so no translation possible.
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: Only the default icon is used.
+ reconfiguration-flow:
+ status: exempt
+ comment: no configuration possible
+ repair-issues: todo
+ stale-devices:
+ status: exempt
+ comment: No devices. One URL is always assigned to one calendar.
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/remote_calendar/strings.json b/homeassistant/components/remote_calendar/strings.json
new file mode 100644
index 00000000000..ef7f20d4699
--- /dev/null
+++ b/homeassistant/components/remote_calendar/strings.json
@@ -0,0 +1,34 @@
+{
+ "title": "Remote Calendar",
+ "config": {
+ "step": {
+ "user": {
+ "description": "Please choose a name for the calendar to be imported",
+ "data": {
+ "calendar_name": "Calendar name",
+ "url": "Calendar URL"
+ },
+ "data_description": {
+ "calendar_name": "The name of the calendar shown in the UI.",
+ "url": "The URL of the remote calendar."
+ }
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "forbidden": "The server understood the request but refuses to authorize it.",
+ "invalid_ics_file": "There was a problem reading the calendar information. See the error log for additional details."
+ }
+ },
+ "exceptions": {
+ "unable_to_fetch": {
+ "message": "Unable to fetch calendar data: {err}"
+ },
+ "unable_to_parse": {
+ "message": "Unable to parse calendar data: {err}"
+ }
+ }
+}
diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py
index 42e8517c1e8..1d970bb3541 100644
--- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py
+++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from gpiozero import DigitalInputDevice
import requests
import voluptuous as vol
@@ -48,10 +49,10 @@ def setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Raspberry PI GPIO devices."""
- address = config["host"]
+ address = config[CONF_HOST]
invert_logic = config[CONF_INVERT_LOGIC]
pull_mode = config[CONF_PULL_MODE]
- ports = config["ports"]
+ ports = config[CONF_PORTS]
bouncetime = config[CONF_BOUNCETIME] / 1000
devices = []
@@ -71,9 +72,11 @@ class RemoteRPiGPIOBinarySensor(BinarySensorEntity):
_attr_should_poll = False
- def __init__(self, name, sensor, invert_logic):
+ def __init__(
+ self, name: str | None, sensor: DigitalInputDevice, invert_logic: bool
+ ) -> None:
"""Initialize the RPi binary sensor."""
- self._name = name
+ self._attr_name = name
self._invert_logic = invert_logic
self._state = False
self._sensor = sensor
@@ -90,20 +93,10 @@ class RemoteRPiGPIOBinarySensor(BinarySensorEntity):
self._sensor.when_activated = read_gpio
@property
- def name(self):
- """Return the name of the sensor."""
- return self._name
-
- @property
- def is_on(self):
+ def is_on(self) -> bool:
"""Return the state of the entity."""
return self._state != self._invert_logic
- @property
- def device_class(self):
- """Return the class of this sensor, from DEVICE_CLASSES."""
- return
-
def update(self) -> None:
"""Update the GPIO state."""
try:
diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py
index 91b389c5a1e..25f95045e4b 100644
--- a/homeassistant/components/remote_rpi_gpio/switch.py
+++ b/homeassistant/components/remote_rpi_gpio/switch.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Any
+from gpiozero import LED
import voluptuous as vol
from homeassistant.components.switch import (
@@ -57,37 +58,23 @@ def setup_platform(
class RemoteRPiGPIOSwitch(SwitchEntity):
"""Representation of a Remote Raspberry Pi GPIO."""
+ _attr_assumed_state = True
_attr_should_poll = False
- def __init__(self, name, led):
+ def __init__(self, name: str | None, led: LED) -> None:
"""Initialize the pin."""
- self._name = name or DEVICE_DEFAULT_NAME
- self._state = False
+ self._attr_name = name or DEVICE_DEFAULT_NAME
+ self._attr_is_on = False
self._switch = led
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def assumed_state(self):
- """If unable to access real state of the entity."""
- return True
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
def turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
write_output(self._switch, 1)
- self._state = True
+ self._attr_is_on = True
self.schedule_update_ha_state()
def turn_off(self, **kwargs: Any) -> None:
"""Turn the device off."""
write_output(self._switch, 0)
- self._state = False
+ self._attr_is_on = False
self.schedule_update_ha_state()
diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py
index a8fdf324f1c..0aebd3bd835 100644
--- a/homeassistant/components/renault/binary_sensor.py
+++ b/homeassistant/components/renault/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import RenaultConfigEntry
@@ -37,7 +37,7 @@ class RenaultBinarySensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RenaultConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Renault entities from config entry."""
entities: list[RenaultBinarySensor] = [
diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py
index 6a9f5e05a38..82b811821ea 100644
--- a/homeassistant/components/renault/button.py
+++ b/homeassistant/components/renault/button.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RenaultConfigEntry
from .entity import RenaultEntity
@@ -29,7 +29,7 @@ class RenaultButtonEntityDescription(ButtonEntityDescription):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RenaultConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Renault entities from config entry."""
entities: list[RenaultButtonEntity] = [
diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py
index 70544a5637f..90d2c11613c 100644
--- a/homeassistant/components/renault/config_flow.py
+++ b/homeassistant/components/renault/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
+import logging
from typing import Any
import aiohttp
@@ -16,6 +17,8 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import CONF_KAMEREON_ACCOUNT_ID, CONF_LOCALE, DOMAIN
from .renault_hub import RenaultHub
+_LOGGER = logging.getLogger(__name__)
+
USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_LOCALE): vol.In(AVAILABLE_LOCALES.keys()),
@@ -54,7 +57,8 @@ class RenaultFlowHandler(ConfigFlow, domain=DOMAIN):
)
except (aiohttp.ClientConnectionError, GigyaException):
errors["base"] = "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
if login_success:
diff --git a/homeassistant/components/renault/const.py b/homeassistant/components/renault/const.py
index 201a07c6783..1dffededf38 100644
--- a/homeassistant/components/renault/const.py
+++ b/homeassistant/components/renault/const.py
@@ -7,7 +7,12 @@ DOMAIN = "renault"
CONF_LOCALE = "locale"
CONF_KAMEREON_ACCOUNT_ID = "kamereon_account_id"
-DEFAULT_SCAN_INTERVAL = 420 # 7 minutes
+# normal number of allowed calls per hour to the API
+# for a single car and the 7 coordinator, it is a scan every 7mn
+MAX_CALLS_PER_HOURS = 60
+
+# If throttled time to pause the updates, in seconds
+COOLING_UPDATES_SECONDS = 60 * 15 # 15 minutes
PLATFORMS = [
Platform.BINARY_SENSOR,
diff --git a/homeassistant/components/renault/coordinator.py b/homeassistant/components/renault/coordinator.py
index a90331730bc..c768c436133 100644
--- a/homeassistant/components/renault/coordinator.py
+++ b/homeassistant/components/renault/coordinator.py
@@ -12,6 +12,7 @@ from renault_api.kamereon.exceptions import (
AccessDeniedException,
KamereonResponseException,
NotSupportedException,
+ QuotaLimitException,
)
from renault_api.kamereon.models import KamereonVehicleDataAttributes
@@ -20,6 +21,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
if TYPE_CHECKING:
from . import RenaultConfigEntry
+ from .renault_hub import RenaultHub
T = TypeVar("T", bound=KamereonVehicleDataAttributes)
@@ -37,6 +39,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
self,
hass: HomeAssistant,
config_entry: RenaultConfigEntry,
+ hub: RenaultHub,
logger: logging.Logger,
*,
name: str,
@@ -54,10 +57,24 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
)
self.access_denied = False
self.not_supported = False
+ self.assumed_state = False
+
self._has_already_worked = False
+ self._hub = hub
async def _async_update_data(self) -> T:
"""Fetch the latest data from the source."""
+
+ if self._hub.is_throttled():
+ if not self._has_already_worked:
+ raise UpdateFailed("Renault hub currently throttled: init skipped")
+ # we have been throttled and decided to cooldown
+ # so do not count this update as an error
+ # coordinator. last_update_success should still be ok
+ self.logger.debug("Renault hub currently throttled: scan skipped")
+ self.assumed_state = True
+ return self.data
+
try:
async with _PARALLEL_SEMAPHORE:
data = await self.update_method()
@@ -70,6 +87,16 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
self.access_denied = True
raise UpdateFailed(f"This endpoint is denied: {err}") from err
+ except QuotaLimitException as err:
+ # The data we got is not bad per see, initiate cooldown for all coordinators
+ self._hub.set_throttled()
+ if self._has_already_worked:
+ self.assumed_state = True
+ self.logger.warning("Renault API throttled")
+ return self.data
+
+ raise UpdateFailed(f"Renault API throttled: {err}") from err
+
except NotSupportedException as err:
# Disable because the vehicle does not support this Renault endpoint.
self.update_interval = None
@@ -81,6 +108,7 @@ class RenaultDataUpdateCoordinator(DataUpdateCoordinator[T]):
raise UpdateFailed(f"Error communicating with API: {err}") from err
self._has_already_worked = True
+ self.assumed_state = False
return data
async def async_config_entry_first_refresh(self) -> None:
diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py
index 08a2a698802..c55ddeb2190 100644
--- a/homeassistant/components/renault/device_tracker.py
+++ b/homeassistant/components/renault/device_tracker.py
@@ -11,7 +11,7 @@ from homeassistant.components.device_tracker import (
TrackerEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RenaultConfigEntry
from .entity import RenaultDataEntity, RenaultDataEntityDescription
@@ -30,7 +30,7 @@ class RenaultTrackerEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RenaultConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Renault entities from config entry."""
entities: list[RenaultDeviceTracker] = [
diff --git a/homeassistant/components/renault/entity.py b/homeassistant/components/renault/entity.py
index 7beb91e9603..81d81a18b7f 100644
--- a/homeassistant/components/renault/entity.py
+++ b/homeassistant/components/renault/entity.py
@@ -60,3 +60,8 @@ class RenaultDataEntity(
def _get_data_attr(self, key: str) -> StateType:
"""Return the attribute value from the coordinator data."""
return cast(StateType, getattr(self.coordinator.data, key))
+
+ @property
+ def assumed_state(self) -> bool:
+ """Return True if unable to access real state of the entity."""
+ return self.coordinator.assumed_state
diff --git a/homeassistant/components/renault/icons.json b/homeassistant/components/renault/icons.json
index 8b9c4885eaa..aa9175052fb 100644
--- a/homeassistant/components/renault/icons.json
+++ b/homeassistant/components/renault/icons.json
@@ -35,7 +35,7 @@
},
"sensor": {
"charge_state": {
- "default": "mdi:mdi:flash-off",
+ "default": "mdi:flash-off",
"state": {
"charge_in_progress": "mdi:flash"
}
diff --git a/homeassistant/components/renault/renault_hub.py b/homeassistant/components/renault/renault_hub.py
index b37390526cf..1f883435dee 100644
--- a/homeassistant/components/renault/renault_hub.py
+++ b/homeassistant/components/renault/renault_hub.py
@@ -27,8 +27,14 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
if TYPE_CHECKING:
from . import RenaultConfigEntry
-from .const import CONF_KAMEREON_ACCOUNT_ID, DEFAULT_SCAN_INTERVAL
-from .renault_vehicle import RenaultVehicleProxy
+from time import time
+
+from .const import (
+ CONF_KAMEREON_ACCOUNT_ID,
+ COOLING_UPDATES_SECONDS,
+ MAX_CALLS_PER_HOURS,
+)
+from .renault_vehicle import COORDINATORS, RenaultVehicleProxy
LOGGER = logging.getLogger(__name__)
@@ -45,6 +51,24 @@ class RenaultHub:
self._account: RenaultAccount | None = None
self._vehicles: dict[str, RenaultVehicleProxy] = {}
+ self._got_throttled_at_time: float | None = None
+
+ def set_throttled(self) -> None:
+ """We got throttled, we need to adjust the rate limit."""
+ if self._got_throttled_at_time is None:
+ self._got_throttled_at_time = time()
+
+ def is_throttled(self) -> bool:
+ """Check if we are throttled."""
+ if self._got_throttled_at_time is None:
+ return False
+
+ if time() - self._got_throttled_at_time > COOLING_UPDATES_SECONDS:
+ self._got_throttled_at_time = None
+ return False
+
+ return True
+
async def attempt_login(self, username: str, password: str) -> bool:
"""Attempt login to Renault servers."""
try:
@@ -58,7 +82,6 @@ class RenaultHub:
async def async_initialise(self, config_entry: RenaultConfigEntry) -> None:
"""Set up proxy."""
account_id: str = config_entry.data[CONF_KAMEREON_ACCOUNT_ID]
- scan_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL)
self._account = await self._client.get_api_account(account_id)
vehicles = await self._account.get_vehicles()
@@ -70,6 +93,12 @@ class RenaultHub:
raise ConfigEntryNotReady(
"Failed to retrieve vehicle details from Renault servers"
)
+
+ num_call_per_scan = len(COORDINATORS) * len(vehicles.vehicleLinks)
+ scan_interval = timedelta(
+ seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS
+ )
+
device_registry = dr.async_get(self._hass)
await asyncio.gather(
*(
@@ -84,6 +113,21 @@ class RenaultHub:
)
)
+ # all vehicles have been initiated with the right number of active coordinators
+ num_call_per_scan = 0
+ for vehicle_link in vehicles.vehicleLinks:
+ vehicle = self._vehicles[str(vehicle_link.vin)]
+ num_call_per_scan += len(vehicle.coordinators)
+
+ new_scan_interval = timedelta(
+ seconds=(3600 * num_call_per_scan) / MAX_CALLS_PER_HOURS
+ )
+ if new_scan_interval != scan_interval:
+ # we need to change the vehicles with the right scan interval
+ for vehicle_link in vehicles.vehicleLinks:
+ vehicle = self._vehicles[str(vehicle_link.vin)]
+ vehicle.update_scan_interval(new_scan_interval)
+
async def async_initialise_vehicle(
self,
vehicle_link: KamereonVehiclesLink,
@@ -99,6 +143,7 @@ class RenaultHub:
vehicle = RenaultVehicleProxy(
hass=self._hass,
config_entry=config_entry,
+ hub=self,
vehicle=await renault_account.get_api_vehicle(vehicle_link.vin),
details=vehicle_link.vehicleDetails,
scan_interval=scan_interval,
diff --git a/homeassistant/components/renault/renault_vehicle.py b/homeassistant/components/renault/renault_vehicle.py
index 1cce0e4459f..8d096a734e1 100644
--- a/homeassistant/components/renault/renault_vehicle.py
+++ b/homeassistant/components/renault/renault_vehicle.py
@@ -20,6 +20,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
if TYPE_CHECKING:
from . import RenaultConfigEntry
+ from .renault_hub import RenaultHub
from .const import DOMAIN
from .coordinator import RenaultDataUpdateCoordinator
@@ -68,6 +69,7 @@ class RenaultVehicleProxy:
self,
hass: HomeAssistant,
config_entry: RenaultConfigEntry,
+ hub: RenaultHub,
vehicle: RenaultVehicle,
details: models.KamereonVehicleDetails,
scan_interval: timedelta,
@@ -87,6 +89,14 @@ class RenaultVehicleProxy:
self.coordinators: dict[str, RenaultDataUpdateCoordinator] = {}
self.hvac_target_temperature = 21
self._scan_interval = scan_interval
+ self._hub = hub
+
+ def update_scan_interval(self, scan_interval: timedelta) -> None:
+ """Set the scan interval for the vehicle."""
+ if scan_interval != self._scan_interval:
+ self._scan_interval = scan_interval
+ for coordinator in self.coordinators.values():
+ coordinator.update_interval = scan_interval
@property
def details(self) -> models.KamereonVehicleDetails:
@@ -104,6 +114,7 @@ class RenaultVehicleProxy:
coord.key: RenaultDataUpdateCoordinator(
self.hass,
self.config_entry,
+ self._hub,
LOGGER,
name=f"{self.details.vin} {coord.key}",
update_method=coord.update_method(self._vehicle),
diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py
index cab1d1f4d8a..cddf83bb860 100644
--- a/homeassistant/components/renault/select.py
+++ b/homeassistant/components/renault/select.py
@@ -9,7 +9,7 @@ from renault_api.kamereon.models import KamereonVehicleBatteryStatusData
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import RenaultConfigEntry
@@ -32,7 +32,7 @@ class RenaultSelectEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RenaultConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Renault entities from config entry."""
entities: list[RenaultSelectEntity] = [
diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py
index 7854d70b1c4..7c513c1b9de 100644
--- a/homeassistant/components/renault/sensor.py
+++ b/homeassistant/components/renault/sensor.py
@@ -31,7 +31,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import as_utc, parse_datetime
@@ -60,7 +60,7 @@ class RenaultSensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RenaultConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Renault entities from config entry."""
entities: list[RenaultSensor[Any]] = [
diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py
index 80fb2363b1e..dfad97ae4ea 100644
--- a/homeassistant/components/renault/services.py
+++ b/homeassistant/components/renault/services.py
@@ -2,14 +2,12 @@
from __future__ import annotations
-from collections.abc import Mapping
from datetime import datetime
import logging
from typing import TYPE_CHECKING, Any
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, device_registry as dr
@@ -106,92 +104,96 @@ SERVICES = [
]
-def setup_services(hass: HomeAssistant) -> None:
- """Register the Renault services."""
+async def ac_cancel(service_call: ServiceCall) -> None:
+ """Cancel A/C."""
+ proxy = get_vehicle_proxy(service_call)
- async def ac_cancel(service_call: ServiceCall) -> None:
- """Cancel A/C."""
- proxy = get_vehicle_proxy(service_call.data)
+ LOGGER.debug("A/C cancel attempt")
+ result = await proxy.set_ac_stop()
+ LOGGER.debug("A/C cancel result: %s", result)
- LOGGER.debug("A/C cancel attempt")
- result = await proxy.set_ac_stop()
- LOGGER.debug("A/C cancel result: %s", result)
- async def ac_start(service_call: ServiceCall) -> None:
- """Start A/C."""
- temperature: float = service_call.data[ATTR_TEMPERATURE]
- when: datetime | None = service_call.data.get(ATTR_WHEN)
- proxy = get_vehicle_proxy(service_call.data)
+async def ac_start(service_call: ServiceCall) -> None:
+ """Start A/C."""
+ temperature: float = service_call.data[ATTR_TEMPERATURE]
+ when: datetime | None = service_call.data.get(ATTR_WHEN)
+ proxy = get_vehicle_proxy(service_call)
- LOGGER.debug("A/C start attempt: %s / %s", temperature, when)
- result = await proxy.set_ac_start(temperature, when)
- LOGGER.debug("A/C start result: %s", result.raw_data)
+ LOGGER.debug("A/C start attempt: %s / %s", temperature, when)
+ result = await proxy.set_ac_start(temperature, when)
+ LOGGER.debug("A/C start result: %s", result.raw_data)
- async def charge_set_schedules(service_call: ServiceCall) -> None:
- """Set charge schedules."""
- schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
- proxy = get_vehicle_proxy(service_call.data)
- charge_schedules = await proxy.get_charging_settings()
- for schedule in schedules:
- charge_schedules.update(schedule)
- if TYPE_CHECKING:
- assert charge_schedules.schedules is not None
- LOGGER.debug("Charge set schedules attempt: %s", schedules)
- result = await proxy.set_charge_schedules(charge_schedules.schedules)
+async def charge_set_schedules(service_call: ServiceCall) -> None:
+ """Set charge schedules."""
+ schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
+ proxy = get_vehicle_proxy(service_call)
+ charge_schedules = await proxy.get_charging_settings()
+ for schedule in schedules:
+ charge_schedules.update(schedule)
- LOGGER.debug("Charge set schedules result: %s", result)
- LOGGER.debug(
- "It may take some time before these changes are reflected in your vehicle"
- )
+ if TYPE_CHECKING:
+ assert charge_schedules.schedules is not None
+ LOGGER.debug("Charge set schedules attempt: %s", schedules)
+ result = await proxy.set_charge_schedules(charge_schedules.schedules)
- async def ac_set_schedules(service_call: ServiceCall) -> None:
- """Set A/C schedules."""
- schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
- proxy = get_vehicle_proxy(service_call.data)
- hvac_schedules = await proxy.get_hvac_settings()
+ LOGGER.debug("Charge set schedules result: %s", result)
+ LOGGER.debug(
+ "It may take some time before these changes are reflected in your vehicle"
+ )
- for schedule in schedules:
- hvac_schedules.update(schedule)
- if TYPE_CHECKING:
- assert hvac_schedules.schedules is not None
- LOGGER.debug("HVAC set schedules attempt: %s", schedules)
- result = await proxy.set_hvac_schedules(hvac_schedules.schedules)
+async def ac_set_schedules(service_call: ServiceCall) -> None:
+ """Set A/C schedules."""
+ schedules: list[dict[str, Any]] = service_call.data[ATTR_SCHEDULES]
+ proxy = get_vehicle_proxy(service_call)
+ hvac_schedules = await proxy.get_hvac_settings()
- LOGGER.debug("HVAC set schedules result: %s", result)
- LOGGER.debug(
- "It may take some time before these changes are reflected in your vehicle"
- )
+ for schedule in schedules:
+ hvac_schedules.update(schedule)
- def get_vehicle_proxy(service_call_data: Mapping) -> RenaultVehicleProxy:
- """Get vehicle from service_call data."""
- device_registry = dr.async_get(hass)
- device_id = service_call_data[ATTR_VEHICLE]
- device_entry = device_registry.async_get(device_id)
- if device_entry is None:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="invalid_device_id",
- translation_placeholders={"device_id": device_id},
- )
+ if TYPE_CHECKING:
+ assert hvac_schedules.schedules is not None
+ LOGGER.debug("HVAC set schedules attempt: %s", schedules)
+ result = await proxy.set_hvac_schedules(hvac_schedules.schedules)
- loaded_entries: list[RenaultConfigEntry] = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- and entry.entry_id in device_entry.config_entries
- ]
- for entry in loaded_entries:
- for vin, vehicle in entry.runtime_data.vehicles.items():
- if (DOMAIN, vin) in device_entry.identifiers:
- return vehicle
+ LOGGER.debug("HVAC set schedules result: %s", result)
+ LOGGER.debug(
+ "It may take some time before these changes are reflected in your vehicle"
+ )
+
+
+def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy:
+ """Get vehicle from service_call data."""
+ device_registry = dr.async_get(service_call.hass)
+ device_id = service_call.data[ATTR_VEHICLE]
+ device_entry = device_registry.async_get(device_id)
+ if device_entry is None:
raise ServiceValidationError(
translation_domain=DOMAIN,
- translation_key="no_config_entry_for_device",
- translation_placeholders={"device_id": device_entry.name or device_id},
+ translation_key="invalid_device_id",
+ translation_placeholders={"device_id": device_id},
)
+ loaded_entries: list[RenaultConfigEntry] = [
+ entry
+ for entry in service_call.hass.config_entries.async_loaded_entries(DOMAIN)
+ if entry.entry_id in device_entry.config_entries
+ ]
+ for entry in loaded_entries:
+ for vin, vehicle in entry.runtime_data.vehicles.items():
+ if (DOMAIN, vin) in device_entry.identifiers:
+ return vehicle
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="no_config_entry_for_device",
+ translation_placeholders={"device_id": device_entry.name or device_id},
+ )
+
+
+def setup_services(hass: HomeAssistant) -> None:
+ """Register the Renault services."""
+
hass.services.async_register(
DOMAIN,
SERVICE_AC_CANCEL,
diff --git a/homeassistant/components/renault/strings.json b/homeassistant/components/renault/strings.json
index 7d9cae1bcf1..727e8cf32f1 100644
--- a/homeassistant/components/renault/strings.json
+++ b/homeassistant/components/renault/strings.json
@@ -18,7 +18,7 @@
"data_description": {
"kamereon_account_id": "The Kamereon account ID associated with your vehicle"
},
- "title": "Kamereon Account ID",
+ "title": "Kamereon account ID",
"description": "You have multiple Kamereon accounts associated to this email, please select one"
},
"reauth_confirm": {
@@ -118,7 +118,7 @@
"charge_ended": "Charge ended",
"waiting_for_current_charge": "Waiting for current charge",
"energy_flap_opened": "Energy flap opened",
- "charge_in_progress": "Charging",
+ "charge_in_progress": "[%key:common::state::charging%]",
"charge_error": "Not charging or plugged in",
"unavailable": "Unavailable"
}
@@ -228,10 +228,10 @@
},
"exceptions": {
"invalid_device_id": {
- "message": "No device with id {device_id} was found"
+ "message": "No device with ID {device_id} was found"
},
"no_config_entry_for_device": {
- "message": "No loaded config entry was found for device with id {device_id}"
+ "message": "No loaded config entry was found for device with ID {device_id}"
}
}
}
diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py
index 46f832ed15c..60b4f54b85c 100644
--- a/homeassistant/components/renson/binary_sensor.py
+++ b/homeassistant/components/renson/binary_sensor.py
@@ -24,7 +24,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RensonCoordinator
@@ -86,7 +86,7 @@ BINARY_SENSORS: tuple[RensonBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Call the Renson integration to setup."""
diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py
index 02278a0d6f6..830e5a03a4a 100644
--- a/homeassistant/components/renson/button.py
+++ b/homeassistant/components/renson/button.py
@@ -15,7 +15,7 @@ from homeassistant.components.button import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RensonCoordinator, RensonData
from .const import DOMAIN
@@ -54,7 +54,7 @@ ENTITY_DESCRIPTIONS: tuple[RensonButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Renson button platform."""
diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py
index 00edd4547cb..474ab640943 100644
--- a/homeassistant/components/renson/fan.py
+++ b/homeassistant/components/renson/fan.py
@@ -19,7 +19,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.util.percentage import (
percentage_to_ranged_value,
@@ -85,7 +85,7 @@ SPEED_RANGE: tuple[float, float] = (1, 4)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Renson fan platform."""
diff --git a/homeassistant/components/renson/number.py b/homeassistant/components/renson/number.py
index fb8ab8fc552..67fde1c56dc 100644
--- a/homeassistant/components/renson/number.py
+++ b/homeassistant/components/renson/number.py
@@ -15,7 +15,7 @@ from homeassistant.components.number import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RensonCoordinator
@@ -40,7 +40,7 @@ RENSON_NUMBER_DESCRIPTION = NumberEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Renson number platform."""
diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py
index 1df62e12312..ce7e71b1c0b 100644
--- a/homeassistant/components/renson/sensor.py
+++ b/homeassistant/components/renson/sensor.py
@@ -43,7 +43,7 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RensonData
from .const import DOMAIN
@@ -272,7 +272,7 @@ class RensonSensor(RensonEntity, SensorEntity):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Renson sensor platform."""
diff --git a/homeassistant/components/renson/switch.py b/homeassistant/components/renson/switch.py
index 2cd44d20a6a..3b73bb3dffe 100644
--- a/homeassistant/components/renson/switch.py
+++ b/homeassistant/components/renson/switch.py
@@ -11,7 +11,7 @@ from renson_endura_delta.renson import Level, RensonVentilation
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RensonCoordinator
from .const import DOMAIN
@@ -68,7 +68,7 @@ class RensonBreezeSwitch(RensonEntity, SwitchEntity):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Call the Renson integration to setup."""
diff --git a/homeassistant/components/renson/time.py b/homeassistant/components/renson/time.py
index feb47fadf99..0a07fd2ec4f 100644
--- a/homeassistant/components/renson/time.py
+++ b/homeassistant/components/renson/time.py
@@ -13,7 +13,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RensonData
from .const import DOMAIN
@@ -50,7 +50,7 @@ ENTITY_DESCRIPTIONS: tuple[RensonTimeEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Renson time platform."""
diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py
index 71ca5428740..f7d13c1d90f 100644
--- a/homeassistant/components/reolink/__init__.py
+++ b/homeassistant/components/reolink/__init__.py
@@ -28,7 +28,7 @@ from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
+from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
from .exceptions import PasswordIncompatible, ReolinkException, UserNotAdmin
from .host import ReolinkHost
from .services import async_setup_services
@@ -67,9 +67,7 @@ async def async_setup_entry(
hass: HomeAssistant, config_entry: ReolinkConfigEntry
) -> bool:
"""Set up Reolink from a config entry."""
- host = ReolinkHost(
- hass, config_entry.data, config_entry.options, config_entry.entry_id
- )
+ host = ReolinkHost(hass, config_entry.data, config_entry.options, config_entry)
try:
await host.async_init()
@@ -100,6 +98,7 @@ async def async_setup_entry(
or host.api.use_https != config_entry.data[CONF_USE_HTTPS]
or host.api.supported(None, "privacy_mode")
!= config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE)
+ or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT)
):
if host.api.port != config_entry.data[CONF_PORT]:
_LOGGER.warning(
@@ -108,10 +107,21 @@ async def async_setup_entry(
config_entry.data[CONF_PORT],
host.api.port,
)
+ if (
+ config_entry.data.get(CONF_BC_PORT, host.api.baichuan.port)
+ != host.api.baichuan.port
+ ):
+ _LOGGER.warning(
+ "Baichuan port of Reolink %s, changed from %s to %s",
+ host.api.nvr_name,
+ config_entry.data.get(CONF_BC_PORT),
+ host.api.baichuan.port,
+ )
data = {
**config_entry.data,
CONF_PORT: host.api.port,
CONF_USE_HTTPS: host.api.use_https,
+ CONF_BC_PORT: host.api.baichuan.port,
CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"),
}
hass.config_entries.async_update_entry(config_entry, data=data)
@@ -361,6 +371,9 @@ def migrate_entity_ids(
new_device_id = f"{host.unique_id}"
else:
new_device_id = f"{host.unique_id}_{device_uid[1]}"
+ _LOGGER.debug(
+ "Updating Reolink device UID from %s to %s", device_uid, new_device_id
+ )
new_identifiers = {(DOMAIN, new_device_id)}
device_reg.async_update_device(device.id, new_identifiers=new_identifiers)
@@ -373,6 +386,9 @@ def migrate_entity_ids(
new_device_id = f"{host.unique_id}_{host.api.camera_uid(ch)}"
else:
new_device_id = f"{device_uid[0]}_{host.api.camera_uid(ch)}"
+ _LOGGER.debug(
+ "Updating Reolink device UID from %s to %s", device_uid, new_device_id
+ )
new_identifiers = {(DOMAIN, new_device_id)}
existing_device = device_reg.async_get_device(identifiers=new_identifiers)
if existing_device is None:
@@ -405,13 +421,31 @@ def migrate_entity_ids(
host.unique_id
):
new_id = f"{host.unique_id}_{entity.unique_id.split('_', 1)[1]}"
+ _LOGGER.debug(
+ "Updating Reolink entity unique_id from %s to %s",
+ entity.unique_id,
+ new_id,
+ )
entity_reg.async_update_entity(entity.entity_id, new_unique_id=new_id)
if entity.device_id in ch_device_ids:
ch = ch_device_ids[entity.device_id]
id_parts = entity.unique_id.split("_", 2)
+ if len(id_parts) < 3:
+ _LOGGER.warning(
+ "Reolink channel %s entity has unexpected unique_id format %s, with device id %s",
+ ch,
+ entity.unique_id,
+ entity.device_id,
+ )
+ continue
if host.api.supported(ch, "UID") and id_parts[1] != host.api.camera_uid(ch):
new_id = f"{host.unique_id}_{host.api.camera_uid(ch)}_{id_parts[2]}"
+ _LOGGER.debug(
+ "Updating Reolink entity unique_id from %s to %s",
+ entity.unique_id,
+ new_id,
+ )
existing_entity = entity_reg.async_get_entity_id(
entity.domain, entity.platform, new_id
)
diff --git a/homeassistant/components/reolink/binary_sensor.py b/homeassistant/components/reolink/binary_sensor.py
index 2191dedc9cf..95c5f1982c3 100644
--- a/homeassistant/components/reolink/binary_sensor.py
+++ b/homeassistant/components/reolink/binary_sensor.py
@@ -23,9 +23,13 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
+from .entity import (
+ ReolinkChannelCoordinatorEntity,
+ ReolinkChannelEntityDescription,
+ ReolinkEntityDescription,
+)
from .util import ReolinkConfigEntry, ReolinkData
PARALLEL_UPDATES = 0
@@ -41,6 +45,18 @@ class ReolinkBinarySensorEntityDescription(
value: Callable[[Host, int], bool]
+@dataclass(frozen=True, kw_only=True)
+class ReolinkSmartAIBinarySensorEntityDescription(
+ BinarySensorEntityDescription,
+ ReolinkEntityDescription,
+):
+ """A class that describes Smart AI binary sensor entities."""
+
+ smart_type: str
+ value: Callable[[Host, int, int], bool]
+ supported: Callable[[Host, int, int], bool] = lambda api, ch, loc: True
+
+
BINARY_PUSH_SENSORS = (
ReolinkBinarySensorEntityDescription(
key="motion",
@@ -121,26 +137,173 @@ BINARY_SENSORS = (
),
)
+BINARY_SMART_AI_SENSORS = (
+ ReolinkSmartAIBinarySensorEntityDescription(
+ key="crossline_person",
+ smart_type="crossline",
+ cmd_id=33,
+ translation_key="crossline_person",
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_state(ch, "crossline", loc, "people")
+ ),
+ supported=lambda api, ch, loc: (
+ api.supported(ch, "ai_crossline")
+ and "people" in api.baichuan.smart_ai_type_list(ch, "crossline", loc)
+ ),
+ ),
+ ReolinkSmartAIBinarySensorEntityDescription(
+ key="crossline_vehicle",
+ smart_type="crossline",
+ cmd_id=33,
+ translation_key="crossline_vehicle",
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_state(ch, "crossline", loc, "vehicle")
+ ),
+ supported=lambda api, ch, loc: (
+ api.supported(ch, "ai_crossline")
+ and "vehicle" in api.baichuan.smart_ai_type_list(ch, "crossline", loc)
+ ),
+ ),
+ ReolinkSmartAIBinarySensorEntityDescription(
+ key="crossline_dog_cat",
+ smart_type="crossline",
+ cmd_id=33,
+ translation_key="crossline_dog_cat",
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_state(ch, "crossline", loc, "dog_cat")
+ ),
+ supported=lambda api, ch, loc: (
+ api.supported(ch, "ai_crossline")
+ and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "crossline", loc)
+ ),
+ ),
+ ReolinkSmartAIBinarySensorEntityDescription(
+ key="intrusion_person",
+ smart_type="intrusion",
+ cmd_id=33,
+ translation_key="intrusion_person",
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_state(ch, "intrusion", loc, "people")
+ ),
+ supported=lambda api, ch, loc: (
+ api.supported(ch, "ai_intrusion")
+ and "people" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc)
+ ),
+ ),
+ ReolinkSmartAIBinarySensorEntityDescription(
+ key="intrusion_vehicle",
+ smart_type="intrusion",
+ cmd_id=33,
+ translation_key="intrusion_vehicle",
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_state(ch, "intrusion", loc, "vehicle")
+ ),
+ supported=lambda api, ch, loc: (
+ api.supported(ch, "ai_intrusion")
+ and "vehicle" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc)
+ ),
+ ),
+ ReolinkSmartAIBinarySensorEntityDescription(
+ key="intrusion_dog_cat",
+ smart_type="intrusion",
+ cmd_id=33,
+ translation_key="intrusion_dog_cat",
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_state(ch, "intrusion", loc, "dog_cat")
+ ),
+ supported=lambda api, ch, loc: (
+ api.supported(ch, "ai_intrusion")
+ and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "intrusion", loc)
+ ),
+ ),
+ ReolinkSmartAIBinarySensorEntityDescription(
+ key="linger_person",
+ smart_type="loitering",
+ cmd_id=33,
+ translation_key="linger_person",
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_state(ch, "loitering", loc, "people")
+ ),
+ supported=lambda api, ch, loc: (
+ api.supported(ch, "ai_linger")
+ and "people" in api.baichuan.smart_ai_type_list(ch, "loitering", loc)
+ ),
+ ),
+ ReolinkSmartAIBinarySensorEntityDescription(
+ key="linger_vehicle",
+ smart_type="loitering",
+ cmd_id=33,
+ translation_key="linger_vehicle",
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_state(ch, "loitering", loc, "vehicle")
+ ),
+ supported=lambda api, ch, loc: (
+ api.supported(ch, "ai_linger")
+ and "vehicle" in api.baichuan.smart_ai_type_list(ch, "loitering", loc)
+ ),
+ ),
+ ReolinkSmartAIBinarySensorEntityDescription(
+ key="linger_dog_cat",
+ smart_type="loitering",
+ cmd_id=33,
+ translation_key="linger_dog_cat",
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_state(ch, "loitering", loc, "dog_cat")
+ ),
+ supported=lambda api, ch, loc: (
+ api.supported(ch, "ai_linger")
+ and "dog_cat" in api.baichuan.smart_ai_type_list(ch, "loitering", loc)
+ ),
+ ),
+ ReolinkSmartAIBinarySensorEntityDescription(
+ key="forgotten_item",
+ smart_type="legacy",
+ cmd_id=33,
+ translation_key="forgotten_item",
+ value=lambda api, ch, loc: (api.baichuan.smart_ai_state(ch, "legacy", loc)),
+ supported=lambda api, ch, loc: api.supported(ch, "ai_forgotten_item"),
+ ),
+ ReolinkSmartAIBinarySensorEntityDescription(
+ key="taken_item",
+ smart_type="loss",
+ cmd_id=33,
+ translation_key="taken_item",
+ value=lambda api, ch, loc: (api.baichuan.smart_ai_state(ch, "loss", loc)),
+ supported=lambda api, ch, loc: api.supported(ch, "ai_taken_item"),
+ ),
+)
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ReolinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Reolink IP Camera."""
reolink_data: ReolinkData = config_entry.runtime_data
+ api = reolink_data.host.api
- entities: list[ReolinkBinarySensorEntity] = []
- for channel in reolink_data.host.api.channels:
+ entities: list[ReolinkBinarySensorEntity | ReolinkSmartAIBinarySensorEntity] = []
+ for channel in api.channels:
entities.extend(
ReolinkPushBinarySensorEntity(reolink_data, channel, entity_description)
for entity_description in BINARY_PUSH_SENSORS
- if entity_description.supported(reolink_data.host.api, channel)
+ if entity_description.supported(api, channel)
)
entities.extend(
ReolinkBinarySensorEntity(reolink_data, channel, entity_description)
for entity_description in BINARY_SENSORS
- if entity_description.supported(reolink_data.host.api, channel)
+ if entity_description.supported(api, channel)
+ )
+ entities.extend(
+ ReolinkSmartAIBinarySensorEntity(
+ reolink_data, channel, location, entity_description
+ )
+ for entity_description in BINARY_SMART_AI_SENSORS
+ for location in api.baichuan.smart_location_list(
+ channel, entity_description.smart_type
+ )
+ if entity_description.supported(api, channel, location)
)
async_add_entities(entities)
@@ -198,3 +361,40 @@ class ReolinkPushBinarySensorEntity(ReolinkBinarySensorEntity):
async def _async_handle_event(self, event: str) -> None:
"""Handle incoming event for motion detection."""
self.async_write_ha_state()
+
+
+class ReolinkSmartAIBinarySensorEntity(
+ ReolinkChannelCoordinatorEntity, BinarySensorEntity
+):
+ """Binary-sensor class for Reolink IP camera Smart AI sensors."""
+
+ entity_description: ReolinkSmartAIBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ channel: int,
+ location: int,
+ entity_description: ReolinkSmartAIBinarySensorEntityDescription,
+ ) -> None:
+ """Initialize Reolink binary sensor."""
+ self.entity_description = entity_description
+ super().__init__(reolink_data, channel)
+ unique_index = self._host.api.baichuan.smart_ai_index(
+ channel, entity_description.smart_type, location
+ )
+ self._attr_unique_id = f"{self._attr_unique_id}_{unique_index}"
+
+ self._location = location
+ self._attr_translation_placeholders = {
+ "zone_name": self._host.api.baichuan.smart_ai_name(
+ channel, entity_description.smart_type, location
+ )
+ }
+
+ @property
+ def is_on(self) -> bool:
+ """State of the sensor."""
+ return self.entity_description.value(
+ self._host.api, self._channel, self._location
+ )
diff --git a/homeassistant/components/reolink/button.py b/homeassistant/components/reolink/button.py
index c1a2aed4119..a67b30a394c 100644
--- a/homeassistant/components/reolink/button.py
+++ b/homeassistant/components/reolink/button.py
@@ -19,7 +19,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
@@ -151,7 +151,7 @@ HOST_BUTTON_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ReolinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Reolink button entities."""
reolink_data: ReolinkData = config_entry.runtime_data
diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py
index a597be3ec7a..329ef9028de 100644
--- a/homeassistant/components/reolink/camera.py
+++ b/homeassistant/components/reolink/camera.py
@@ -13,7 +13,7 @@ from homeassistant.components.camera import (
CameraEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
@@ -89,7 +89,7 @@ CAMERA_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ReolinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Reolink IP Camera."""
reolink_data: ReolinkData = config_entry.runtime_data
diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py
index 7943cadef21..12ccd455be3 100644
--- a/homeassistant/components/reolink/config_flow.py
+++ b/homeassistant/components/reolink/config_flow.py
@@ -8,6 +8,7 @@ import logging
from typing import Any
from reolink_aio.api import ALLOWED_SPECIAL_CHARS
+from reolink_aio.baichuan import DEFAULT_BC_PORT
from reolink_aio.exceptions import (
ApiError,
CredentialsInvalidError,
@@ -37,7 +38,7 @@ from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
-from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
+from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
from .exceptions import (
PasswordIncompatible,
ReolinkException,
@@ -287,6 +288,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
if not errors:
user_input[CONF_PORT] = host.api.port
user_input[CONF_USE_HTTPS] = host.api.use_https
+ user_input[CONF_BC_PORT] = host.api.baichuan.port
user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported(
None, "privacy_mode"
)
@@ -326,8 +328,9 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
if errors:
data_schema = data_schema.extend(
{
- vol.Optional(CONF_PORT): cv.positive_int,
+ vol.Optional(CONF_PORT): cv.port,
vol.Required(CONF_USE_HTTPS, default=False): bool,
+ vol.Required(CONF_BC_PORT, default=DEFAULT_BC_PORT): cv.port,
}
)
diff --git a/homeassistant/components/reolink/const.py b/homeassistant/components/reolink/const.py
index 7bd93337c46..026d1219881 100644
--- a/homeassistant/components/reolink/const.py
+++ b/homeassistant/components/reolink/const.py
@@ -3,4 +3,5 @@
DOMAIN = "reolink"
CONF_USE_HTTPS = "use_https"
+CONF_BC_PORT = "baichuan_port"
CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported"
diff --git a/homeassistant/components/reolink/diagnostics.py b/homeassistant/components/reolink/diagnostics.py
index 693f2ba59a4..1d0e5d919e7 100644
--- a/homeassistant/components/reolink/diagnostics.py
+++ b/homeassistant/components/reolink/diagnostics.py
@@ -25,6 +25,14 @@ async def async_get_config_entry_diagnostics(
IPC_cam[ch]["firmware version"] = api.camera_sw_version(ch)
IPC_cam[ch]["encoding main"] = await api.get_encoding(ch)
+ chimes: dict[int, dict[str, Any]] = {}
+ for chime in api.chime_list:
+ chimes[chime.dev_id] = {}
+ chimes[chime.dev_id]["channel"] = chime.channel
+ chimes[chime.dev_id]["name"] = chime.name
+ chimes[chime.dev_id]["online"] = chime.online
+ chimes[chime.dev_id]["event_types"] = chime.chime_event_types
+
return {
"model": api.model,
"hardware version": api.hardware_version,
@@ -41,9 +49,11 @@ async def async_get_config_entry_diagnostics(
"channels": api.channels,
"stream channels": api.stream_channels,
"IPC cams": IPC_cam,
+ "Chimes": chimes,
"capabilities": api.capabilities,
"cmd list": host.update_cmd,
"firmware ch list": host.firmware_ch_list,
"api versions": api.checked_api_versions,
"abilities": api.abilities,
+ "BC_abilities": api.baichuan.abilities,
}
diff --git a/homeassistant/components/reolink/entity.py b/homeassistant/components/reolink/entity.py
index e3a84579865..ec598de663d 100644
--- a/homeassistant/components/reolink/entity.py
+++ b/homeassistant/components/reolink/entity.py
@@ -107,10 +107,10 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
"""Handle incoming TCP push event."""
self.async_write_ha_state()
- def register_callback(self, unique_id: str, cmd_id: int) -> None:
+ def register_callback(self, callback_id: str, cmd_id: int) -> None:
"""Register callback for TCP push events."""
self._host.api.baichuan.register_callback( # pragma: no cover
- unique_id, self._push_callback, cmd_id
+ callback_id, self._push_callback, cmd_id
)
async def async_added_to_hass(self) -> None:
@@ -118,23 +118,25 @@ class ReolinkHostCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinator[None]
await super().async_added_to_hass()
cmd_key = self.entity_description.cmd_key
cmd_id = self.entity_description.cmd_id
+ callback_id = f"{self.platform.domain}_{self._attr_unique_id}"
if cmd_key is not None:
self._host.async_register_update_cmd(cmd_key)
if cmd_id is not None:
- self.register_callback(self._attr_unique_id, cmd_id)
+ self.register_callback(callback_id, cmd_id)
# Privacy mode
- self.register_callback(f"{self._attr_unique_id}_623", 623)
+ self.register_callback(f"{callback_id}_623", 623)
async def async_will_remove_from_hass(self) -> None:
"""Entity removed."""
cmd_key = self.entity_description.cmd_key
cmd_id = self.entity_description.cmd_id
+ callback_id = f"{self.platform.domain}_{self._attr_unique_id}"
if cmd_key is not None:
self._host.async_unregister_update_cmd(cmd_key)
if cmd_id is not None:
- self._host.api.baichuan.unregister_callback(self._attr_unique_id)
+ self._host.api.baichuan.unregister_callback(callback_id)
# Privacy mode
- self._host.api.baichuan.unregister_callback(f"{self._attr_unique_id}_623")
+ self._host.api.baichuan.unregister_callback(f"{callback_id}_623")
await super().async_will_remove_from_hass()
@@ -176,8 +178,13 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
else:
self._dev_id = f"{self._host.unique_id}_ch{dev_ch}"
+ connections = set()
+ if mac := self._host.api.baichuan.mac_address(dev_ch):
+ connections.add((CONNECTION_NETWORK_MAC, mac))
+
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._dev_id)},
+ connections=connections,
via_device=(DOMAIN, self._host.unique_id),
name=self._host.api.camera_name(dev_ch),
model=self._host.api.camera_model(dev_ch),
@@ -193,10 +200,10 @@ class ReolinkChannelCoordinatorEntity(ReolinkHostCoordinatorEntity):
"""Return True if entity is available."""
return super().available and self._host.api.camera_online(self._channel)
- def register_callback(self, unique_id: str, cmd_id: int) -> None:
+ def register_callback(self, callback_id: str, cmd_id: int) -> None:
"""Register callback for TCP push events."""
self._host.api.baichuan.register_callback(
- unique_id, self._push_callback, cmd_id, self._channel
+ callback_id, self._push_callback, cmd_id, self._channel
)
async def async_added_to_hass(self) -> None:
diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py
index 2f646ba9090..a027177f1fc 100644
--- a/homeassistant/components/reolink/host.py
+++ b/homeassistant/components/reolink/host.py
@@ -12,6 +12,7 @@ from typing import Any, Literal
import aiohttp
from aiohttp.web import Request
from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host
+from reolink_aio.baichuan import DEFAULT_BC_PORT
from reolink_aio.enums import SubType
from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError
@@ -33,14 +34,14 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.storage import Store
from homeassistant.util.ssl import SSLCipherList
-from .const import CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
+from .const import CONF_BC_PORT, CONF_SUPPORTS_PRIVACY_MODE, CONF_USE_HTTPS, DOMAIN
from .exceptions import (
PasswordIncompatible,
ReolinkSetupException,
ReolinkWebhookException,
UserNotAdmin,
)
-from .util import get_store
+from .util import ReolinkConfigEntry, get_store
DEFAULT_TIMEOUT = 30
FIRST_TCP_PUSH_TIMEOUT = 10
@@ -66,11 +67,11 @@ class ReolinkHost:
hass: HomeAssistant,
config: Mapping[str, Any],
options: Mapping[str, Any],
- config_entry_id: str | None = None,
+ config_entry: ReolinkConfigEntry | None = None,
) -> None:
"""Initialize Reolink Host. Could be either NVR, or Camera."""
self._hass: HomeAssistant = hass
- self._config_entry_id = config_entry_id
+ self._config_entry = config_entry
self._config = config
self._unique_id: str = ""
@@ -91,6 +92,7 @@ class ReolinkHost:
protocol=options[CONF_PROTOCOL],
timeout=DEFAULT_TIMEOUT,
aiohttp_get_session_callback=get_aiohttp_session,
+ bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT),
)
self.last_wake: float = 0
@@ -149,15 +151,33 @@ class ReolinkHost:
async def async_init(self) -> None:
"""Connect to Reolink host."""
if not self._api.valid_password():
+ if (
+ len(self._config[CONF_PASSWORD]) >= 32
+ and self._config_entry is not None
+ ):
+ ir.async_create_issue(
+ self._hass,
+ DOMAIN,
+ f"password_too_long_{self._config_entry.entry_id}",
+ is_fixable=True,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="password_too_long",
+ translation_placeholders={"name": self._config_entry.title},
+ )
+
raise PasswordIncompatible(
- "Reolink password contains incompatible special character, "
- "please change the password to only contain characters: "
- f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS}"
+ "Reolink password contains incompatible special character or "
+ "is too long, please change the password to only contain characters: "
+ f"a-z, A-Z, 0-9 or {ALLOWED_SPECIAL_CHARS} "
+ "and not be longer than 31 characters"
)
store: Store[str] | None = None
- if self._config_entry_id is not None:
- store = get_store(self._hass, self._config_entry_id)
+ if self._config_entry is not None:
+ ir.async_delete_issue(
+ self._hass, DOMAIN, f"password_too_long_{self._config_entry.entry_id}"
+ )
+ store = get_store(self._hass, self._config_entry.entry_id)
if self._config.get(CONF_SUPPORTS_PRIVACY_MODE) and (
data := await store.async_load()
):
diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json
index 26198a11594..7df82dfc512 100644
--- a/homeassistant/components/reolink/icons.json
+++ b/homeassistant/components/reolink/icons.json
@@ -54,6 +54,72 @@
"state": {
"on": "mdi:sleep"
}
+ },
+ "crossline_person": {
+ "default": "mdi:fence",
+ "state": {
+ "on": "mdi:fence-electric"
+ }
+ },
+ "crossline_vehicle": {
+ "default": "mdi:fence",
+ "state": {
+ "on": "mdi:fence-electric"
+ }
+ },
+ "crossline_dog_cat": {
+ "default": "mdi:fence",
+ "state": {
+ "on": "mdi:fence-electric"
+ }
+ },
+ "intrusion_person": {
+ "default": "mdi:location-enter",
+ "state": {
+ "on": "mdi:alert-circle-outline"
+ }
+ },
+ "intrusion_vehicle": {
+ "default": "mdi:location-enter",
+ "state": {
+ "on": "mdi:alert-circle-outline"
+ }
+ },
+ "intrusion_dog_cat": {
+ "default": "mdi:location-enter",
+ "state": {
+ "on": "mdi:alert-circle-outline"
+ }
+ },
+ "linger_person": {
+ "default": "mdi:account-switch",
+ "state": {
+ "on": "mdi:account-alert"
+ }
+ },
+ "linger_vehicle": {
+ "default": "mdi:account-switch",
+ "state": {
+ "on": "mdi:account-alert"
+ }
+ },
+ "linger_dog_cat": {
+ "default": "mdi:account-switch",
+ "state": {
+ "on": "mdi:account-alert"
+ }
+ },
+ "forgotten_item": {
+ "default": "mdi:package-variant-closed-plus",
+ "state": {
+ "on": "mdi:package-variant-closed-check"
+ }
+ },
+ "taken_item": {
+ "default": "mdi:package-variant-closed-minus",
+ "state": {
+ "on": "mdi:package-variant-closed-check"
+ }
}
},
"button": {
@@ -151,6 +217,21 @@
"ai_animal_sensitivity": {
"default": "mdi:paw"
},
+ "crossline_sensitivity": {
+ "default": "mdi:fence"
+ },
+ "intrusion_sensitivity": {
+ "default": "mdi:location-enter"
+ },
+ "linger_sensitivity": {
+ "default": "mdi:account-switch"
+ },
+ "forgotten_item_sensitivity": {
+ "default": "mdi:package-variant-closed-plus"
+ },
+ "taken_item_sensitivity": {
+ "default": "mdi:package-variant-closed-minus"
+ },
"ai_face_delay": {
"default": "mdi:face-recognition"
},
@@ -169,6 +250,18 @@
"ai_animal_delay": {
"default": "mdi:paw"
},
+ "intrusion_delay": {
+ "default": "mdi:location-enter"
+ },
+ "linger_delay": {
+ "default": "mdi:account-switch"
+ },
+ "forgotten_item_delay": {
+ "default": "mdi:package-variant-closed-plus"
+ },
+ "taken_item_delay": {
+ "default": "mdi:package-variant-closed-minus"
+ },
"auto_quick_reply_time": {
"default": "mdi:message-reply-text-outline"
},
@@ -284,6 +377,12 @@
},
"sub_bit_rate": {
"default": "mdi:play-speed"
+ },
+ "scene_mode": {
+ "default": "mdi:view-list"
+ },
+ "packing_time": {
+ "default": "mdi:record-rec"
}
},
"sensor": {
@@ -299,6 +398,9 @@
"battery_state": {
"default": "mdi:battery-charging"
},
+ "day_night_state": {
+ "default": "mdi:theme-light-dark"
+ },
"wifi_signal": {
"default": "mdi:wifi"
},
diff --git a/homeassistant/components/reolink/light.py b/homeassistant/components/reolink/light.py
index bbb9592dd76..d48790264d1 100644
--- a/homeassistant/components/reolink/light.py
+++ b/homeassistant/components/reolink/light.py
@@ -16,7 +16,7 @@ from homeassistant.components.light import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import (
ReolinkChannelCoordinatorEntity,
@@ -92,7 +92,7 @@ HOST_LIGHT_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ReolinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Reolink light entities."""
reolink_data: ReolinkData = config_entry.runtime_data
diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json
index 505358a07f7..59a2741571f 100644
--- a/homeassistant/components/reolink/manifest.json
+++ b/homeassistant/components/reolink/manifest.json
@@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
- "requirements": ["reolink-aio==0.11.10"]
+ "requirements": ["reolink-aio==0.13.2"]
}
diff --git a/homeassistant/components/reolink/media_source.py b/homeassistant/components/reolink/media_source.py
index e912bfb5100..092f0d4ddca 100644
--- a/homeassistant/components/reolink/media_source.py
+++ b/homeassistant/components/reolink/media_source.py
@@ -18,7 +18,6 @@ from homeassistant.components.media_source import (
Unresolvable,
)
from homeassistant.components.stream import create_stream
-from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -71,7 +70,7 @@ class ReolinkVODMediaSource(MediaSource):
host = get_host(self.hass, config_entry_id)
def get_vod_type() -> VodRequestType:
- if filename.endswith(".mp4"):
+ if filename.endswith((".mp4", ".vref")) or host.api.is_hub:
if host.api.is_nvr:
return VodRequestType.DOWNLOAD
return VodRequestType.PLAYBACK
@@ -151,9 +150,7 @@ class ReolinkVODMediaSource(MediaSource):
entity_reg = er.async_get(self.hass)
device_reg = dr.async_get(self.hass)
- for config_entry in self.hass.config_entries.async_entries(DOMAIN):
- if config_entry.state != ConfigEntryState.LOADED:
- continue
+ for config_entry in self.hass.config_entries.async_loaded_entries(DOMAIN):
channels: list[str] = []
host = config_entry.runtime_data.host
entities = er.async_entries_for_config_entry(
@@ -222,7 +219,7 @@ class ReolinkVODMediaSource(MediaSource):
if main_enc == "h265":
_LOGGER.debug(
"Reolink camera %s uses h265 encoding for main stream,"
- "playback only possible using sub stream",
+ "playback at high resolution may not work in all browsers/apps",
host.api.camera_name(channel),
)
@@ -236,34 +233,29 @@ class ReolinkVODMediaSource(MediaSource):
can_play=False,
can_expand=True,
),
+ BrowseMediaSource(
+ domain=DOMAIN,
+ identifier=f"RES|{config_entry_id}|{channel}|main",
+ media_class=MediaClass.CHANNEL,
+ media_content_type=MediaType.PLAYLIST,
+ title="High resolution",
+ can_play=False,
+ can_expand=True,
+ ),
]
- if main_enc != "h265":
- children.append(
- BrowseMediaSource(
- domain=DOMAIN,
- identifier=f"RES|{config_entry_id}|{channel}|main",
- media_class=MediaClass.CHANNEL,
- media_content_type=MediaType.PLAYLIST,
- title="High resolution",
- can_play=False,
- can_expand=True,
- ),
- )
if host.api.supported(channel, "autotrack_stream"):
- children.append(
- BrowseMediaSource(
- domain=DOMAIN,
- identifier=f"RES|{config_entry_id}|{channel}|autotrack_sub",
- media_class=MediaClass.CHANNEL,
- media_content_type=MediaType.PLAYLIST,
- title="Autotrack low resolution",
- can_play=False,
- can_expand=True,
- ),
- )
- if main_enc != "h265":
- children.append(
+ children.extend(
+ [
+ BrowseMediaSource(
+ domain=DOMAIN,
+ identifier=f"RES|{config_entry_id}|{channel}|autotrack_sub",
+ media_class=MediaClass.CHANNEL,
+ media_content_type=MediaType.PLAYLIST,
+ title="Autotrack low resolution",
+ can_play=False,
+ can_expand=True,
+ ),
BrowseMediaSource(
domain=DOMAIN,
identifier=f"RES|{config_entry_id}|{channel}|autotrack_main",
@@ -273,11 +265,7 @@ class ReolinkVODMediaSource(MediaSource):
can_play=False,
can_expand=True,
),
- )
-
- if len(children) == 1:
- return await self._async_generate_camera_days(
- config_entry_id, channel, "sub"
+ ]
)
title = host.api.camera_name(channel)
diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py
index d8fabfaa3b8..2a6fb740ee0 100644
--- a/homeassistant/components/reolink/number.py
+++ b/homeassistant/components/reolink/number.py
@@ -9,13 +9,14 @@ from typing import Any
from reolink_aio.api import Chime, Host
from homeassistant.components.number import (
+ NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import (
ReolinkChannelCoordinatorEntity,
@@ -44,6 +45,19 @@ class ReolinkNumberEntityDescription(
value: Callable[[Host, int], float | None]
+@dataclass(frozen=True, kw_only=True)
+class ReolinkSmartAINumberEntityDescription(
+ NumberEntityDescription,
+ ReolinkChannelEntityDescription,
+):
+ """A class that describes smart AI number entities."""
+
+ smart_type: str
+ method: Callable[[Host, int, int, float], Any]
+ mode: NumberMode = NumberMode.AUTO
+ value: Callable[[Host, int, int], float | None]
+
+
@dataclass(frozen=True, kw_only=True)
class ReolinkHostNumberEntityDescription(
NumberEntityDescription,
@@ -125,6 +139,7 @@ NUMBER_ENTITIES = (
cmd_key="GetPtzGuard",
translation_key="guard_return_time",
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
native_min_value=10,
@@ -248,6 +263,7 @@ NUMBER_ENTITIES = (
cmd_key="GetAiAlarm",
translation_key="ai_face_delay",
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
entity_registry_enabled_default=False,
native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -264,6 +280,7 @@ NUMBER_ENTITIES = (
cmd_key="GetAiAlarm",
translation_key="ai_person_delay",
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
entity_registry_enabled_default=False,
native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -280,6 +297,7 @@ NUMBER_ENTITIES = (
cmd_key="GetAiAlarm",
translation_key="ai_vehicle_delay",
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
entity_registry_enabled_default=False,
native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -296,6 +314,7 @@ NUMBER_ENTITIES = (
cmd_key="GetAiAlarm",
translation_key="ai_package_delay",
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
entity_registry_enabled_default=False,
native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -312,6 +331,7 @@ NUMBER_ENTITIES = (
cmd_key="GetAiAlarm",
translation_key="ai_pet_delay",
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
entity_registry_enabled_default=False,
native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -330,6 +350,7 @@ NUMBER_ENTITIES = (
cmd_key="GetAiAlarm",
translation_key="ai_animal_delay",
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
entity_registry_enabled_default=False,
native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
@@ -346,6 +367,7 @@ NUMBER_ENTITIES = (
cmd_key="GetAutoReply",
translation_key="auto_quick_reply_time",
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
native_min_value=1,
@@ -385,6 +407,7 @@ NUMBER_ENTITIES = (
cmd_key="GetAiCfg",
translation_key="auto_track_disappear_time",
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
native_min_value=1,
@@ -400,6 +423,7 @@ NUMBER_ENTITIES = (
cmd_key="GetAiCfg",
translation_key="auto_track_stop_time",
entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
native_step=1,
native_unit_of_measurement=UnitOfTime.SECONDS,
native_min_value=1,
@@ -493,6 +517,168 @@ NUMBER_ENTITIES = (
),
)
+SMART_AI_NUMBER_ENTITIES = (
+ ReolinkSmartAINumberEntityDescription(
+ key="crossline_sensitivity",
+ smart_type="crossline",
+ cmd_id=527,
+ translation_key="crossline_sensitivity",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ native_step=1,
+ native_min_value=0,
+ native_max_value=100,
+ supported=lambda api, ch: api.supported(ch, "ai_crossline"),
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_sensitivity(ch, "crossline", loc)
+ ),
+ method=lambda api, ch, loc, value: api.baichuan.set_smart_ai(
+ ch, "crossline", loc, sensitivity=int(value)
+ ),
+ ),
+ ReolinkSmartAINumberEntityDescription(
+ key="intrusion_sensitivity",
+ smart_type="intrusion",
+ cmd_id=529,
+ translation_key="intrusion_sensitivity",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ native_step=1,
+ native_min_value=0,
+ native_max_value=100,
+ supported=lambda api, ch: api.supported(ch, "ai_intrusion"),
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_sensitivity(ch, "intrusion", loc)
+ ),
+ method=lambda api, ch, loc, value: api.baichuan.set_smart_ai(
+ ch, "intrusion", loc, sensitivity=int(value)
+ ),
+ ),
+ ReolinkSmartAINumberEntityDescription(
+ key="linger_sensitivity",
+ smart_type="loitering",
+ cmd_id=531,
+ translation_key="linger_sensitivity",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ native_step=1,
+ native_min_value=0,
+ native_max_value=100,
+ supported=lambda api, ch: api.supported(ch, "ai_linger"),
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_sensitivity(ch, "loitering", loc)
+ ),
+ method=lambda api, ch, loc, value: api.baichuan.set_smart_ai(
+ ch, "loitering", loc, sensitivity=int(value)
+ ),
+ ),
+ ReolinkSmartAINumberEntityDescription(
+ key="forgotten_item_sensitivity",
+ smart_type="legacy",
+ cmd_id=549,
+ translation_key="forgotten_item_sensitivity",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ native_step=1,
+ native_min_value=0,
+ native_max_value=100,
+ supported=lambda api, ch: api.supported(ch, "ai_forgotten_item"),
+ value=lambda api, ch, loc: (
+ api.baichuan.smart_ai_sensitivity(ch, "legacy", loc)
+ ),
+ method=lambda api, ch, loc, value: api.baichuan.set_smart_ai(
+ ch, "legacy", loc, sensitivity=int(value)
+ ),
+ ),
+ ReolinkSmartAINumberEntityDescription(
+ key="taken_item_sensitivity",
+ smart_type="loss",
+ cmd_id=551,
+ translation_key="taken_item_sensitivity",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ native_step=1,
+ native_min_value=0,
+ native_max_value=100,
+ supported=lambda api, ch: api.supported(ch, "ai_taken_item"),
+ value=lambda api, ch, loc: api.baichuan.smart_ai_sensitivity(ch, "loss", loc),
+ method=lambda api, ch, loc, value: api.baichuan.set_smart_ai(
+ ch, "loss", loc, sensitivity=int(value)
+ ),
+ ),
+ ReolinkSmartAINumberEntityDescription(
+ key="intrusion_delay",
+ smart_type="intrusion",
+ cmd_id=529,
+ translation_key="intrusion_delay",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
+ native_step=1,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ native_min_value=0,
+ native_max_value=10,
+ supported=lambda api, ch: api.supported(ch, "ai_intrusion"),
+ value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "intrusion", loc),
+ method=lambda api, ch, loc, value: api.baichuan.set_smart_ai(
+ ch, "intrusion", loc, delay=int(value)
+ ),
+ ),
+ ReolinkSmartAINumberEntityDescription(
+ key="linger_delay",
+ smart_type="loitering",
+ cmd_id=531,
+ translation_key="linger_delay",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ native_step=1,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ native_min_value=1,
+ native_max_value=10,
+ supported=lambda api, ch: api.supported(ch, "ai_linger"),
+ value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "loitering", loc),
+ method=lambda api, ch, loc, value: api.baichuan.set_smart_ai(
+ ch, "loitering", loc, delay=int(value)
+ ),
+ ),
+ ReolinkSmartAINumberEntityDescription(
+ key="forgotten_item_delay",
+ smart_type="legacy",
+ cmd_id=549,
+ translation_key="forgotten_item_delay",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
+ native_step=1,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ native_min_value=1,
+ native_max_value=30,
+ supported=lambda api, ch: api.supported(ch, "ai_forgotten_item"),
+ value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "legacy", loc),
+ method=lambda api, ch, loc, value: api.baichuan.set_smart_ai(
+ ch, "legacy", loc, delay=int(value)
+ ),
+ ),
+ ReolinkSmartAINumberEntityDescription(
+ key="taken_item_delay",
+ smart_type="loss",
+ cmd_id=551,
+ translation_key="taken_item_delay",
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ device_class=NumberDeviceClass.DURATION,
+ native_step=1,
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ native_min_value=1,
+ native_max_value=30,
+ supported=lambda api, ch: api.supported(ch, "ai_taken_item"),
+ value=lambda api, ch, loc: api.baichuan.smart_ai_delay(ch, "loss", loc),
+ method=lambda api, ch, loc, value: api.baichuan.set_smart_ai(
+ ch, "loss", loc, delay=int(value)
+ ),
+ ),
+)
+
HOST_NUMBER_ENTITIES = (
ReolinkHostNumberEntityDescription(
key="alarm_volume",
@@ -538,26 +724,36 @@ CHIME_NUMBER_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ReolinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Reolink number entities."""
reolink_data: ReolinkData = config_entry.runtime_data
+ api = reolink_data.host.api
entities: list[NumberEntity] = [
ReolinkNumberEntity(reolink_data, channel, entity_description)
for entity_description in NUMBER_ENTITIES
- for channel in reolink_data.host.api.channels
- if entity_description.supported(reolink_data.host.api, channel)
+ for channel in api.channels
+ if entity_description.supported(api, channel)
]
+ entities.extend(
+ ReolinkSmartAINumberEntity(reolink_data, channel, location, entity_description)
+ for entity_description in SMART_AI_NUMBER_ENTITIES
+ for channel in api.channels
+ for location in api.baichuan.smart_location_list(
+ channel, entity_description.smart_type
+ )
+ if entity_description.supported(api, channel)
+ )
entities.extend(
ReolinkHostNumberEntity(reolink_data, entity_description)
for entity_description in HOST_NUMBER_ENTITIES
- if entity_description.supported(reolink_data.host.api)
+ if entity_description.supported(api)
)
entities.extend(
ReolinkChimeNumberEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_NUMBER_ENTITIES
- for chime in reolink_data.host.api.chime_list
+ for chime in api.chime_list
)
async_add_entities(entities)
@@ -599,6 +795,51 @@ class ReolinkNumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity):
self.async_write_ha_state()
+class ReolinkSmartAINumberEntity(ReolinkChannelCoordinatorEntity, NumberEntity):
+ """Base smart AI number entity class for Reolink IP cameras."""
+
+ entity_description: ReolinkSmartAINumberEntityDescription
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ channel: int,
+ location: int,
+ entity_description: ReolinkSmartAINumberEntityDescription,
+ ) -> None:
+ """Initialize Reolink number entity."""
+ self.entity_description = entity_description
+ super().__init__(reolink_data, channel)
+
+ unique_index = self._host.api.baichuan.smart_ai_index(
+ channel, entity_description.smart_type, location
+ )
+ self._attr_unique_id = f"{self._attr_unique_id}_{unique_index}"
+
+ self._location = location
+ self._attr_mode = entity_description.mode
+ self._attr_translation_placeholders = {
+ "zone_name": self._host.api.baichuan.smart_ai_name(
+ channel, entity_description.smart_type, location
+ )
+ }
+
+ @property
+ def native_value(self) -> float | None:
+ """State of the number entity."""
+ return self.entity_description.value(
+ self._host.api, self._channel, self._location
+ )
+
+ @raise_translated_error
+ async def async_set_native_value(self, value: float) -> None:
+ """Update the current value."""
+ await self.entity_description.method(
+ self._host.api, self._channel, self._location, value
+ )
+ self.async_write_ha_state()
+
+
class ReolinkHostNumberEntity(ReolinkHostCoordinatorEntity, NumberEntity):
"""Base number entity class for Reolink Host."""
diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py
index df8c0269957..2ee2b790687 100644
--- a/homeassistant/components/reolink/select.py
+++ b/homeassistant/components/reolink/select.py
@@ -23,13 +23,15 @@ from reolink_aio.api import (
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfFrequency
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import (
ReolinkChannelCoordinatorEntity,
ReolinkChannelEntityDescription,
ReolinkChimeCoordinatorEntity,
ReolinkChimeEntityDescription,
+ ReolinkHostCoordinatorEntity,
+ ReolinkHostEntityDescription,
)
from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
@@ -49,6 +51,18 @@ class ReolinkSelectEntityDescription(
value: Callable[[Host, int], str] | None = None
+@dataclass(frozen=True, kw_only=True)
+class ReolinkHostSelectEntityDescription(
+ SelectEntityDescription,
+ ReolinkHostEntityDescription,
+):
+ """A class that describes host select entities."""
+
+ get_options: Callable[[Host], list[str]]
+ method: Callable[[Host, str], Any]
+ value: Callable[[Host], str]
+
+
@dataclass(frozen=True, kw_only=True)
class ReolinkChimeSelectEntityDescription(
SelectEntityDescription,
@@ -238,6 +252,30 @@ SELECT_ENTITIES = (
),
)
+HOST_SELECT_ENTITIES = (
+ ReolinkHostSelectEntityDescription(
+ key="scene_mode",
+ cmd_key="GetScene",
+ translation_key="scene_mode",
+ entity_category=EntityCategory.CONFIG,
+ get_options=lambda api: api.baichuan.scene_names,
+ supported=lambda api: api.supported(None, "scenes"),
+ value=lambda api: api.baichuan.active_scene,
+ method=lambda api, name: api.baichuan.set_scene(scene_name=name),
+ ),
+ ReolinkHostSelectEntityDescription(
+ key="packing_time",
+ cmd_key="GetRec",
+ translation_key="packing_time",
+ entity_category=EntityCategory.CONFIG,
+ entity_registry_enabled_default=False,
+ get_options=lambda api: api.recording_packing_time_list,
+ supported=lambda api: api.supported(None, "pak_time"),
+ value=lambda api: api.recording_packing_time,
+ method=lambda api, value: api.set_recording_packing_time(value),
+ ),
+)
+
CHIME_SELECT_ENTITIES = (
ReolinkChimeSelectEntityDescription(
key="motion_tone",
@@ -295,17 +333,24 @@ CHIME_SELECT_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ReolinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Reolink select entities."""
reolink_data: ReolinkData = config_entry.runtime_data
- entities: list[ReolinkSelectEntity | ReolinkChimeSelectEntity] = [
+ entities: list[
+ ReolinkSelectEntity | ReolinkHostSelectEntity | ReolinkChimeSelectEntity
+ ] = [
ReolinkSelectEntity(reolink_data, channel, entity_description)
for entity_description in SELECT_ENTITIES
for channel in reolink_data.host.api.channels
if entity_description.supported(reolink_data.host.api, channel)
]
+ entities.extend(
+ ReolinkHostSelectEntity(reolink_data, entity_description)
+ for entity_description in HOST_SELECT_ENTITIES
+ if entity_description.supported(reolink_data.host.api)
+ )
entities.extend(
ReolinkChimeSelectEntity(reolink_data, chime, entity_description)
for entity_description in CHIME_SELECT_ENTITIES
@@ -360,6 +405,33 @@ class ReolinkSelectEntity(ReolinkChannelCoordinatorEntity, SelectEntity):
self.async_write_ha_state()
+class ReolinkHostSelectEntity(ReolinkHostCoordinatorEntity, SelectEntity):
+ """Base select entity class for Reolink Host."""
+
+ entity_description: ReolinkHostSelectEntityDescription
+
+ def __init__(
+ self,
+ reolink_data: ReolinkData,
+ entity_description: ReolinkHostSelectEntityDescription,
+ ) -> None:
+ """Initialize Reolink select entity."""
+ self.entity_description = entity_description
+ super().__init__(reolink_data)
+ self._attr_options = entity_description.get_options(self._host.api)
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the current option."""
+ return self.entity_description.value(self._host.api)
+
+ @raise_translated_error
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ await self.entity_description.method(self._host.api, option)
+ self.async_write_ha_state()
+
+
class ReolinkChimeSelectEntity(ReolinkChimeCoordinatorEntity, SelectEntity):
"""Base select entity class for Reolink IP cameras."""
diff --git a/homeassistant/components/reolink/sensor.py b/homeassistant/components/reolink/sensor.py
index 36900da99ca..85de03dd1a3 100644
--- a/homeassistant/components/reolink/sensor.py
+++ b/homeassistant/components/reolink/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .entity import (
@@ -107,6 +107,17 @@ SENSORS = (
value=lambda api, ch: BatteryEnum(api.battery_status(ch)).name,
supported=lambda api, ch: api.supported(ch, "battery"),
),
+ ReolinkSensorEntityDescription(
+ key="day_night_state",
+ cmd_id=33,
+ cmd_key="296",
+ translation_key="day_night_state",
+ device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ options=["day", "night", "led_day"],
+ value=lambda api, ch: api.baichuan.day_night_state(ch),
+ supported=lambda api, ch: api.supported(ch, "day_night_state"),
+ ),
)
HOST_SENSORS = (
@@ -150,7 +161,7 @@ HDD_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ReolinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Reolink IP Camera."""
reolink_data: ReolinkData = config_entry.runtime_data
diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py
index acd31fe0d7d..d170aa32379 100644
--- a/homeassistant/components/reolink/services.py
+++ b/homeassistant/components/reolink/services.py
@@ -40,7 +40,7 @@ def async_setup_services(hass: HomeAssistant) -> None:
if (
config_entry is None
or device is None
- or config_entry.state == ConfigEntryState.NOT_LOADED
+ or config_entry.state != ConfigEntryState.LOADED
):
raise ServiceValidationError(
translation_domain=DOMAIN,
diff --git a/homeassistant/components/reolink/siren.py b/homeassistant/components/reolink/siren.py
index 74bb227d078..f5d2de977ae 100644
--- a/homeassistant/components/reolink/siren.py
+++ b/homeassistant/components/reolink/siren.py
@@ -13,7 +13,7 @@ from homeassistant.components.siren import (
SirenEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ReolinkChannelCoordinatorEntity, ReolinkChannelEntityDescription
from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
@@ -40,7 +40,7 @@ SIREN_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ReolinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Reolink siren entities."""
reolink_data: ReolinkData = config_entry.runtime_data
diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json
index b72e7bbd00d..8b7d276a9e3 100644
--- a/homeassistant/components/reolink/strings.json
+++ b/homeassistant/components/reolink/strings.json
@@ -8,15 +8,17 @@
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"use_https": "Enable HTTPS",
+ "baichuan_port": "Basic service port",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"host": "The hostname or IP address of your Reolink device. For example: '192.168.1.25'.",
- "port": "The port to connect to the Reolink device. For HTTP normally: '80', for HTTPS normally '443'.",
- "use_https": "Use a HTTPS (SSL) connection to the Reolink device.",
- "username": "Username to login to the Reolink device itself. Not the Reolink cloud account.",
- "password": "Password to login to the Reolink device itself. Not the Reolink cloud account."
+ "port": "The HTTP(s) port to connect to the Reolink device API. For HTTP normally: '80', for HTTPS normally '443'.",
+ "use_https": "Use an HTTPS (SSL) connection to the Reolink device.",
+ "baichuan_port": "The 'Basic Service Port' to connect to the Reolink device over TCP. Normally '9000' unless manually changed in the Reolink desktop client.",
+ "username": "Username to log in to the Reolink device itself. Not the Reolink cloud account.",
+ "password": "Password to log in to the Reolink device itself. Not the Reolink cloud account."
}
},
"privacy": {
@@ -29,9 +31,9 @@
"cannot_connect": "Failed to connect, check the IP address of the camera",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"not_admin": "User needs to be admin, user \"{username}\" has authorisation level \"{userlevel}\"",
- "password_incompatible": "Password contains incompatible special character, only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}",
+ "password_incompatible": "Password contains incompatible special character or is too long, maximum 31 characters and only these characters are allowed: a-z, A-Z, 0-9 or {special_chars}",
"unknown": "[%key:common::config_flow::error::unknown%]",
- "update_needed": "Failed to login because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed",
+ "update_needed": "Failed to log in because of outdated firmware, please update the firmware to version {needed_firmware} using the Reolink Download Center: {download_center_url}, currently version {current_firmware} is installed",
"webhook_exception": "Home Assistant URL is not available, go to Settings > System > Network > Home Assistant URL and correct the URLs, see {more_info}"
},
"abort": {
@@ -64,7 +66,7 @@
"message": "Invalid input parameter: {err}"
},
"api_error": {
- "message": "The device responded with a error: {err}"
+ "message": "The device responded with an error: {err}"
},
"invalid_content_type": {
"message": "Received a different content type than expected: {err}"
@@ -100,7 +102,13 @@
"message": "Error trying to update Reolink firmware: {err}"
},
"config_entry_not_ready": {
- "message": "Error while trying to setup {host}: {err}"
+ "message": "Error while trying to set up {host}: {err}"
+ },
+ "update_already_running": {
+ "message": "Reolink firmware update already running, wait on completion before starting another"
+ },
+ "firmware_rate_limit": {
+ "message": "Reolink firmware update server reached hourly rate limit: updating can be tried again in 1 hour"
}
},
"issues": {
@@ -122,11 +130,15 @@
},
"firmware_update": {
"title": "Reolink firmware update required",
- "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running a old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})."
+ "description": "\"{name}\" with model \"{model}\" and hardware version \"{hw_version}\" is running an old firmware version \"{current_firmware}\", while at least firmware version \"{required_firmware}\" is required for proper operation of the Reolink integration. The firmware can be updated by pressing \"install\" in the more info dialog of the update entity of \"{name}\" from within Home Assistant. Alternatively, the latest firmware can be downloaded from the [Reolink download center]({download_link})."
},
"hub_switch_deprecated": {
"title": "Reolink Home Hub switches deprecated",
- "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are depricated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned."
+ "description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are deprecated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned."
+ },
+ "password_too_long": {
+ "title": "Reolink password too long",
+ "description": "The password for \"{name}\" is more than 31 characters long, this is no longer compatible with the Reolink API. Please change the password using the Reolink app/client to a password with is shorter than 32 characters. After changing the password, fill in the new password in the Reolink Re-authentication flow to continue using this integration. The latest version of the Reolink app/client also has a password limit of 31 characters."
}
},
"services": {
@@ -335,6 +347,83 @@
"off": "Awake",
"on": "Sleeping"
}
+ },
+ "crossline_person": {
+ "name": "Crossline {zone_name} person",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
+ "crossline_vehicle": {
+ "name": "Crossline {zone_name} vehicle",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
+ "crossline_dog_cat": {
+ "name": "Crossline {zone_name} animal",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
+ "intrusion_person": {
+ "name": "Intrusion {zone_name} person",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
+ "intrusion_vehicle": {
+ "name": "Intrusion {zone_name} vehicle",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
+ "intrusion_dog_cat": {
+ "name": "Intrusion {zone_name} animal",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
+ "linger_person": {
+ "name": "Linger {zone_name} person",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
+ "linger_vehicle": {
+ "name": "Linger {zone_name} vehicle",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
+ "linger_dog_cat": {
+ "name": "Linger {zone_name} animal",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
+ "forgotten_item": {
+ "name": "Item forgotten {zone_name}",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
+ },
+ "taken_item": {
+ "name": "Item taken {zone_name}",
+ "state": {
+ "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]",
+ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]"
+ }
}
},
"button": {
@@ -479,6 +568,21 @@
"ai_animal_sensitivity": {
"name": "AI animal sensitivity"
},
+ "crossline_sensitivity": {
+ "name": "AI crossline {zone_name} sensitivity"
+ },
+ "intrusion_sensitivity": {
+ "name": "AI intrusion {zone_name} sensitivity"
+ },
+ "linger_sensitivity": {
+ "name": "AI linger {zone_name} sensitivity"
+ },
+ "forgotten_item_sensitivity": {
+ "name": "AI item forgotten {zone_name} sensitivity"
+ },
+ "taken_item_sensitivity": {
+ "name": "AI item taken {zone_name} sensitivity"
+ },
"ai_face_delay": {
"name": "AI face delay"
},
@@ -497,6 +601,18 @@
"ai_animal_delay": {
"name": "AI animal delay"
},
+ "intrusion_delay": {
+ "name": "AI intrusion {zone_name} delay"
+ },
+ "linger_delay": {
+ "name": "AI linger {zone_name} delay"
+ },
+ "forgotten_item_delay": {
+ "name": "AI item forgotten {zone_name} delay"
+ },
+ "taken_item_delay": {
+ "name": "AI item taken {zone_name} delay"
+ },
"auto_quick_reply_time": {
"name": "Auto quick reply time"
},
@@ -536,7 +652,7 @@
"name": "Floodlight mode",
"state": {
"off": "[%key:common::state::off%]",
- "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]",
+ "auto": "[%key:common::state::auto%]",
"onatnight": "On at night",
"schedule": "Schedule",
"adaptive": "Adaptive",
@@ -546,7 +662,7 @@
"day_night_mode": {
"name": "Day night mode",
"state": {
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
"color": "Color",
"blackwhite": "Black & white"
}
@@ -575,7 +691,7 @@
"name": "Doorbell LED",
"state": {
"stayoff": "Stay off",
- "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]",
+ "auto": "[%key:common::state::auto%]",
"alwaysonatnight": "Auto & always on at night",
"always": "Always on",
"alwayson": "Always on"
@@ -586,7 +702,7 @@
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
- "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]"
+ "auto": "[%key:common::state::auto%]"
}
},
"binning_mode": {
@@ -594,7 +710,7 @@
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
- "auto": "[%key:component::reolink::entity::select::day_night_mode::state::auto%]"
+ "auto": "[%key:common::state::auto%]"
}
},
"hub_alarm_ringtone": {
@@ -720,6 +836,18 @@
},
"sub_bit_rate": {
"name": "Fluent bit rate"
+ },
+ "scene_mode": {
+ "name": "Scene mode",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "disarm": "Disarmed",
+ "home": "[%key:common::state::home%]",
+ "away": "[%key:common::state::not_home%]"
+ }
+ },
+ "packing_time": {
+ "name": "Recording packing time"
}
},
"sensor": {
@@ -741,11 +869,19 @@
"battery_state": {
"name": "Battery state",
"state": {
- "discharging": "Discharging",
- "charging": "Charging",
+ "discharging": "[%key:common::state::discharging%]",
+ "charging": "[%key:common::state::charging%]",
"chargecomplete": "Charge complete"
}
},
+ "day_night_state": {
+ "name": "Day night state",
+ "state": {
+ "day": "Color",
+ "night": "Black & white",
+ "led_day": "Color with floodlight"
+ }
+ },
"hdd_storage": {
"name": "HDD {hdd_index} storage"
},
@@ -760,7 +896,7 @@
},
"switch": {
"ir_lights": {
- "name": "Infra red lights in night mode"
+ "name": "Infrared lights in night mode"
},
"record_audio": {
"name": "Record audio"
diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py
index a0b8824782a..af87a75eece 100644
--- a/homeassistant/components/reolink/switch.py
+++ b/homeassistant/components/reolink/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er, issue_registry as ir
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import (
@@ -162,6 +162,7 @@ SWITCH_ENTITIES = (
ReolinkSwitchEntityDescription(
key="manual_record",
cmd_key="GetManualRec",
+ cmd_id=588,
translation_key="manual_record",
entity_category=EntityCategory.CONFIG,
supported=lambda api, ch: api.supported(ch, "manual_record"),
@@ -330,7 +331,7 @@ DEPRECATED_NVR_SWITCHES = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ReolinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Reolink switch entities."""
reolink_data: ReolinkData = config_entry.runtime_data
diff --git a/homeassistant/components/reolink/update.py b/homeassistant/components/reolink/update.py
index 5a8c7d7dc08..a7c883003b7 100644
--- a/homeassistant/components/reolink/update.py
+++ b/homeassistant/components/reolink/update.py
@@ -16,7 +16,7 @@ from homeassistant.components.update import (
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
@@ -31,7 +31,7 @@ from .entity import (
ReolinkHostCoordinatorEntity,
ReolinkHostEntityDescription,
)
-from .util import ReolinkConfigEntry, ReolinkData
+from .util import ReolinkConfigEntry, ReolinkData, raise_translated_error
PARALLEL_UPDATES = 0
RESUME_AFTER_INSTALL = 15
@@ -75,7 +75,7 @@ HOST_UPDATE_ENTITIES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ReolinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up update entities for Reolink component."""
reolink_data: ReolinkData = config_entry.runtime_data
@@ -184,6 +184,7 @@ class ReolinkUpdateBaseEntity(
f"## Release notes\n\n{new_firmware.release_notes}"
)
+ @raise_translated_error
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
@@ -196,6 +197,8 @@ class ReolinkUpdateBaseEntity(
try:
await self._host.api.update_firmware(self._channel)
except ReolinkError as err:
+ if err.translation_key:
+ raise
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="firmware_install_error",
diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py
index a5556b66a33..17e666ac52c 100644
--- a/homeassistant/components/reolink/util.py
+++ b/homeassistant/components/reolink/util.py
@@ -27,6 +27,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.storage import Store
+from homeassistant.helpers.translation import async_get_exception_message
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
@@ -78,11 +79,15 @@ def get_device_uid_and_ch(
device: dr.DeviceEntry, host: ReolinkHost
) -> tuple[list[str], int | None, bool]:
"""Get the channel and the split device_uid from a reolink DeviceEntry."""
- device_uid = [
- dev_id[1].split("_") for dev_id in device.identifiers if dev_id[0] == DOMAIN
- ][0]
-
+ device_uid = []
is_chime = False
+
+ for dev_id in device.identifiers:
+ if dev_id[0] == DOMAIN:
+ device_uid = dev_id[1].split("_")
+ if device_uid[0] == host.unique_id:
+ break
+
if len(device_uid) < 2:
# NVR itself
ch = None
@@ -97,6 +102,30 @@ def get_device_uid_and_ch(
return (device_uid, ch, is_chime)
+def check_translation_key(err: ReolinkError) -> str | None:
+ """Check if the translation key from the upstream library is present."""
+ if not err.translation_key:
+ return None
+ if async_get_exception_message(DOMAIN, err.translation_key) == err.translation_key:
+ # translation key not found in strings.json
+ return None
+ return err.translation_key
+
+
+_EXCEPTION_TO_TRANSLATION_KEY = {
+ ApiError: "api_error",
+ InvalidContentTypeError: "invalid_content_type",
+ CredentialsInvalidError: "invalid_credentials",
+ LoginError: "login_error",
+ NoDataError: "no_data",
+ UnexpectedDataError: "unexpected_data",
+ NotSupportedError: "not_supported",
+ SubscriptionError: "subscription_error",
+ ReolinkConnectionError: "connection_error",
+ ReolinkTimeoutError: "timeout",
+}
+
+
# Decorators
def raise_translated_error[**P, R](
func: Callable[P, Awaitable[R]],
@@ -110,73 +139,14 @@ def raise_translated_error[**P, R](
except InvalidParameterError as err:
raise ServiceValidationError(
translation_domain=DOMAIN,
- translation_key="invalid_parameter",
- translation_placeholders={"err": str(err)},
- ) from err
- except ApiError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="api_error",
- translation_placeholders={"err": str(err)},
- ) from err
- except InvalidContentTypeError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="invalid_content_type",
- translation_placeholders={"err": str(err)},
- ) from err
- except CredentialsInvalidError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="invalid_credentials",
- translation_placeholders={"err": str(err)},
- ) from err
- except LoginError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="login_error",
- translation_placeholders={"err": str(err)},
- ) from err
- except NoDataError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="no_data",
- translation_placeholders={"err": str(err)},
- ) from err
- except UnexpectedDataError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="unexpected_data",
- translation_placeholders={"err": str(err)},
- ) from err
- except NotSupportedError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="not_supported",
- translation_placeholders={"err": str(err)},
- ) from err
- except SubscriptionError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="subscription_error",
- translation_placeholders={"err": str(err)},
- ) from err
- except ReolinkConnectionError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="connection_error",
- translation_placeholders={"err": str(err)},
- ) from err
- except ReolinkTimeoutError as err:
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="timeout",
+ translation_key=check_translation_key(err) or "invalid_parameter",
translation_placeholders={"err": str(err)},
) from err
except ReolinkError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
- translation_key="unexpected",
+ translation_key=check_translation_key(err)
+ or _EXCEPTION_TO_TRANSLATION_KEY.get(type(err), "unexpected"),
translation_placeholders={"err": str(err)},
) from err
diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py
index 1a4585bc997..44265244b18 100644
--- a/homeassistant/components/reolink/views.py
+++ b/homeassistant/components/reolink/views.py
@@ -83,7 +83,16 @@ class PlaybackProxyView(HomeAssistantView):
_LOGGER.warning("Reolink playback proxy error: %s", str(err))
return web.Response(body=str(err), status=HTTPStatus.BAD_REQUEST)
+ headers = dict(request.headers)
+ headers.pop("Host", None)
+ headers.pop("Referer", None)
+
if _LOGGER.isEnabledFor(logging.DEBUG):
+ _LOGGER.debug(
+ "Requested Playback Proxy Method %s, Headers: %s",
+ request.method,
+ headers,
+ )
_LOGGER.debug(
"Opening VOD stream from %s: %s",
host.api.camera_name(ch),
@@ -93,6 +102,7 @@ class PlaybackProxyView(HomeAssistantView):
try:
reolink_response = await self.session.get(
reolink_url,
+ headers=headers,
timeout=ClientTimeout(
connect=15, sock_connect=15, sock_read=5, total=None
),
@@ -118,18 +128,25 @@ class PlaybackProxyView(HomeAssistantView):
]:
err_str = f"Reolink playback expected video/mp4 but got {reolink_response.content_type}"
_LOGGER.error(err_str)
+ if reolink_response.content_type == "text/html":
+ text = await reolink_response.text()
+ _LOGGER.debug(text)
return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST)
- response = web.StreamResponse(
- status=200,
- reason="OK",
- headers={
- "Content-Type": "video/mp4",
- },
+ response_headers = dict(reolink_response.headers)
+ _LOGGER.debug(
+ "Response Playback Proxy Status %s:%s, Headers: %s",
+ reolink_response.status,
+ reolink_response.reason,
+ response_headers,
)
+ response_headers["Content-Type"] = "video/mp4"
- if reolink_response.content_length is not None:
- response.content_length = reolink_response.content_length
+ response = web.StreamResponse(
+ status=reolink_response.status,
+ reason=reolink_response.reason,
+ headers=response_headers,
+ )
await response.prepare(request)
@@ -141,7 +158,8 @@ class PlaybackProxyView(HomeAssistantView):
"Timeout while reading Reolink playback from %s, writing EOF",
host.api.nvr_name,
)
+ finally:
+ reolink_response.release()
- reolink_response.release()
await response.write_eof()
return response
diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py
index fe3702510af..c6a4206de4a 100644
--- a/homeassistant/components/rest_command/__init__.py
+++ b/homeassistant/components/rest_command/__init__.py
@@ -34,6 +34,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util.ssl import SSLCipherList
DOMAIN = "rest_command"
@@ -46,6 +47,7 @@ DEFAULT_VERIFY_SSL = True
SUPPORT_REST_METHODS = ["get", "patch", "post", "put", "delete"]
CONF_CONTENT_TYPE = "content_type"
+CONF_INSECURE_CIPHER = "insecure_cipher"
COMMAND_SCHEMA = vol.Schema(
{
@@ -60,6 +62,7 @@ COMMAND_SCHEMA = vol.Schema(
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int),
vol.Optional(CONF_CONTENT_TYPE): cv.string,
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+ vol.Optional(CONF_INSECURE_CIPHER, default=False): cv.boolean,
}
)
@@ -91,7 +94,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@callback
def async_register_rest_command(name: str, command_config: dict[str, Any]) -> None:
"""Create service for rest command."""
- websession = async_get_clientsession(hass, command_config[CONF_VERIFY_SSL])
+ websession = async_get_clientsession(
+ hass,
+ command_config[CONF_VERIFY_SSL],
+ ssl_cipher=(
+ SSLCipherList.INSECURE
+ if command_config[CONF_INSECURE_CIPHER]
+ else SSLCipherList.PYTHON_DEFAULT
+ ),
+ )
timeout = command_config[CONF_TIMEOUT]
method = command_config[CONF_METHOD]
@@ -135,6 +146,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if content_type:
headers[hdrs.CONTENT_TYPE] = content_type
+ _LOGGER.debug(
+ "Calling %s %s with headers: %s and payload: %s",
+ method,
+ request_url,
+ headers,
+ payload,
+ )
+
try:
async with getattr(websession, method)(
request_url,
diff --git a/homeassistant/components/rflink/entity.py b/homeassistant/components/rflink/entity.py
index 26153acf7ba..0caec4ea2c3 100644
--- a/homeassistant/components/rflink/entity.py
+++ b/homeassistant/components/rflink/entity.py
@@ -105,12 +105,12 @@ class RflinkDevice(Entity):
return self._state
@property
- def assumed_state(self):
+ def assumed_state(self) -> bool:
"""Assume device state until first device event sets state."""
return self._state is None
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if entity is available."""
return self._available
@@ -120,7 +120,7 @@ class RflinkDevice(Entity):
self._available = availability
self.async_write_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register update callback."""
await super().async_added_to_hass()
# Remove temporary bogus entity_id if added
@@ -300,7 +300,7 @@ class RflinkCommand(RflinkDevice):
class SwitchableRflinkDevice(RflinkCommand, RestoreEntity):
"""Rflink entity which can switch on/off (eg: light, switch)."""
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Restore RFLink device state (ON/OFF)."""
await super().async_added_to_hass()
if (old_state := await self.async_get_last_state()) is not None:
diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py
index 027c39da70f..97d0b811509 100644
--- a/homeassistant/components/rflink/sensor.py
+++ b/homeassistant/components/rflink/sensor.py
@@ -236,7 +236,8 @@ SENSOR_TYPES = (
key="winddirection",
name="Wind direction",
icon="mdi:compass",
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
native_unit_of_measurement=DEGREE,
),
SensorEntityDescription(
diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py
index 316cf44ef0d..a86ad5557b4 100644
--- a/homeassistant/components/rfxtrx/binary_sensor.py
+++ b/homeassistant/components/rfxtrx/binary_sensor.py
@@ -17,7 +17,7 @@ from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON, STATE_ON
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import event as evt
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeviceTuple, async_setup_platform_entry, get_pt2262_cmd
from .const import (
@@ -91,7 +91,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up config entry."""
diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py
index 473a0d94056..07443afb38b 100644
--- a/homeassistant/components/rfxtrx/cover.py
+++ b/homeassistant/components/rfxtrx/cover.py
@@ -11,7 +11,7 @@ from homeassistant.components.cover import CoverEntity, CoverEntityFeature, Cove
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeviceTuple, async_setup_platform_entry
from .const import (
@@ -34,7 +34,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up config entry."""
diff --git a/homeassistant/components/rfxtrx/event.py b/homeassistant/components/rfxtrx/event.py
index 212d93b5019..40d02953aeb 100644
--- a/homeassistant/components/rfxtrx/event.py
+++ b/homeassistant/components/rfxtrx/event.py
@@ -11,7 +11,7 @@ from homeassistant.components.event import EventEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from . import DeviceTuple, async_setup_platform_entry
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up config entry."""
diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py
index 0e2f7bef65a..90c0d2eeed7 100644
--- a/homeassistant/components/rfxtrx/light.py
+++ b/homeassistant/components/rfxtrx/light.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeviceTuple, async_setup_platform_entry
from .const import COMMAND_OFF_LIST, COMMAND_ON_LIST
@@ -32,7 +32,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up config entry."""
diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py
index 13f3c012af8..6669b1367df 100644
--- a/homeassistant/components/rfxtrx/sensor.py
+++ b/homeassistant/components/rfxtrx/sensor.py
@@ -36,7 +36,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import DeviceTuple, async_setup_platform_entry, get_rfx_object
@@ -161,7 +161,8 @@ SENSOR_TYPES = (
RfxtrxSensorEntityDescription(
key="Wind direction",
translation_key="wind_direction",
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
native_unit_of_measurement=DEGREE,
),
RfxtrxSensorEntityDescription(
@@ -241,7 +242,7 @@ SENSOR_TYPES_DICT = {desc.key: desc for desc in SENSOR_TYPES}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up config entry."""
diff --git a/homeassistant/components/rfxtrx/siren.py b/homeassistant/components/rfxtrx/siren.py
index 1635f1f55a9..1164dafbfce 100644
--- a/homeassistant/components/rfxtrx/siren.py
+++ b/homeassistant/components/rfxtrx/siren.py
@@ -11,7 +11,7 @@ from homeassistant.components.siren import ATTR_TONE, SirenEntity, SirenEntityFe
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import DEFAULT_OFF_DELAY, DeviceTuple, async_setup_platform_entry
@@ -47,7 +47,7 @@ def get_first_key(data: dict[int, str], entry: str) -> int:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up config entry."""
diff --git a/homeassistant/components/rfxtrx/strings.json b/homeassistant/components/rfxtrx/strings.json
index db4efad5bb4..d3b65dc238a 100644
--- a/homeassistant/components/rfxtrx/strings.json
+++ b/homeassistant/components/rfxtrx/strings.json
@@ -48,7 +48,7 @@
"event_code": "Enter event code to add",
"device": "Select device to configure"
},
- "title": "Rfxtrx Options"
+ "title": "RFXtrx options"
},
"set_device_options": {
"data": {
@@ -105,15 +105,15 @@
"sound_15": "Sound 15",
"down": "Down",
"up": "Up",
- "all_off": "All Off",
- "all_on": "All On",
+ "all_off": "All off",
+ "all_on": "All on",
"scene": "Scene",
- "off": "Off",
- "on": "On",
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]",
"dim": "Dim",
"bright": "Bright",
- "all_group_off": "All/group Off",
- "all_group_on": "All/group On",
+ "all_group_off": "All/group off",
+ "all_group_on": "All/group on",
"chime": "Chime",
"illegal_command": "Illegal command",
"set_level": "Set level",
@@ -131,40 +131,40 @@
"level_9": "Level 9",
"program": "Program",
"stop": "Stop",
- "0_5_seconds_up": "0.5 Seconds Up",
- "0_5_seconds_down": "0.5 Seconds Down",
- "2_seconds_up": "2 Seconds Up",
- "2_seconds_down": "2 Seconds Down",
+ "0_5_seconds_up": "0.5 seconds up",
+ "0_5_seconds_down": "0.5 seconds down",
+ "2_seconds_up": "2 seconds up",
+ "2_seconds_down": "2 seconds down",
"enable_sun_automation": "Enable sun automation",
"disable_sun_automation": "Disable sun automation",
- "normal": "Normal",
- "normal_delayed": "Normal Delayed",
+ "normal": "[%key:common::state::normal%]",
+ "normal_delayed": "Normal delayed",
"alarm": "Alarm",
- "alarm_delayed": "Alarm Delayed",
+ "alarm_delayed": "Alarm delayed",
"motion": "Motion",
- "no_motion": "No Motion",
+ "no_motion": "No motion",
"panic": "Panic",
- "end_panic": "End Panic",
+ "end_panic": "End panic",
"ir": "IR",
- "arm_away": "Arm Away",
- "arm_away_delayed": "Arm Away Delayed",
- "arm_home": "Arm Home",
- "arm_home_delayed": "Arm Home Delayed",
+ "arm_away": "Arm away",
+ "arm_away_delayed": "Arm away delayed",
+ "arm_home": "Arm home",
+ "arm_home_delayed": "Arm home delayed",
"disarm": "Disarm",
- "light_1_off": "Light 1 Off",
- "light_1_on": "Light 1 On",
- "light_2_off": "Light 2 Off",
- "light_2_on": "Light 2 On",
- "dark_detected": "Dark Detected",
- "light_detected": "Light Detected",
+ "light_1_off": "Light 1 off",
+ "light_1_on": "Light 1 on",
+ "light_2_off": "Light 2 off",
+ "light_2_on": "Light 2 on",
+ "dark_detected": "Dark detected",
+ "light_detected": "Light detected",
"battery_low": "Battery low",
"pairing_kd101": "Pairing KD101",
- "normal_tamper": "Normal Tamper",
- "normal_delayed_tamper": "Normal Delayed Tamper",
- "alarm_tamper": "Alarm Tamper",
- "alarm_delayed_tamper": "Alarm Delayed Tamper",
- "motion_tamper": "Motion Tamper",
- "no_motion_tamper": "No Motion Tamper"
+ "normal_tamper": "Normal tamper",
+ "normal_delayed_tamper": "Normal delayed tamper",
+ "alarm_tamper": "Alarm tamper",
+ "alarm_delayed_tamper": "Alarm delayed tamper",
+ "motion_tamper": "Motion tamper",
+ "no_motion_tamper": "No motion tamper"
}
}
}
diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py
index cd17e71f4f0..b3eb63fb2b4 100644
--- a/homeassistant/components/rfxtrx/switch.py
+++ b/homeassistant/components/rfxtrx/switch.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON, STATE_ON
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DeviceTuple, async_setup_platform_entry, get_pt2262_cmd
from .const import (
@@ -41,7 +41,7 @@ def supported(event: rfxtrxmod.RFXtrxEvent) -> bool:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up config entry."""
diff --git a/homeassistant/components/ridwell/calendar.py b/homeassistant/components/ridwell/calendar.py
index ecca0366754..bb7982a5391 100644
--- a/homeassistant/components/ridwell/calendar.py
+++ b/homeassistant/components/ridwell/calendar.py
@@ -9,7 +9,7 @@ from aioridwell.model import RidwellAccount, RidwellPickupEvent
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RidwellDataUpdateCoordinator
@@ -36,7 +36,9 @@ def async_get_calendar_event_from_pickup_event(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ridwell calendars based on a config entry."""
coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/ridwell/sensor.py b/homeassistant/components/ridwell/sensor.py
index 7fc7fdb5348..30f97ecaea8 100644
--- a/homeassistant/components/ridwell/sensor.py
+++ b/homeassistant/components/ridwell/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, SENSOR_TYPE_NEXT_PICKUP
from .coordinator import RidwellDataUpdateCoordinator
@@ -34,7 +34,9 @@ SENSOR_DESCRIPTION = SensorEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ridwell sensors based on a config entry."""
coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/ridwell/switch.py b/homeassistant/components/ridwell/switch.py
index 04e3e4c5ff9..e3be9ea5368 100644
--- a/homeassistant/components/ridwell/switch.py
+++ b/homeassistant/components/ridwell/switch.py
@@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RidwellDataUpdateCoordinator
@@ -24,7 +24,9 @@ SWITCH_DESCRIPTION = SwitchEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ridwell sensors based on a config entry."""
coordinator: RidwellDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py
index da0e0cc1d9b..49051ee5e11 100644
--- a/homeassistant/components/ring/binary_sensor.py
+++ b/homeassistant/components/ring/binary_sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import Platform
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_at
from . import RingConfigEntry
@@ -67,7 +67,7 @@ BINARY_SENSOR_TYPES: tuple[RingBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ring binary sensors from a config entry."""
ring_data = entry.runtime_data
diff --git a/homeassistant/components/ring/button.py b/homeassistant/components/ring/button.py
index 30600237847..09e6c0e413a 100644
--- a/homeassistant/components/ring/button.py
+++ b/homeassistant/components/ring/button.py
@@ -6,7 +6,7 @@ from ring_doorbell import RingOther
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RingConfigEntry
from .coordinator import RingDataCoordinator
@@ -24,7 +24,7 @@ BUTTON_DESCRIPTION = ButtonEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the buttons for the Ring devices."""
ring_data = entry.runtime_data
diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py
index e0ae2b52fa0..156d82665d2 100644
--- a/homeassistant/components/ring/camera.py
+++ b/homeassistant/components/ring/camera.py
@@ -27,7 +27,7 @@ from homeassistant.components.camera import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import RingConfigEntry
@@ -76,7 +76,7 @@ CAMERA_DESCRIPTIONS: tuple[RingCameraEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Ring Door Bell and StickUp Camera."""
ring_data = entry.runtime_data
diff --git a/homeassistant/components/ring/event.py b/homeassistant/components/ring/event.py
index 4d7a6277579..db99a10de74 100644
--- a/homeassistant/components/ring/event.py
+++ b/homeassistant/components/ring/event.py
@@ -12,7 +12,7 @@ from homeassistant.components.event import (
EventEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RingConfigEntry
from .coordinator import RingListenCoordinator
@@ -57,7 +57,7 @@ EVENT_DESCRIPTIONS: tuple[RingEventEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up events for a Ring device."""
ring_data = entry.runtime_data
diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py
index 62c5217a89b..34915dd5133 100644
--- a/homeassistant/components/ring/light.py
+++ b/homeassistant/components/ring/light.py
@@ -9,7 +9,7 @@ from ring_doorbell import RingStickUpCam
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import RingConfigEntry
@@ -40,7 +40,7 @@ class OnOffState(StrEnum):
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the lights for the Ring devices."""
ring_data = entry.runtime_data
diff --git a/homeassistant/components/ring/number.py b/homeassistant/components/ring/number.py
index b920ff7edc7..68b41451bd0 100644
--- a/homeassistant/components/ring/number.py
+++ b/homeassistant/components/ring/number.py
@@ -13,7 +13,7 @@ from homeassistant.components.number import (
NumberMode,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import RingConfigEntry
@@ -28,7 +28,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a numbers for a Ring device."""
ring_data = entry.runtime_data
diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py
index a2f72b94336..5744ed9a4d8 100644
--- a/homeassistant/components/ring/sensor.py
+++ b/homeassistant/components/ring/sensor.py
@@ -28,7 +28,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import RingConfigEntry
@@ -48,7 +48,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a sensor for a Ring device."""
ring_data = entry.runtime_data
diff --git a/homeassistant/components/ring/siren.py b/homeassistant/components/ring/siren.py
index 05fa07c39eb..7f096c0e643 100644
--- a/homeassistant/components/ring/siren.py
+++ b/homeassistant/components/ring/siren.py
@@ -22,7 +22,7 @@ from homeassistant.components.siren import (
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RingConfigEntry
from .coordinator import RingDataCoordinator
@@ -85,7 +85,7 @@ SIRENS: tuple[RingSirenEntityDescription[Any], ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the sirens for the Ring devices."""
ring_data = entry.runtime_data
diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py
index cab5654fc5a..02d98388edc 100644
--- a/homeassistant/components/ring/switch.py
+++ b/homeassistant/components/ring/switch.py
@@ -11,7 +11,7 @@ from ring_doorbell.const import DOORBELL_EXISTING_TYPE
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import RingConfigEntry
@@ -86,7 +86,7 @@ SWITCHES: Sequence[RingSwitchEntityDescription[Any]] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RingConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the switches for the Ring devices."""
ring_data = entry.runtime_data
diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py
index b1eae8fd917..2472baa932e 100644
--- a/homeassistant/components/risco/alarm_control_panel.py
+++ b/homeassistant/components/risco/alarm_control_panel.py
@@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LocalData, is_local
from .const import (
@@ -50,7 +50,7 @@ STATES_TO_SUPPORTED_FEATURES = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Risco alarm control panel."""
options = {**DEFAULT_OPTIONS, **config_entry.options}
diff --git a/homeassistant/components/risco/binary_sensor.py b/homeassistant/components/risco/binary_sensor.py
index a7ca0129b06..ff61985fef3 100644
--- a/homeassistant/components/risco/binary_sensor.py
+++ b/homeassistant/components/risco/binary_sensor.py
@@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LocalData, is_local
from .const import DATA_COORDINATOR, DOMAIN, SYSTEM_UPDATE_SIGNAL
@@ -73,7 +73,7 @@ SYSTEM_ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Risco alarm control panel."""
if is_local(config_entry):
diff --git a/homeassistant/components/risco/const.py b/homeassistant/components/risco/const.py
index 078e26c43b5..ef3280fe232 100644
--- a/homeassistant/components/risco/const.py
+++ b/homeassistant/components/risco/const.py
@@ -30,9 +30,9 @@ RISCO_ARM = "arm"
RISCO_PARTIAL_ARM = "partial_arm"
RISCO_STATES = [RISCO_ARM, RISCO_PARTIAL_ARM, *RISCO_GROUPS]
-DEFAULT_RISCO_GROUPS_TO_HA = {
- group: AlarmControlPanelState.ARMED_HOME for group in RISCO_GROUPS
-}
+DEFAULT_RISCO_GROUPS_TO_HA = dict.fromkeys(
+ RISCO_GROUPS, AlarmControlPanelState.ARMED_HOME
+)
DEFAULT_RISCO_STATES_TO_HA = {
RISCO_ARM: AlarmControlPanelState.ARMED_AWAY,
RISCO_PARTIAL_ARM: AlarmControlPanelState.ARMED_HOME,
diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json
index 149b8761589..43d471172d6 100644
--- a/homeassistant/components/risco/manifest.json
+++ b/homeassistant/components/risco/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/risco",
"iot_class": "local_push",
"loggers": ["pyrisco"],
- "requirements": ["pyrisco==0.6.5"]
+ "requirements": ["pyrisco==0.6.7"]
}
diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py
index c1495512e62..93683f1aa50 100644
--- a/homeassistant/components/risco/sensor.py
+++ b/homeassistant/components/risco/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -46,7 +46,7 @@ EVENT_ATTRIBUTES = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for device."""
if is_local(config_entry):
diff --git a/homeassistant/components/risco/switch.py b/homeassistant/components/risco/switch.py
index 8bad2c6c15e..547dedd3933 100644
--- a/homeassistant/components/risco/switch.py
+++ b/homeassistant/components/risco/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LocalData, is_local
from .const import DATA_COORDINATOR, DOMAIN
@@ -21,7 +21,7 @@ from .entity import RiscoCloudZoneEntity, RiscoLocalZoneEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Risco switch."""
if is_local(config_entry):
diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py
index 63666fc1aca..97e9c8418d1 100644
--- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py
+++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RitualsDataUpdateCoordinator
@@ -44,7 +44,7 @@ ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the diffuser binary sensors."""
coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][
diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py
index 0ac9c30f285..98e833ff9bd 100644
--- a/homeassistant/components/rituals_perfume_genie/number.py
+++ b/homeassistant/components/rituals_perfume_genie/number.py
@@ -11,7 +11,7 @@ from pyrituals import Diffuser
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RitualsDataUpdateCoordinator
@@ -41,7 +41,7 @@ ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the diffuser numbers."""
coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][
diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py
index 27aff70649b..c239627e9c6 100644
--- a/homeassistant/components/rituals_perfume_genie/select.py
+++ b/homeassistant/components/rituals_perfume_genie/select.py
@@ -11,7 +11,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfArea
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RitualsDataUpdateCoordinator
@@ -44,7 +44,7 @@ ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the diffuser select entities."""
coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][
diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py
index 46faa8d73e9..3921fd0b6c2 100644
--- a/homeassistant/components/rituals_perfume_genie/sensor.py
+++ b/homeassistant/components/rituals_perfume_genie/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RitualsDataUpdateCoordinator
@@ -60,7 +60,7 @@ ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the diffuser sensors."""
coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][
diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py
index b5828f5ca07..c5331b49078 100644
--- a/homeassistant/components/rituals_perfume_genie/switch.py
+++ b/homeassistant/components/rituals_perfume_genie/switch.py
@@ -11,7 +11,7 @@ from pyrituals import Diffuser
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RitualsDataUpdateCoordinator
@@ -42,7 +42,7 @@ ENTITY_DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the diffuser switch."""
coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][
diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py
index 764518df636..81b412c6770 100644
--- a/homeassistant/components/roborock/__init__.py
+++ b/homeassistant/components/roborock/__init__.py
@@ -23,6 +23,8 @@ from roborock.web_api import RoborockApiClient
from homeassistant.const import CONF_USERNAME, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_BASE_URL, CONF_USER_DATA, DOMAIN, PLATFORMS
from .coordinator import (
@@ -44,7 +46,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
entry.async_on_unload(entry.add_update_listener(update_listener))
user_data = UserData.from_dict(entry.data[CONF_USER_DATA])
- api_client = RoborockApiClient(entry.data[CONF_USERNAME], entry.data[CONF_BASE_URL])
+ api_client = RoborockApiClient(
+ entry.data[CONF_USERNAME],
+ entry.data[CONF_BASE_URL],
+ session=async_get_clientsession(hass),
+ )
_LOGGER.debug("Getting home data")
try:
home_data = await api_client.get_home_data_v2(user_data)
@@ -65,6 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
translation_key="no_user_agreement",
) from err
except RoborockException as err:
+ _LOGGER.debug("Failed to get Roborock home data: %s", err)
raise ConfigEntryNotReady(
"Failed to get Roborock home data",
translation_domain=DOMAIN,
@@ -82,7 +89,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
# Get a Coordinator if the device is available or if we have connected to the device before
coordinators = await asyncio.gather(
*build_setup_functions(
- hass, entry, device_map, user_data, product_info, home_data.rooms
+ hass,
+ entry,
+ device_map,
+ user_data,
+ product_info,
+ home_data.rooms,
+ api_client,
),
return_exceptions=True,
)
@@ -104,6 +117,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
translation_key="no_coordinators",
)
valid_coordinators = RoborockCoordinators(v1_coords, a01_coords)
+ await asyncio.gather(
+ *(coord.refresh_coordinator_map() for coord in valid_coordinators.v1)
+ )
async def on_stop(_: Any) -> None:
_LOGGER.debug("Shutting down roborock")
@@ -124,6 +140,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ 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:
+ # Remove any devices that are no longer in the account.
+ # The API returns all devices, even if they are offline
+ device_duids = {
+ identifier[1].replace("_dock", "") for identifier in device.identifiers
+ }
+ if any(device_duid in device_map for device_duid in device_duids):
+ continue
+ _LOGGER.info(
+ "Removing device: %s because it is no longer exists in your account",
+ device.name,
+ )
+ device_registry.async_update_device(
+ device_id=device.id,
+ remove_config_entry_id=entry.entry_id,
+ )
+
+ return True
+
+
+async def async_migrate_entry(hass: HomeAssistant, entry: RoborockConfigEntry) -> bool:
+ """Migrate old configuration entries to the new format."""
+ _LOGGER.debug(
+ "Migrating configuration from version %s.%s",
+ entry.version,
+ entry.minor_version,
+ )
+ if entry.version > 1:
+ # Downgrade from future version
+ return False
+
+ # 1->2: Migrate from unique id as email address to unique id as rruid
+ if entry.minor_version == 1:
+ user_data = UserData.from_dict(entry.data[CONF_USER_DATA])
+ _LOGGER.debug("Updating unique id to %s", user_data.rruid)
+ hass.config_entries.async_update_entry(
+ entry,
+ unique_id=user_data.rruid,
+ version=1,
+ minor_version=2,
+ )
+
return True
@@ -134,6 +196,7 @@ def build_setup_functions(
user_data: UserData,
product_info: dict[str, HomeDataProduct],
home_data_rooms: list[HomeDataRoom],
+ api_client: RoborockApiClient,
) -> list[
Coroutine[
Any,
@@ -150,6 +213,7 @@ def build_setup_functions(
device,
product_info[device.product_id],
home_data_rooms,
+ api_client,
)
for device in device_map.values()
]
@@ -162,11 +226,12 @@ async def setup_device(
device: HomeDataDevice,
product_info: HomeDataProduct,
home_data_rooms: list[HomeDataRoom],
+ api_client: RoborockApiClient,
) -> RoborockDataUpdateCoordinator | RoborockDataUpdateCoordinatorA01 | None:
"""Set up a coordinator for a given device."""
if device.pv == "1.0":
return await setup_device_v1(
- hass, entry, user_data, device, product_info, home_data_rooms
+ hass, entry, user_data, device, product_info, home_data_rooms, api_client
)
if device.pv == "A01":
return await setup_device_a01(hass, entry, user_data, device, product_info)
@@ -186,6 +251,7 @@ async def setup_device_v1(
device: HomeDataDevice,
product_info: HomeDataProduct,
home_data_rooms: list[HomeDataRoom],
+ api_client: RoborockApiClient,
) -> RoborockDataUpdateCoordinator | None:
"""Set up a device Coordinator."""
mqtt_client = await hass.async_add_executor_job(
@@ -207,7 +273,15 @@ async def setup_device_v1(
await mqtt_client.async_release()
raise
coordinator = RoborockDataUpdateCoordinator(
- hass, entry, device, networking, product_info, mqtt_client, home_data_rooms
+ hass,
+ entry,
+ device,
+ networking,
+ product_info,
+ mqtt_client,
+ home_data_rooms,
+ api_client,
+ user_data,
)
try:
await coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py
index c734eaf5ce8..a2c34f5c59d 100644
--- a/homeassistant/components/roborock/binary_sensor.py
+++ b/homeassistant/components/roborock/binary_sensor.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
+from roborock.containers import RoborockStateCode
from roborock.roborock_typing import DeviceProp
from homeassistant.components.binary_sensor import (
@@ -12,19 +13,23 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.const import EntityCategory
+from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
from .entity import RoborockCoordinatedEntityV1
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RoborockBinarySensorDescription(BinarySensorEntityDescription):
"""A class that describes Roborock binary sensors."""
value_fn: Callable[[DeviceProp], bool | int | None]
+ # If it is a dock entity
+ is_dock_entity: bool = False
BINARY_SENSOR_DESCRIPTIONS = [
@@ -34,6 +39,7 @@ BINARY_SENSOR_DESCRIPTIONS = [
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.status.dry_status,
+ is_dock_entity=True,
),
RoborockBinarySensorDescription(
key="water_box_carriage_status",
@@ -63,13 +69,20 @@ BINARY_SENSOR_DESCRIPTIONS = [
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.status.in_cleaning,
),
+ RoborockBinarySensorDescription(
+ key=ATTR_BATTERY_CHARGING,
+ device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_fn=lambda data: data.status.state
+ in (RoborockStateCode.charging, RoborockStateCode.charging_complete),
+ ),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Roborock vacuum binary sensors."""
async_add_entities(
@@ -97,6 +110,7 @@ class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity
super().__init__(
f"{description.key}_{coordinator.duid_slug}",
coordinator,
+ is_dock_entity=description.is_dock_entity,
)
self.entity_description = description
diff --git a/homeassistant/components/roborock/button.py b/homeassistant/components/roborock/button.py
index 038f224f726..fea38524fe0 100644
--- a/homeassistant/components/roborock/button.py
+++ b/homeassistant/components/roborock/button.py
@@ -2,17 +2,22 @@
from __future__ import annotations
+import asyncio
from dataclasses import dataclass
+import itertools
+from typing import Any
from roborock.roborock_typing import RoborockCommand
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
-from .entity import RoborockEntityV1
+from .entity import RoborockEntity, RoborockEntityV1
+
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -62,17 +67,37 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock button platform."""
+ routines_lists = await asyncio.gather(
+ *[coordinator.get_routines() for coordinator in config_entry.runtime_data.v1],
+ )
async_add_entities(
- RoborockButtonEntity(
- coordinator,
- description,
+ itertools.chain(
+ (
+ RoborockButtonEntity(
+ coordinator,
+ description,
+ )
+ for coordinator in config_entry.runtime_data.v1
+ for description in CONSUMABLE_BUTTON_DESCRIPTIONS
+ if isinstance(coordinator, RoborockDataUpdateCoordinator)
+ ),
+ (
+ RoborockRoutineButtonEntity(
+ coordinator,
+ ButtonEntityDescription(
+ key=str(routine.id),
+ name=routine.name,
+ ),
+ )
+ for coordinator, routines in zip(
+ config_entry.runtime_data.v1, routines_lists, strict=True
+ )
+ for routine in routines
+ ),
)
- for coordinator in config_entry.runtime_data.v1
- for description in CONSUMABLE_BUTTON_DESCRIPTIONS
- if isinstance(coordinator, RoborockDataUpdateCoordinator)
)
@@ -97,3 +122,28 @@ class RoborockButtonEntity(RoborockEntityV1, ButtonEntity):
async def async_press(self) -> None:
"""Press the button."""
await self.send(self.entity_description.command, self.entity_description.param)
+
+
+class RoborockRoutineButtonEntity(RoborockEntity, ButtonEntity):
+ """A class to define Roborock routines button entities."""
+
+ entity_description: ButtonEntityDescription
+
+ def __init__(
+ self,
+ coordinator: RoborockDataUpdateCoordinator,
+ entity_description: ButtonEntityDescription,
+ ) -> None:
+ """Create a button entity."""
+ super().__init__(
+ f"{entity_description.key}_{coordinator.duid_slug}",
+ coordinator.device_info,
+ coordinator.api,
+ )
+ self._routine_id = int(entity_description.key)
+ self._coordinator = coordinator
+ self.entity_description = entity_description
+
+ async def async_press(self, **kwargs: Any) -> None:
+ """Press the button."""
+ await self._coordinator.execute_routines(self._routine_id)
diff --git a/homeassistant/components/roborock/config_flow.py b/homeassistant/components/roborock/config_flow.py
index 1a6b67286bb..62943e0dcc9 100644
--- a/homeassistant/components/roborock/config_flow.py
+++ b/homeassistant/components/roborock/config_flow.py
@@ -21,14 +21,17 @@ import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_REAUTH,
- ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_USERNAME
from homeassistant.core import callback
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
+from . import RoborockConfigEntry
from .const import (
CONF_BASE_URL,
CONF_ENTRY_CODE,
@@ -45,6 +48,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Roborock."""
VERSION = 1
+ MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the config flow."""
@@ -59,11 +63,11 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
if user_input is not None:
username = user_input[CONF_USERNAME]
- await self.async_set_unique_id(username.lower())
- self._abort_if_unique_id_configured(error="already_configured_account")
self._username = username
_LOGGER.debug("Requesting code for Roborock account")
- self._client = RoborockApiClient(username)
+ self._client = RoborockApiClient(
+ username, session=async_get_clientsession(self.hass)
+ )
errors = await self._request_code()
if not errors:
return await self.async_step_code()
@@ -106,7 +110,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
code = user_input[CONF_ENTRY_CODE]
_LOGGER.debug("Logging into Roborock account using email provided code")
try:
- login_data = await self._client.code_login(code)
+ user_data = await self._client.code_login(code)
except RoborockInvalidCode:
errors["base"] = "invalid_code"
except RoborockException:
@@ -116,17 +120,20 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
+ await self.async_set_unique_id(user_data.rruid)
if self.source == SOURCE_REAUTH:
+ self._abort_if_unique_id_mismatch(reason="wrong_account")
reauth_entry = self._get_reauth_entry()
self.hass.config_entries.async_update_entry(
reauth_entry,
data={
**reauth_entry.data,
- CONF_USER_DATA: login_data.as_dict(),
+ CONF_USER_DATA: user_data.as_dict(),
},
)
return self.async_abort(reason="reauth_successful")
- return self._create_entry(self._client, self._username, login_data)
+ self._abort_if_unique_id_configured(error="already_configured_account")
+ return self._create_entry(self._client, self._username, user_data)
return self.async_show_form(
step_id="code",
@@ -134,13 +141,32 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
errors=errors,
)
+ async def async_step_dhcp(
+ self, discovery_info: DhcpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle a flow started by a dhcp discovery."""
+ await self._async_handle_discovery_without_unique_id()
+ device_registry = dr.async_get(self.hass)
+ device = device_registry.async_get_device(
+ connections={
+ (dr.CONNECTION_NETWORK_MAC, dr.format_mac(discovery_info.macaddress))
+ }
+ )
+ if device is not None and any(
+ identifier[0] == DOMAIN for identifier in device.identifiers
+ ):
+ return self.async_abort(reason="already_configured")
+ return await self.async_step_user()
+
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Perform reauth upon an API authentication error."""
self._username = entry_data[CONF_USERNAME]
assert self._username
- self._client = RoborockApiClient(self._username)
+ self._client = RoborockApiClient(
+ self._username, session=async_get_clientsession(self.hass)
+ )
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
@@ -170,7 +196,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: RoborockConfigEntry,
) -> RoborockOptionsFlowHandler:
"""Create the options flow."""
return RoborockOptionsFlowHandler(config_entry)
@@ -179,7 +205,7 @@ class RoborockFlowHandler(ConfigFlow, domain=DOMAIN):
class RoborockOptionsFlowHandler(OptionsFlow):
"""Handle an option flow for Roborock."""
- def __init__(self, config_entry: ConfigEntry) -> None:
+ def __init__(self, config_entry: RoborockConfigEntry) -> None:
"""Initialize options flow."""
self.options = deepcopy(dict(config_entry.options))
diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py
index cc8d34fbadc..e56fade7078 100644
--- a/homeassistant/components/roborock/const.py
+++ b/homeassistant/components/roborock/const.py
@@ -1,5 +1,7 @@
"""Constants for Roborock."""
+from datetime import timedelta
+
from vacuum_map_parser_base.config.drawable import Drawable
from homeassistant.const import Platform
@@ -43,13 +45,21 @@ PLATFORMS = [
Platform.VACUUM,
]
-
-IMAGE_CACHE_INTERVAL = 90
+# This can be lowered in the future if we do not receive rate limiting issues.
+IMAGE_CACHE_INTERVAL = timedelta(seconds=30)
MAP_SLEEP = 3
GET_MAPS_SERVICE_NAME = "get_maps"
+MAP_SCALE = 4
MAP_FILE_FORMAT = "PNG"
MAP_FILENAME_SUFFIX = ".png"
SET_VACUUM_GOTO_POSITION_SERVICE_NAME = "set_vacuum_goto_position"
GET_VACUUM_CURRENT_POSITION_SERVICE_NAME = "get_vacuum_current_position"
+
+
+A01_UPDATE_INTERVAL = timedelta(minutes=1)
+V1_CLOUD_IN_CLEANING_INTERVAL = timedelta(seconds=30)
+V1_CLOUD_NOT_CLEANING_INTERVAL = timedelta(minutes=1)
+V1_LOCAL_IN_CLEANING_INTERVAL = timedelta(seconds=15)
+V1_LOCAL_NOT_CLEANING_INTERVAL = timedelta(seconds=30)
diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py
index 918c7159ee3..2439a4f904a 100644
--- a/homeassistant/components/roborock/coordinator.py
+++ b/homeassistant/components/roborock/coordinator.py
@@ -5,29 +5,57 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import timedelta
+import io
import logging
from propcache.api import cached_property
from roborock import HomeDataRoom
from roborock.code_mappings import RoborockCategory
-from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo
+from roborock.containers import (
+ DeviceData,
+ HomeDataDevice,
+ HomeDataProduct,
+ HomeDataScene,
+ NetworkInfo,
+ UserData,
+)
from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol
from roborock.roborock_typing import DeviceProp
from roborock.version_1_apis.roborock_local_client_v1 import RoborockLocalClientV1
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
from roborock.version_a01_apis import RoborockClientA01
+from roborock.web_api import RoborockApiClient
+from vacuum_map_parser_base.config.color import ColorsPalette
+from vacuum_map_parser_base.config.image_config import ImageConfig
+from vacuum_map_parser_base.config.size import Sizes
+from vacuum_map_parser_base.map_data import MapData
+from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CONNECTIONS
from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from homeassistant.util import slugify
+from homeassistant.util import dt as dt_util, slugify
-from .const import DOMAIN
+from .const import (
+ A01_UPDATE_INTERVAL,
+ DEFAULT_DRAWABLES,
+ DOMAIN,
+ DRAWABLES,
+ IMAGE_CACHE_INTERVAL,
+ MAP_FILE_FORMAT,
+ MAP_SCALE,
+ MAP_SLEEP,
+ V1_CLOUD_IN_CLEANING_INTERVAL,
+ V1_CLOUD_NOT_CLEANING_INTERVAL,
+ V1_LOCAL_IN_CLEANING_INTERVAL,
+ V1_LOCAL_NOT_CLEANING_INTERVAL,
+)
from .models import RoborockA01HassDeviceInfo, RoborockHassDeviceInfo, RoborockMapInfo
from .roborock_storage import RoborockMapStorage
@@ -67,6 +95,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
product_info: HomeDataProduct,
cloud_api: RoborockMqttClientV1,
home_data_rooms: list[HomeDataRoom],
+ api_client: RoborockApiClient,
+ user_data: UserData,
) -> None:
"""Initialize."""
super().__init__(
@@ -74,7 +104,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
- update_interval=SCAN_INTERVAL,
+ # Assume we can use the local api.
+ update_interval=V1_LOCAL_NOT_CLEANING_INTERVAL,
)
self.roborock_device_info = RoborockHassDeviceInfo(
device,
@@ -89,7 +120,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
self.cloud_api = cloud_api
self.device_info = DeviceInfo(
name=self.roborock_device_info.device.name,
- identifiers={(DOMAIN, self.roborock_device_info.device.duid)},
+ identifiers={(DOMAIN, self.duid)},
manufacturer="Roborock",
model=self.roborock_device_info.product.model,
model_id=self.roborock_device_info.product.model,
@@ -98,13 +129,63 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
self.current_map: int | None = None
if mac := self.roborock_device_info.network_info.mac:
- self.device_info[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, mac)}
+ self.device_info[ATTR_CONNECTIONS] = {
+ (dr.CONNECTION_NETWORK_MAC, dr.format_mac(mac))
+ }
# Maps from map flag to map name
self.maps: dict[int, RoborockMapInfo] = {}
self._home_data_rooms = {str(room.id): room.name for room in home_data_rooms}
self.map_storage = RoborockMapStorage(
- hass, self.config_entry.entry_id, slugify(self.duid)
+ hass, self.config_entry.entry_id, self.duid_slug
)
+ self._user_data = user_data
+ self._api_client = api_client
+ self._is_cloud_api = False
+ drawables = [
+ drawable
+ for drawable, default_value in DEFAULT_DRAWABLES.items()
+ if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value)
+ ]
+ self.map_parser = RoborockMapDataParser(
+ ColorsPalette(),
+ Sizes({k: v * MAP_SCALE for k, v in Sizes.SIZES.items()}),
+ drawables,
+ ImageConfig(scale=MAP_SCALE),
+ [],
+ )
+ self.last_update_state: str | None = None
+
+ @cached_property
+ def dock_device_info(self) -> DeviceInfo:
+ """Gets the device info for the dock.
+
+ This must happen after the coordinator does the first update.
+ Which will be the case when this is called.
+ """
+ dock_type = self.roborock_device_info.props.status.dock_type
+ return DeviceInfo(
+ name=f"{self.roborock_device_info.device.name} Dock",
+ identifiers={(DOMAIN, f"{self.duid}_dock")},
+ manufacturer="Roborock",
+ model=f"{self.roborock_device_info.product.model} Dock",
+ model_id=str(dock_type.value) if dock_type is not None else "Unknown",
+ sw_version=self.roborock_device_info.device.fv,
+ )
+
+ def parse_map_data_v1(
+ self, map_bytes: bytes
+ ) -> tuple[bytes | None, MapData | None]:
+ """Parse map_bytes and return MapData and the image."""
+ try:
+ parsed_map = self.map_parser.parse(map_bytes)
+ except (IndexError, ValueError) as err:
+ _LOGGER.debug("Exception when parsing map contents: %s", err)
+ return None, None
+ if parsed_map.image is None:
+ return None, None
+ img_byte_arr = io.BytesIO()
+ parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT)
+ return img_byte_arr.getvalue(), parsed_map
async def _async_setup(self) -> None:
"""Set up the coordinator."""
@@ -115,17 +196,68 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
try:
maps = await self.api.get_multi_maps_list()
except RoborockException as err:
- raise UpdateFailed("Failed to get map data: {err}") from err
+ _LOGGER.debug("Failed to get maps: %s", err)
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="map_failure",
+ translation_placeholders={"error": str(err)},
+ ) from err
# Rooms names populated later with calls to `set_current_map_rooms` for each map
+ roborock_maps = maps.map_info if (maps and maps.map_info) else ()
+ stored_images = await asyncio.gather(
+ *[
+ self.map_storage.async_load_map(roborock_map.mapFlag)
+ for roborock_map in roborock_maps
+ ]
+ )
self.maps = {
roborock_map.mapFlag: RoborockMapInfo(
flag=roborock_map.mapFlag,
name=roborock_map.name or f"Map {roborock_map.mapFlag}",
rooms={},
+ image=image,
+ last_updated=dt_util.utcnow() - IMAGE_CACHE_INTERVAL,
+ map_data=None,
)
- for roborock_map in (maps.map_info if (maps and maps.map_info) else ())
+ for image, roborock_map in zip(stored_images, roborock_maps, strict=False)
}
+ async def update_map(self) -> None:
+ """Update the currently selected map."""
+ # The current map was set in the props update, so these can be done without
+ # worry of applying them to the wrong map.
+ if self.current_map is None or self.current_map not in self.maps:
+ # This exists as a safeguard/ to keep mypy happy.
+ return
+ try:
+ response = await self.cloud_api.get_map_v1()
+ except RoborockException as ex:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="map_failure",
+ ) from ex
+ if not isinstance(response, bytes):
+ _LOGGER.debug("Failed to parse map contents: %s", response)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="map_failure",
+ )
+ parsed_image, parsed_map = self.parse_map_data_v1(response)
+ if parsed_image is None or parsed_map is None:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="map_failure",
+ )
+ current_roborock_map_info = self.maps[self.current_map]
+ if parsed_image != self.maps[self.current_map].image:
+ await self.map_storage.async_save_map(
+ self.current_map,
+ parsed_image,
+ )
+ current_roborock_map_info.image = parsed_image
+ current_roborock_map_info.last_updated = dt_util.utcnow()
+ current_roborock_map_info.map_data = parsed_map
+
async def _verify_api(self) -> None:
"""Verify that the api is reachable. If it is not, switch clients."""
if isinstance(self.api, RoborockLocalClientV1):
@@ -134,11 +266,13 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
except RoborockException:
_LOGGER.warning(
"Using the cloud API for device %s. This is not recommended as it can lead to rate limiting. We recommend making your vacuum accessible by your Home Assistant instance",
- self.roborock_device_info.device.duid,
+ self.duid,
)
await self.api.async_disconnect()
# We use the cloud api if the local api fails to connect.
self.api = self.cloud_api
+ self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL
+ self._is_cloud_api = True
# Right now this should never be called if the cloud api is the primary api,
# but in the future if it is, a new else should be added.
@@ -164,9 +298,43 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
# Set the new map id from the updated device props
self._set_current_map()
# Get the rooms for that map id.
+
+ # If the vacuum is currently cleaning and it has been IMAGE_CACHE_INTERVAL
+ # since the last map update, you can update the map.
+ new_status = self.roborock_device_info.props.status
+ if (
+ self.current_map is not None
+ and (current_map := self.maps.get(self.current_map))
+ and (
+ (
+ new_status.in_cleaning
+ and (dt_util.utcnow() - current_map.last_updated)
+ > IMAGE_CACHE_INTERVAL
+ )
+ or self.last_update_state != new_status.state_name
+ )
+ ):
+ try:
+ await self.update_map()
+ except HomeAssistantError as err:
+ _LOGGER.debug("Failed to update map: %s", err)
await self.set_current_map_rooms()
except RoborockException as ex:
- raise UpdateFailed(ex) from ex
+ _LOGGER.debug("Failed to update data: %s", ex)
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_data_fail",
+ ) from ex
+ if self.roborock_device_info.props.status.in_cleaning:
+ if self._is_cloud_api:
+ self.update_interval = V1_CLOUD_IN_CLEANING_INTERVAL
+ else:
+ self.update_interval = V1_LOCAL_IN_CLEANING_INTERVAL
+ elif self._is_cloud_api:
+ self.update_interval = V1_CLOUD_NOT_CLEANING_INTERVAL
+ else:
+ self.update_interval = V1_LOCAL_NOT_CLEANING_INTERVAL
+ self.last_update_state = self.roborock_device_info.props.status.state_name
return self.roborock_device_info.props
def _set_current_map(self) -> None:
@@ -193,6 +361,34 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
for room in room_mapping or ()
}
+ async def get_routines(self) -> list[HomeDataScene]:
+ """Get routines."""
+ try:
+ return await self._api_client.get_scenes(self._user_data, self.duid)
+ except RoborockException as err:
+ _LOGGER.error("Failed to get routines %s", err)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="command_failed",
+ translation_placeholders={
+ "command": "get_scenes",
+ },
+ ) from err
+
+ async def execute_routines(self, routine_id: int) -> None:
+ """Execute routines."""
+ try:
+ await self._api_client.execute_scene(self._user_data, routine_id)
+ except RoborockException as err:
+ _LOGGER.error("Failed to execute routines %s %s", routine_id, err)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="command_failed",
+ translation_placeholders={
+ "command": "execute_scene",
+ },
+ ) from err
+
@cached_property
def duid(self) -> str:
"""Get the unique id of the device as specified by Roborock."""
@@ -203,6 +399,43 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
"""Get the slug of the duid."""
return slugify(self.duid)
+ async def refresh_coordinator_map(self) -> None:
+ """Get the starting map information for all maps for this device.
+
+ The following steps must be done synchronously.
+ Only one map can be loaded at a time per device.
+ """
+ cur_map = self.current_map
+ # This won't be None at this point as the coordinator will have run first.
+ if cur_map is None:
+ # If we don't have a cur map(shouldn't happen) just
+ # return as we can't do anything.
+ return
+ map_flags = sorted(self.maps, key=lambda data: data == cur_map, reverse=True)
+ for map_flag in map_flags:
+ if map_flag != cur_map:
+ # Only change the map and sleep if we have multiple maps.
+ await self.api.load_multi_map(map_flag)
+ self.current_map = map_flag
+ # We cannot get the map until the roborock servers fully process the
+ # map change.
+ await asyncio.sleep(MAP_SLEEP)
+ tasks = [self.set_current_map_rooms()]
+ # The image is set within async_setup, so if it exists, we have it here.
+ if self.maps[map_flag].image is None:
+ # If we don't have a cached map, let's update it here so that it can be
+ # cached in the future.
+ tasks.append(self.update_map())
+ # If either of these fail, we don't care, and we want to continue.
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+ if len(self.maps) != 1:
+ # Set the map back to the map the user previously had selected so that it
+ # does not change the end user's app.
+ # Only needs to happen when we changed maps above.
+ await self.api.load_multi_map(cur_map)
+ self.current_map = cur_map
+
class RoborockDataUpdateCoordinatorA01(
DataUpdateCoordinator[
@@ -227,7 +460,7 @@ class RoborockDataUpdateCoordinatorA01(
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
- update_interval=SCAN_INTERVAL,
+ update_interval=A01_UPDATE_INTERVAL,
)
self.api = api
self.device_info = DeviceInfo(
diff --git a/homeassistant/components/roborock/entity.py b/homeassistant/components/roborock/entity.py
index 4a16ada5967..404f239c93a 100644
--- a/homeassistant/components/roborock/entity.py
+++ b/homeassistant/components/roborock/entity.py
@@ -8,7 +8,11 @@ from roborock.containers import Consumable, Status
from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import RoborockCommand
-from roborock.version_1_apis.roborock_client_v1 import AttributeCache, RoborockClientV1
+from roborock.version_1_apis.roborock_client_v1 import (
+ CLOUD_REQUIRED,
+ AttributeCache,
+ RoborockClientV1,
+)
from roborock.version_1_apis.roborock_mqtt_client_v1 import RoborockMqttClientV1
from roborock.version_a01_apis import RoborockClientA01
@@ -53,14 +57,16 @@ class RoborockEntityV1(RoborockEntity):
"""Get an item from the api cache."""
return self._api.cache[attribute]
- async def send(
- self,
+ @classmethod
+ async def _send_command(
+ cls,
command: RoborockCommand | str,
+ api: RoborockClientV1,
params: dict[str, Any] | list[Any] | int | None = None,
) -> dict:
- """Send a command to a vacuum cleaner."""
+ """Send a Roborock command with params to a given api."""
try:
- response: dict = await self._api.send_command(command, params)
+ response: dict = await api.send_command(command, params)
except RoborockException as err:
if isinstance(command, RoborockCommand):
command_name = command.name
@@ -75,6 +81,14 @@ class RoborockEntityV1(RoborockEntity):
) from err
return response
+ async def send(
+ self,
+ command: RoborockCommand | str,
+ params: dict[str, Any] | list[Any] | int | None = None,
+ ) -> dict:
+ """Send a command to a vacuum cleaner."""
+ return await self._send_command(command, self._api, params)
+
@property
def api(self) -> RoborockClientV1:
"""Returns the api."""
@@ -107,12 +121,15 @@ class RoborockCoordinatedEntityV1(
listener_request: list[RoborockDataProtocol]
| RoborockDataProtocol
| None = None,
+ is_dock_entity: bool = False,
) -> None:
"""Initialize the coordinated Roborock Device."""
RoborockEntityV1.__init__(
self,
unique_id=unique_id,
- device_info=coordinator.device_info,
+ device_info=coordinator.device_info
+ if not is_dock_entity
+ else coordinator.dock_device_info,
api=coordinator.api,
)
CoordinatorEntity.__init__(self, coordinator=coordinator)
@@ -152,7 +169,10 @@ class RoborockCoordinatedEntityV1(
params: dict[str, Any] | list[Any] | int | None = None,
) -> dict:
"""Overloads normal send command but refreshes coordinator."""
- res = await super().send(command, params)
+ if command in CLOUD_REQUIRED:
+ res = await self._send_command(command, self.coordinator.cloud_api, params)
+ else:
+ res = await self._send_command(command, self._api, params)
await self.coordinator.async_refresh()
return res
diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py
index ff1c94957e0..d1c19331ba4 100644
--- a/homeassistant/components/roborock/image.py
+++ b/homeassistant/components/roborock/image.py
@@ -1,63 +1,29 @@
"""Support for Roborock image."""
-import asyncio
-from collections.abc import Callable
from datetime import datetime
-import io
-
-from roborock import RoborockCommand
-from vacuum_map_parser_base.config.color import ColorsPalette
-from vacuum_map_parser_base.config.image_config import ImageConfig
-from vacuum_map_parser_base.config.size import Sizes
-from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
+import logging
from homeassistant.components.image import ImageEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.util import dt as dt_util
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import (
- DEFAULT_DRAWABLES,
- DOMAIN,
- DRAWABLES,
- IMAGE_CACHE_INTERVAL,
- MAP_FILE_FORMAT,
- MAP_SLEEP,
-)
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
from .entity import RoborockCoordinatedEntityV1
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock image platform."""
- drawables = [
- drawable
- for drawable, default_value in DEFAULT_DRAWABLES.items()
- if config_entry.options.get(DRAWABLES, {}).get(drawable, default_value)
- ]
- parser = RoborockMapDataParser(
- ColorsPalette(), Sizes(), drawables, ImageConfig(), []
- )
-
- def parse_image(map_bytes: bytes) -> bytes | None:
- parsed_map = parser.parse(map_bytes)
- if parsed_map.image is None:
- return None
- img_byte_arr = io.BytesIO()
- parsed_map.image.data.save(img_byte_arr, format=MAP_FILE_FORMAT)
- return img_byte_arr.getvalue()
-
- await asyncio.gather(
- *(refresh_coordinators(hass, coord) for coord in config_entry.runtime_data.v1)
- )
async_add_entities(
(
RoborockMap(
@@ -66,7 +32,6 @@ async def async_setup_entry(
coord,
map_info.flag,
map_info.name,
- parse_image,
)
for coord in config_entry.runtime_data.v1
for map_info in coord.maps.values()
@@ -88,14 +53,12 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
coordinator: RoborockDataUpdateCoordinator,
map_flag: int,
map_name: str,
- parser: Callable[[bytes], bytes | None],
) -> None:
"""Initialize a Roborock map."""
RoborockCoordinatedEntityV1.__init__(self, unique_id, coordinator)
ImageEntity.__init__(self, coordinator.hass)
self.config_entry = config_entry
self._attr_name = map_name
- self.parser = parser
self.map_flag = map_flag
self.cached_map = b""
self._attr_entity_category = EntityCategory.DIAGNOSTIC
@@ -105,89 +68,22 @@ class RoborockMap(RoborockCoordinatedEntityV1, ImageEntity):
"""Return if this map is the currently selected map."""
return self.map_flag == self.coordinator.current_map
- def is_map_valid(self) -> bool:
- """Update the map if it is valid.
-
- Update this map if it is the currently active map, and the
- vacuum is cleaning, or if it has never been set at all.
- """
- return self.cached_map == b"" or (
- self.is_selected
- and self.image_last_updated is not None
- and self.coordinator.roborock_device_info.props.status is not None
- and bool(self.coordinator.roborock_device_info.props.status.in_cleaning)
- )
-
async def async_added_to_hass(self) -> None:
"""When entity is added to hass load any previously cached maps from disk."""
await super().async_added_to_hass()
- content = await self.coordinator.map_storage.async_load_map(self.map_flag)
- self.cached_map = content or b""
- self._attr_image_last_updated = dt_util.utcnow()
+ self._attr_image_last_updated = self.coordinator.maps[
+ self.map_flag
+ ].last_updated
self.async_write_ha_state()
def _handle_coordinator_update(self) -> None:
- # Bump last updated every third time the coordinator runs, so that async_image
- # will be called and we will evaluate on the new coordinator data if we should
- # update the cache.
- if (
- dt_util.utcnow() - self.image_last_updated
- ).total_seconds() > IMAGE_CACHE_INTERVAL and self.is_map_valid():
- self._attr_image_last_updated = dt_util.utcnow()
+ # If the coordinator has updated the map, we can update the image.
+ self._attr_image_last_updated = self.coordinator.maps[
+ self.map_flag
+ ].last_updated
+
super()._handle_coordinator_update()
async def async_image(self) -> bytes | None:
- """Update the image if it is not cached."""
- if self.is_map_valid():
- response = await asyncio.gather(
- *(
- self.cloud_api.get_map_v1(),
- self.coordinator.set_current_map_rooms(),
- ),
- return_exceptions=True,
- )
- if (
- not isinstance(response[0], bytes)
- or (content := self.parser(response[0])) is None
- ):
- raise HomeAssistantError(
- translation_domain=DOMAIN,
- translation_key="map_failure",
- )
- if self.cached_map != content:
- self.cached_map = content
- await self.coordinator.map_storage.async_save_map(
- self.map_flag,
- content,
- )
- return self.cached_map
-
-
-async def refresh_coordinators(
- hass: HomeAssistant, coord: RoborockDataUpdateCoordinator
-) -> None:
- """Get the starting map information for all maps for this device.
-
- The following steps must be done synchronously.
- Only one map can be loaded at a time per device.
- """
- cur_map = coord.current_map
- # This won't be None at this point as the coordinator will have run first.
- assert cur_map is not None
- map_flags = sorted(coord.maps, key=lambda data: data == cur_map, reverse=True)
- for map_flag in map_flags:
- if map_flag != cur_map:
- # Only change the map and sleep if we have multiple maps.
- await coord.api.send_command(RoborockCommand.LOAD_MULTI_MAP, [map_flag])
- coord.current_map = map_flag
- # We cannot get the map until the roborock servers fully process the
- # map change.
- await asyncio.sleep(MAP_SLEEP)
- await coord.set_current_map_rooms()
-
- if len(coord.maps) != 1:
- # Set the map back to the map the user previously had selected so that it
- # does not change the end user's app.
- # Only needs to happen when we changed maps above.
- await coord.cloud_api.send_command(RoborockCommand.LOAD_MULTI_MAP, [cur_map])
- coord.current_map = cur_map
+ """Get the cached image."""
+ return self.coordinator.maps[self.map_flag].image
diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json
index db2654d4baa..531590d5d6e 100644
--- a/homeassistant/components/roborock/manifest.json
+++ b/homeassistant/components/roborock/manifest.json
@@ -3,11 +3,23 @@
"name": "Roborock",
"codeowners": ["@Lash-L", "@allenporter"],
"config_flow": true,
+ "dhcp": [
+ {
+ "macaddress": "249E7D*"
+ },
+ {
+ "macaddress": "B04A39*"
+ },
+ {
+ "hostname": "roborock-*"
+ }
+ ],
"documentation": "https://www.home-assistant.io/integrations/roborock",
"iot_class": "local_polling",
"loggers": ["roborock"],
+ "quality_scale": "silver",
"requirements": [
- "python-roborock==2.11.1",
+ "python-roborock==2.16.1",
"vacuum-map-parser-roborock==0.1.2"
]
}
diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py
index 4b8ab43b4a1..ab40f23d574 100644
--- a/homeassistant/components/roborock/models.py
+++ b/homeassistant/components/roborock/models.py
@@ -1,10 +1,12 @@
"""Roborock Models."""
from dataclasses import dataclass
+from datetime import datetime
from typing import Any
from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo
from roborock.roborock_typing import DeviceProp
+from vacuum_map_parser_base.map_data import MapData
@dataclass
@@ -48,3 +50,13 @@ class RoborockMapInfo:
flag: int
name: str
rooms: dict[int, str]
+ image: bytes | None
+ last_updated: datetime
+ map_data: MapData | None
+
+ @property
+ def current_room(self) -> str | None:
+ """Get the currently active room for this map if any."""
+ if self.map_data is None or self.map_data.vacuum_room is None:
+ return None
+ return self.rooms.get(self.map_data.vacuum_room)
diff --git a/homeassistant/components/roborock/number.py b/homeassistant/components/roborock/number.py
index 97aa8c2ffd4..73ac14fca71 100644
--- a/homeassistant/components/roborock/number.py
+++ b/homeassistant/components/roborock/number.py
@@ -14,7 +14,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
@@ -22,6 +22,8 @@ from .entity import RoborockEntityV1
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RoborockNumberDescription(NumberEntityDescription):
@@ -50,7 +52,7 @@ NUMBER_DESCRIPTIONS: list[RoborockNumberDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock number platform."""
possible_entities: list[
diff --git a/homeassistant/components/roborock/quality_scale.yaml b/homeassistant/components/roborock/quality_scale.yaml
new file mode 100644
index 00000000000..32ddb145f90
--- /dev/null
+++ b/homeassistant/components/roborock/quality_scale.yaml
@@ -0,0 +1,75 @@
+rules:
+ # Bronze
+ action-setup: done
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow: done
+ config-flow-test-coverage: done
+ dependency-transparency: done
+ docs-actions: done
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure:
+ status: done
+ comment: The config flow verifies credentials and the cloud APIs.
+ test-before-setup: done
+ unique-config-entry: done
+ # Silver
+ action-exceptions: done
+ 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: done
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery: done
+ discovery-update-info:
+ status: exempt
+ comment: Devices do not support discovery.
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations:
+ status: todo
+ comment: Documentation does not describe known limitations like rate limiting
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-troubleshooting:
+ status: todo
+ comment: |
+ There are good troubleshooting steps, however we should update the "cloud vs local"
+ and rate limiting documentation with more information.
+ docs-use-cases:
+ status: todo
+ comment: |
+ The docs describe controlling the vacuum, though does not describe more
+ interesting potential integrations with the homoe assistant ecosystem.
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: There are no noisy entities.
+ entity-translations: done
+ exception-translations: done
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues:
+ status: todo
+ comment: The Cloud vs Local API warning should probably be a repair issue.
+ stale-devices: done
+ # Platinum
+ async-dependency: todo
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py
index 826af3e24e8..208020dccab 100644
--- a/homeassistant/components/roborock/select.py
+++ b/homeassistant/components/roborock/select.py
@@ -4,19 +4,21 @@ import asyncio
from collections.abc import Callable
from dataclasses import dataclass
-from roborock.containers import Status
+from roborock.code_mappings import RoborockDockDustCollectionModeCode
from roborock.roborock_message import RoborockDataProtocol
-from roborock.roborock_typing import RoborockCommand
+from roborock.roborock_typing import DeviceProp, RoborockCommand
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import MAP_SLEEP
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
from .entity import RoborockCoordinatedEntityV1
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RoborockSelectDescription(SelectEntityDescription):
@@ -25,13 +27,15 @@ class RoborockSelectDescription(SelectEntityDescription):
# The command that the select entity will send to the api.
api_command: RoborockCommand
# Gets the current value of the select entity.
- value_fn: Callable[[Status], str | None]
+ value_fn: Callable[[DeviceProp], str | None]
# Gets all options of the select entity.
- options_lambda: Callable[[Status], list[str] | None]
+ options_lambda: Callable[[DeviceProp], list[str] | None]
# Takes the value from the select entity and converts it for the api.
- parameter_lambda: Callable[[str, Status], list[int]]
+ parameter_lambda: Callable[[str, DeviceProp], list[int]]
protocol_listener: RoborockDataProtocol | None = None
+ # If it is a dock entity
+ is_dock_entity: bool = False
SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [
@@ -39,24 +43,38 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [
key="water_box_mode",
translation_key="mop_intensity",
api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE,
- value_fn=lambda data: data.water_box_mode_name,
+ value_fn=lambda data: data.status.water_box_mode_name,
entity_category=EntityCategory.CONFIG,
- options_lambda=lambda data: data.water_box_mode.keys()
- if data.water_box_mode is not None
+ options_lambda=lambda data: data.status.water_box_mode.keys()
+ if data.status.water_box_mode is not None
else None,
- parameter_lambda=lambda key, status: [status.get_mop_intensity_code(key)],
+ parameter_lambda=lambda key, prop: [prop.status.get_mop_intensity_code(key)],
protocol_listener=RoborockDataProtocol.WATER_BOX_MODE,
),
RoborockSelectDescription(
key="mop_mode",
translation_key="mop_mode",
api_command=RoborockCommand.SET_MOP_MODE,
- value_fn=lambda data: data.mop_mode_name,
+ value_fn=lambda data: data.status.mop_mode_name,
entity_category=EntityCategory.CONFIG,
- options_lambda=lambda data: data.mop_mode.keys()
- if data.mop_mode is not None
+ options_lambda=lambda data: data.status.mop_mode.keys()
+ if data.status.mop_mode is not None
else None,
- parameter_lambda=lambda key, status: [status.get_mop_mode_code(key)],
+ parameter_lambda=lambda key, prop: [prop.status.get_mop_mode_code(key)],
+ ),
+ RoborockSelectDescription(
+ key="dust_collection_mode",
+ translation_key="dust_collection_mode",
+ api_command=RoborockCommand.SET_DUST_COLLECTION_MODE,
+ value_fn=lambda data: data.dust_collection_mode_name,
+ entity_category=EntityCategory.CONFIG,
+ options_lambda=lambda data: RoborockDockDustCollectionModeCode.keys()
+ if data.dust_collection_mode_name is not None
+ else None,
+ parameter_lambda=lambda key, _: [
+ RoborockDockDustCollectionModeCode.as_dict().get(key)
+ ],
+ is_dock_entity=True,
),
]
@@ -64,7 +82,7 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock select platform."""
@@ -74,7 +92,7 @@ async def async_setup_entry(
for description in SELECT_DESCRIPTIONS
if (
options := description.options_lambda(
- coordinator.roborock_device_info.props.status
+ coordinator.roborock_device_info.props
)
)
is not None
@@ -104,6 +122,7 @@ class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
f"{entity_description.key}_{coordinator.duid_slug}",
coordinator,
entity_description.protocol_listener,
+ is_dock_entity=entity_description.is_dock_entity,
)
self._attr_options = options
@@ -111,27 +130,28 @@ class RoborockSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
"""Set the option."""
await self.send(
self.entity_description.api_command,
- self.entity_description.parameter_lambda(option, self._device_status),
+ self.entity_description.parameter_lambda(option, self.coordinator.data),
)
@property
def current_option(self) -> str | None:
- """Get the current status of the select entity from device_status."""
- return self.entity_description.value_fn(self._device_status)
+ """Get the current status of the select entity from device props."""
+ return self.entity_description.value_fn(self.coordinator.data)
class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
"""A class to let you set the selected map on Roborock vacuum."""
- _attr_entity_category = EntityCategory.DIAGNOSTIC
+ _attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "selected_map"
async def async_select_option(self, option: str) -> None:
"""Set the option."""
for map_id, map_ in self.coordinator.maps.items():
if map_.name == option:
- await self.send(
+ await self._send_command(
RoborockCommand.LOAD_MULTI_MAP,
+ self.api,
[map_id],
)
# Update the current map id manually so that nothing gets broken
@@ -140,6 +160,7 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
# We need to wait after updating the map
# so that other commands will be executed correctly.
await asyncio.sleep(MAP_SLEEP)
+ await self.coordinator.async_refresh()
break
@property
diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py
index 0d376debcbf..a007d6fa457 100644
--- a/homeassistant/components/roborock/sensor.py
+++ b/homeassistant/components/roborock/sensor.py
@@ -28,7 +28,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import (
@@ -36,7 +36,13 @@ from .coordinator import (
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
)
-from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1
+from .entity import (
+ RoborockCoordinatedEntityA01,
+ RoborockCoordinatedEntityV1,
+ RoborockEntity,
+)
+
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -47,6 +53,9 @@ class RoborockSensorDescription(SensorEntityDescription):
protocol_listener: RoborockDataProtocol | None = None
+ # If it is a dock entity
+ is_dock_entity: bool = False
+
@dataclass(frozen=True, kw_only=True)
class RoborockSensorDescriptionA01(SensorEntityDescription):
@@ -197,6 +206,7 @@ SENSOR_DESCRIPTIONS = [
entity_category=EntityCategory.DIAGNOSTIC,
device_class=SensorDeviceClass.ENUM,
options=RoborockDockErrorCode.keys(),
+ is_dock_entity=True,
),
RoborockSensorDescription(
key="mop_clean_remaining",
@@ -205,6 +215,7 @@ SENSOR_DESCRIPTIONS = [
value_fn=lambda data: data.status.rdt,
translation_key="mop_drying_remaining_time",
entity_category=EntityCategory.DIAGNOSTIC,
+ is_dock_entity=True,
),
]
@@ -295,11 +306,11 @@ A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Roborock vacuum sensors."""
coordinators = config_entry.runtime_data
- async_add_entities(
+ entities: list[RoborockEntity] = [
RoborockSensorEntity(
coordinator,
description,
@@ -307,8 +318,9 @@ async def async_setup_entry(
for coordinator in coordinators.v1
for description in SENSOR_DESCRIPTIONS
if description.value_fn(coordinator.roborock_device_info.props) is not None
- )
- async_add_entities(
+ ]
+ entities.extend(RoborockCurrentRoom(coordinator) for coordinator in coordinators.v1)
+ entities.extend(
RoborockSensorEntityA01(
coordinator,
description,
@@ -317,6 +329,7 @@ async def async_setup_entry(
for description in A01_SENSOR_DESCRIPTIONS
if description.data_protocol in coordinator.data
)
+ async_add_entities(entities)
class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity):
@@ -335,6 +348,7 @@ class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity):
f"{description.key}_{coordinator.duid_slug}",
coordinator,
description.protocol_listener,
+ is_dock_entity=description.is_dock_entity,
)
@property
@@ -345,6 +359,48 @@ class RoborockSensorEntity(RoborockCoordinatedEntityV1, SensorEntity):
)
+class RoborockCurrentRoom(RoborockCoordinatedEntityV1, SensorEntity):
+ """Representation of a Current Room Sensor."""
+
+ _attr_device_class = SensorDeviceClass.ENUM
+ _attr_translation_key = "current_room"
+ _attr_entity_category = EntityCategory.DIAGNOSTIC
+
+ def __init__(
+ self,
+ coordinator: RoborockDataUpdateCoordinator,
+ ) -> None:
+ """Initialize the entity."""
+ super().__init__(
+ f"current_room_{coordinator.duid_slug}",
+ coordinator,
+ None,
+ is_dock_entity=False,
+ )
+
+ @property
+ def options(self) -> list[str]:
+ """Return the currently valid rooms."""
+ if (
+ self.coordinator.current_map is not None
+ and self.coordinator.current_map in self.coordinator.maps
+ ):
+ return list(
+ self.coordinator.maps[self.coordinator.current_map].rooms.values()
+ )
+ return []
+
+ @property
+ def native_value(self) -> str | None:
+ """Return the value reported by the sensor."""
+ if (
+ self.coordinator.current_map is not None
+ and self.coordinator.current_map in self.coordinator.maps
+ ):
+ return self.coordinator.maps[self.coordinator.current_map].current_room
+ return None
+
+
class RoborockSensorEntityA01(RoborockCoordinatedEntityA01, SensorEntity):
"""Representation of a A01 Roborock sensor."""
diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json
index 8968ac020a2..0f36fbec3d5 100644
--- a/homeassistant/components/roborock/strings.json
+++ b/homeassistant/components/roborock/strings.json
@@ -5,12 +5,18 @@
"description": "Enter your Roborock email address.",
"data": {
"username": "[%key:common::config_flow::data::email%]"
+ },
+ "data_description": {
+ "username": "The email address used to sign in to the Roborock app."
}
},
"code": {
"description": "Type the verification code sent to your email",
"data": {
"code": "Verification code"
+ },
+ "data_description": {
+ "code": "The verification code sent to your email."
}
},
"reauth_confirm": {
@@ -29,7 +35,8 @@
},
"abort": {
"already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "wrong_account": "Wrong account: Please authenticate with the right account."
}
},
"options": {
@@ -54,6 +61,25 @@
"vacuum_position": "Vacuum position",
"virtual_walls": "Virtual walls",
"zones": "Zones"
+ },
+ "data_description": {
+ "charger": "Show the charger on the map.",
+ "cleaned_area": "Show the area cleaned on the map.",
+ "goto_path": "Show the go-to path on the map.",
+ "ignored_obstacles": "Show ignored obstacles on the map.",
+ "ignored_obstacles_with_photo": "Show ignored obstacles with photos on the map.",
+ "mop_path": "Show the mop path on the map.",
+ "no_carpet_zones": "Show the no carpet zones on the map.",
+ "no_go_zones": "Show the no-go zones on the map.",
+ "no_mopping_zones": "Show the no-mop zones on the map.",
+ "obstacles": "Show obstacles on the map.",
+ "obstacles_with_photo": "Show obstacles with photos on the map.",
+ "path": "Show the path on the map.",
+ "predicted_path": "Show the predicted path on the map.",
+ "room_names": "Show room names on the map.",
+ "vacuum_position": "Show the vacuum position on the map.",
+ "virtual_walls": "Show virtual walls on the map.",
+ "zones": "Show zones on the map."
}
}
}
@@ -128,7 +154,7 @@
"updating": "[%key:component::roborock::entity::sensor::status::state::updating%]",
"washing": "Washing",
"ready": "Ready",
- "charging": "[%key:component::roborock::entity::sensor::status::state::charging%]",
+ "charging": "[%key:common::state::charging%]",
"mop_washing": "Washing mop",
"self_clean_cleaning": "Self clean cleaning",
"self_clean_deep_cleaning": "Self clean deep cleaning",
@@ -156,6 +182,9 @@
"countdown": {
"name": "Countdown"
},
+ "current_room": {
+ "name": "Current room"
+ },
"dock_error": {
"name": "Dock error",
"state": {
@@ -199,7 +228,7 @@
"cleaning": "Cleaning",
"returning_home": "Returning home",
"manual_mode": "Manual mode",
- "charging": "Charging",
+ "charging": "[%key:common::state::charging%]",
"charging_problem": "Charging problem",
"paused": "[%key:common::state::paused%]",
"spot_cleaning": "Spot cleaning",
@@ -310,7 +339,7 @@
"zeo_state": {
"name": "State",
"state": {
- "standby": "Standby",
+ "standby": "[%key:common::state::standby%]",
"weighing": "Weighing",
"soaking": "Soaking",
"washing": "Washing",
@@ -339,12 +368,12 @@
"name": "Mop intensity",
"state": {
"off": "[%key:common::state::off%]",
- "low": "Low",
+ "low": "[%key:common::state::low%]",
"mild": "Mild",
- "medium": "Medium",
+ "medium": "[%key:common::state::medium%]",
"moderate": "Moderate",
"max": "Max",
- "high": "High",
+ "high": "[%key:common::state::high%]",
"intense": "Intense",
"custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]",
"custom_water_flow": "Custom water flow",
@@ -353,6 +382,15 @@
},
"selected_map": {
"name": "Selected map"
+ },
+ "dust_collection_mode": {
+ "name": "Empty mode",
+ "state": {
+ "smart": "Smart",
+ "light": "Light",
+ "balanced": "[%key:component::roborock::entity::vacuum::roborock::state_attributes::fan_speed::state::balanced%]",
+ "max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]"
+ }
}
},
"switch": {
@@ -388,14 +426,14 @@
"state_attributes": {
"fan_speed": {
"state": {
- "auto": "Auto",
+ "off": "[%key:common::state::off%]",
+ "auto": "[%key:common::state::auto%]",
"balanced": "Balanced",
"custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]",
"gentle": "Gentle",
- "off": "[%key:common::state::off%]",
"max": "[%key:component::roborock::entity::select::mop_intensity::state::max%]",
"max_plus": "Max plus",
- "medium": "Medium",
+ "medium": "[%key:common::state::medium%]",
"quiet": "Quiet",
"silent": "Silent",
"standard": "[%key:component::roborock::entity::select::mop_mode::state::standard%]",
@@ -420,6 +458,12 @@
"map_failure": {
"message": "Something went wrong creating the map"
},
+ "position_not_found": {
+ "message": "Robot position not found"
+ },
+ "update_data_fail": {
+ "message": "Failed to update data"
+ },
"no_coordinators": {
"message": "No devices were able to successfully setup"
},
diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py
index ebf8225b4f5..44feccdebac 100644
--- a/homeassistant/components/roborock/switch.py
+++ b/homeassistant/components/roborock/switch.py
@@ -16,7 +16,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
@@ -24,6 +24,8 @@ from .entity import RoborockEntityV1
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RoborockSwitchDescription(SwitchEntityDescription):
@@ -35,6 +37,8 @@ class RoborockSwitchDescription(SwitchEntityDescription):
update_value: Callable[[AttributeCache, bool], Coroutine[Any, Any, None]]
# Attribute from cache
attribute: str
+ # If it is a dock entity
+ is_dock_entity: bool = False
SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [
@@ -47,6 +51,7 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [
key="child_lock",
translation_key="child_lock",
entity_category=EntityCategory.CONFIG,
+ is_dock_entity=True,
),
RoborockSwitchDescription(
cache_key=CacheableAttribute.flow_led_status,
@@ -57,6 +62,7 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [
key="status_indicator",
translation_key="status_indicator",
entity_category=EntityCategory.CONFIG,
+ is_dock_entity=True,
),
RoborockSwitchDescription(
cache_key=CacheableAttribute.dnd_timer,
@@ -99,7 +105,7 @@ SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock switch platform."""
possible_entities: list[
@@ -147,7 +153,13 @@ class RoborockSwitch(RoborockEntityV1, SwitchEntity):
) -> None:
"""Initialize the entity."""
self.entity_description = entity_description
- super().__init__(unique_id, coordinator.device_info, coordinator.api)
+ super().__init__(
+ unique_id,
+ coordinator.device_info
+ if not entity_description.is_dock_entity
+ else coordinator.dock_device_info,
+ coordinator.api,
+ )
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the switch."""
diff --git a/homeassistant/components/roborock/time.py b/homeassistant/components/roborock/time.py
index 76f20bc6607..83d341fa2dd 100644
--- a/homeassistant/components/roborock/time.py
+++ b/homeassistant/components/roborock/time.py
@@ -16,7 +16,7 @@ from homeassistant.components.time import TimeEntity, TimeEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
@@ -24,6 +24,8 @@ from .entity import RoborockEntityV1
_LOGGER = logging.getLogger(__name__)
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class RoborockTimeDescription(TimeEntityDescription):
@@ -114,7 +116,7 @@ TIME_DESCRIPTIONS: list[RoborockTimeDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roborock time platform."""
possible_entities: list[
diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py
index e604ab6a209..058fffbdb1c 100644
--- a/homeassistant/components/roborock/vacuum.py
+++ b/homeassistant/components/roborock/vacuum.py
@@ -1,11 +1,14 @@
"""Support for Roborock vacuum class."""
-from dataclasses import asdict
from typing import Any
from roborock.code_mappings import RoborockStateCode
from roborock.roborock_message import RoborockDataProtocol
from roborock.roborock_typing import RoborockCommand
+from vacuum_map_parser_base.config.color import ColorsPalette
+from vacuum_map_parser_base.config.image_config import ImageConfig
+from vacuum_map_parser_base.config.size import Sizes
+from vacuum_map_parser_roborock.map_data_parser import RoborockMapDataParser
import voluptuous as vol
from homeassistant.components.vacuum import (
@@ -16,7 +19,7 @@ from homeassistant.components.vacuum import (
from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
@@ -26,7 +29,6 @@ from .const import (
)
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
from .entity import RoborockCoordinatedEntityV1
-from .image import ColorsPalette, ImageConfig, RoborockMapDataParser, Sizes
STATE_CODE_TO_STATE = {
RoborockStateCode.starting: VacuumActivity.IDLE, # "Starting"
@@ -54,11 +56,13 @@ STATE_CODE_TO_STATE = {
RoborockStateCode.device_offline: VacuumActivity.ERROR, # "Device offline"
}
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Roborock sensor."""
async_add_entities(
@@ -201,7 +205,14 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
"""Get map information such as map id and room ids."""
return {
"maps": [
- asdict(vacuum_map) for vacuum_map in self.coordinator.maps.values()
+ {
+ "flag": vacuum_map.flag,
+ "name": vacuum_map.name,
+ # JsonValueType does not accept a int as a key - was not a
+ # issue with previous asdict() implementation.
+ "rooms": vacuum_map.rooms, # type: ignore[dict-item]
+ }
+ for vacuum_map in self.coordinator.maps.values()
]
}
@@ -210,13 +221,18 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
map_data = await self.coordinator.cloud_api.get_map_v1()
if not isinstance(map_data, bytes):
- raise HomeAssistantError("Failed to retrieve map data.")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="map_failure",
+ )
parser = RoborockMapDataParser(ColorsPalette(), Sizes(), [], ImageConfig(), [])
parsed_map = parser.parse(map_data)
robot_position = parsed_map.vacuum_position
if robot_position is None:
- raise HomeAssistantError("Robot position not found")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN, translation_key="position_not_found"
+ )
return {
"x": robot_position.x,
diff --git a/homeassistant/components/roku/binary_sensor.py b/homeassistant/components/roku/binary_sensor.py
index 1afc580f2fe..31250898055 100644
--- a/homeassistant/components/roku/binary_sensor.py
+++ b/homeassistant/components/roku/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import RokuConfigEntry
from .entity import RokuEntity
@@ -59,7 +59,7 @@ BINARY_SENSORS: tuple[RokuBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RokuConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Roku binary sensors based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py
index fb4f8b1c2e8..d0e1e3a53c0 100644
--- a/homeassistant/components/roku/media_player.py
+++ b/homeassistant/components/roku/media_player.py
@@ -26,7 +26,7 @@ from homeassistant.components.stream import FORMAT_CONTENT_TYPE, HLS_PROVIDER
from homeassistant.const import ATTR_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .browse_media import async_browse_media
@@ -82,7 +82,9 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
- hass: HomeAssistant, entry: RokuConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: RokuConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Roku config entry."""
async_add_entities(
diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py
index fd76e2e8dcf..cc3689c9df3 100644
--- a/homeassistant/components/roku/remote.py
+++ b/homeassistant/components/roku/remote.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import RokuConfigEntry
from .entity import RokuEntity
@@ -19,7 +19,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: RokuConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Roku remote based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/roku/select.py b/homeassistant/components/roku/select.py
index c99b9892b47..062e1258ea2 100644
--- a/homeassistant/components/roku/select.py
+++ b/homeassistant/components/roku/select.py
@@ -10,7 +10,7 @@ from rokuecp.models import Device as RokuDevice
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import RokuConfigEntry
from .entity import RokuEntity
@@ -109,7 +109,7 @@ CHANNEL_ENTITY = RokuSelectEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: RokuConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roku select based on a config entry."""
device: RokuDevice = entry.runtime_data.data
diff --git a/homeassistant/components/roku/sensor.py b/homeassistant/components/roku/sensor.py
index 96295984f76..a61a9be6a73 100644
--- a/homeassistant/components/roku/sensor.py
+++ b/homeassistant/components/roku/sensor.py
@@ -10,7 +10,7 @@ from rokuecp.models import Device as RokuDevice
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import RokuConfigEntry
from .entity import RokuEntity
@@ -45,7 +45,7 @@ SENSORS: tuple[RokuSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: RokuConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roku sensor based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/roku/strings.json b/homeassistant/components/roku/strings.json
index 04348bc3bfb..62f1f8b1736 100644
--- a/homeassistant/components/roku/strings.json
+++ b/homeassistant/components/roku/strings.json
@@ -47,7 +47,7 @@
"name": "Supports AirPlay"
},
"supports_ethernet": {
- "name": "Supports ethernet"
+ "name": "Supports Ethernet"
},
"supports_find_remote": {
"name": "Supports find remote"
diff --git a/homeassistant/components/romy/binary_sensor.py b/homeassistant/components/romy/binary_sensor.py
index d8f6216007f..599c0fe023e 100644
--- a/homeassistant/components/romy/binary_sensor.py
+++ b/homeassistant/components/romy/binary_sensor.py
@@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RomyVacuumCoordinator
@@ -39,7 +39,7 @@ BINARY_SENSORS: list[BinarySensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ROMY vacuum cleaner."""
diff --git a/homeassistant/components/romy/sensor.py b/homeassistant/components/romy/sensor.py
index 341125b86ba..85bf0df8f64 100644
--- a/homeassistant/components/romy/sensor.py
+++ b/homeassistant/components/romy/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RomyVacuumCoordinator
@@ -77,7 +77,7 @@ SENSORS: list[SensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ROMY vacuum cleaner."""
diff --git a/homeassistant/components/romy/strings.json b/homeassistant/components/romy/strings.json
index 78721da17ba..aa7bfe26ea0 100644
--- a/homeassistant/components/romy/strings.json
+++ b/homeassistant/components/romy/strings.json
@@ -21,7 +21,7 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "password": "(8 characters, see QR Code under the dustbin)."
+ "password": "(8 characters, see QR code under the dustbin)."
}
},
"zeroconf_confirm": {
@@ -36,12 +36,12 @@
"fan_speed": {
"state": {
"default": "Default",
- "normal": "Normal",
- "silent": "Silent",
+ "auto": "[%key:common::state::auto%]",
+ "normal": "[%key:common::state::normal%]",
+ "high": "[%key:common::state::high%]",
"intensive": "Intensive",
- "super_silent": "Super silent",
- "high": "High",
- "auto": "Auto"
+ "silent": "Silent",
+ "super_silent": "Super silent"
}
}
}
diff --git a/homeassistant/components/romy/vacuum.py b/homeassistant/components/romy/vacuum.py
index 49129daabbd..0e9dd13ffe1 100644
--- a/homeassistant/components/romy/vacuum.py
+++ b/homeassistant/components/romy/vacuum.py
@@ -13,7 +13,7 @@ from homeassistant.components.vacuum import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LOGGER
from .coordinator import RomyVacuumCoordinator
@@ -51,7 +51,7 @@ SUPPORT_ROMY_ROBOT = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up ROMY vacuum cleaner."""
diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py
index baf66375036..d50535c885a 100644
--- a/homeassistant/components/roomba/binary_sensor.py
+++ b/homeassistant/components/roomba/binary_sensor.py
@@ -3,7 +3,7 @@
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import roomba_reported_state
from .const import DOMAIN
@@ -14,7 +14,7 @@ from .models import RoombaData
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the iRobot Roomba vacuum cleaner."""
domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py
index ae5577da4e4..14c7ac3af3e 100644
--- a/homeassistant/components/roomba/entity.py
+++ b/homeassistant/components/roomba/entity.py
@@ -80,7 +80,7 @@ class IRobotEntity(Entity):
return None
return dt_util.utc_from_timestamp(ts)
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callback function."""
self.vacuum.register_on_message_callback(self.on_message)
diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py
index d358dcb428c..3a98bedcd94 100644
--- a/homeassistant/components/roomba/sensor.py
+++ b/homeassistant/components/roomba/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfArea, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
@@ -125,7 +125,7 @@ SENSORS: list[RoombaSensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the iRobot Roomba vacuum cleaner."""
domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py
index 92063f74afa..10606814a35 100644
--- a/homeassistant/components/roomba/vacuum.py
+++ b/homeassistant/components/roomba/vacuum.py
@@ -14,7 +14,7 @@ from homeassistant.components.vacuum import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_system import METRIC_SYSTEM
@@ -89,7 +89,7 @@ SUPPORT_BRAAVA = SUPPORT_IROBOT | VacuumEntityFeature.FAN_SPEED
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the iRobot Roomba vacuum cleaner."""
domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py
index 7bc6ea27dd9..2f2967c5789 100644
--- a/homeassistant/components/roon/event.py
+++ b/homeassistant/components/roon/event.py
@@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -18,7 +18,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roon Event from Config Entry."""
roon_server = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py
index 3b1735cd2fc..4a87601a24f 100644
--- a/homeassistant/components/roon/media_player.py
+++ b/homeassistant/components/roon/media_player.py
@@ -25,7 +25,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import convert
from homeassistant.util.dt import utcnow
@@ -52,7 +52,7 @@ REPEAT_MODE_MAPPING_TO_ROON = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Roon MediaPlayer from Config Entry."""
roon_server = hass.data[DOMAIN][config_entry.entry_id]
@@ -329,6 +329,11 @@ class RoonDevice(MediaPlayerEntity):
"""Album artist of current playing media (Music track only)."""
return self.media_artist
+ @property
+ def media_content_type(self) -> str:
+ """Return the media type."""
+ return MediaType.MUSIC
+
@property
def supports_standby(self):
"""Return power state of source controls."""
diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json
index 978c916e3ee..8c21b856b80 100644
--- a/homeassistant/components/route53/manifest.json
+++ b/homeassistant/components/route53/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["boto3", "botocore", "s3transfer"],
"quality_scale": "legacy",
- "requirements": ["boto3==1.34.131"]
+ "requirements": ["boto3==1.37.1"]
}
diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py
index 589183eb7a8..59f9f28f8f5 100644
--- a/homeassistant/components/rova/sensor.py
+++ b/homeassistant/components/rova/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -43,7 +43,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Rova entry."""
coordinator: RovaCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/rova/strings.json b/homeassistant/components/rova/strings.json
index 3b89fc789ee..21f4146bf78 100644
--- a/homeassistant/components/rova/strings.json
+++ b/homeassistant/components/rova/strings.json
@@ -4,7 +4,7 @@
"user": {
"title": "Provide your address details",
"data": {
- "zip_code": "Your zip code",
+ "zip_code": "Your ZIP code",
"house_number": "Your house number",
"house_number_suffix": "A suffix for your house number"
}
diff --git a/homeassistant/components/rpi_power/binary_sensor.py b/homeassistant/components/rpi_power/binary_sensor.py
index 00d7ec0e3f4..1424148f554 100644
--- a/homeassistant/components/rpi_power/binary_sensor.py
+++ b/homeassistant/components/rpi_power/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
_LOGGER = logging.getLogger(__name__)
@@ -28,7 +28,7 @@ DESCRIPTION_UNDER_VOLTAGE = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up rpi_power binary sensor."""
under_voltage = await hass.async_add_executor_job(new_under_voltage)
diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py
index 8a5e8b79294..890148ec25c 100644
--- a/homeassistant/components/ruckus_unleashed/device_tracker.py
+++ b/homeassistant/components/ruckus_unleashed/device_tracker.py
@@ -8,7 +8,7 @@ from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -25,7 +25,9 @@ _LOGGER = logging.getLogger(__package__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for Ruckus component."""
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
@@ -69,7 +71,7 @@ def restore_entities(
registry: er.EntityRegistry,
coordinator: RuckusDataUpdateCoordinator,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
tracked: set[str],
) -> None:
"""Restore clients that are not a part of active clients list."""
diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json
index f91406e8a4b..acedbaf0573 100644
--- a/homeassistant/components/russound_rio/manifest.json
+++ b/homeassistant/components/russound_rio/manifest.json
@@ -7,6 +7,6 @@
"iot_class": "local_push",
"loggers": ["aiorussound"],
"quality_scale": "silver",
- "requirements": ["aiorussound==4.4.0"],
+ "requirements": ["aiorussound==4.5.0"],
"zeroconf": ["_rio._tcp.local."]
}
diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py
index 346f4903f6a..b40b82862f9 100644
--- a/homeassistant/components/russound_rio/media_player.py
+++ b/homeassistant/components/russound_rio/media_player.py
@@ -20,7 +20,7 @@ from homeassistant.components.media_player import (
MediaType,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import RussoundConfigEntry
from .entity import RussoundBaseEntity, command
@@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: RussoundConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Russound RIO platform."""
client = entry.runtime_data
diff --git a/homeassistant/components/ruuvitag_ble/sensor.py b/homeassistant/components/ruuvitag_ble/sensor.py
index ef287753ed4..57248d547ba 100644
--- a/homeassistant/components/ruuvitag_ble/sensor.py
+++ b/homeassistant/components/ruuvitag_ble/sensor.py
@@ -32,7 +32,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
@@ -126,7 +126,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ruuvitag BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/rympro/coordinator.py b/homeassistant/components/rympro/coordinator.py
index 55e5f0f90df..6b49a065d35 100644
--- a/homeassistant/components/rympro/coordinator.py
+++ b/homeassistant/components/rympro/coordinator.py
@@ -42,6 +42,12 @@ class RymProDataUpdateCoordinator(DataUpdateCoordinator[dict[int, dict]]):
try:
meters = await self.rympro.last_read()
for meter_id, meter in meters.items():
+ meter["monthly_consumption"] = await self.rympro.monthly_consumption(
+ meter_id
+ )
+ meter["daily_consumption"] = await self.rympro.daily_consumption(
+ meter_id
+ )
meter["consumption_forecast"] = await self.rympro.consumption_forecast(
meter_id
)
diff --git a/homeassistant/components/rympro/manifest.json b/homeassistant/components/rympro/manifest.json
index 046e778f05b..51c26b312fb 100644
--- a/homeassistant/components/rympro/manifest.json
+++ b/homeassistant/components/rympro/manifest.json
@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rympro",
"iot_class": "cloud_polling",
- "requirements": ["pyrympro==0.0.8"]
+ "requirements": ["pyrympro==0.0.9"]
}
diff --git a/homeassistant/components/rympro/sensor.py b/homeassistant/components/rympro/sensor.py
index 8bb0af6e9ff..66ed41a4ce9 100644
--- a/homeassistant/components/rympro/sensor.py
+++ b/homeassistant/components/rympro/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -36,6 +36,20 @@ SENSOR_DESCRIPTIONS: tuple[RymProSensorEntityDescription, ...] = (
suggested_display_precision=3,
value_key="read",
),
+ RymProSensorEntityDescription(
+ key="monthly_consumption",
+ translation_key="monthly_consumption",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ suggested_display_precision=3,
+ value_key="monthly_consumption",
+ ),
+ RymProSensorEntityDescription(
+ key="daily_consumption",
+ translation_key="daily_consumption",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ suggested_display_precision=3,
+ value_key="daily_consumption",
+ ),
RymProSensorEntityDescription(
key="monthly_forecast",
translation_key="monthly_forecast",
@@ -48,7 +62,7 @@ SENSOR_DESCRIPTIONS: tuple[RymProSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for device."""
coordinator: RymProDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/rympro/strings.json b/homeassistant/components/rympro/strings.json
index 2c1e2ad93c9..589e91a6c6f 100644
--- a/homeassistant/components/rympro/strings.json
+++ b/homeassistant/components/rympro/strings.json
@@ -23,6 +23,12 @@
"total_consumption": {
"name": "Total consumption"
},
+ "monthly_consumption": {
+ "name": "Monthly consumption"
+ },
+ "daily_consumption": {
+ "name": "Daily consumption"
+ },
"monthly_forecast": {
"name": "Monthly forecast"
}
diff --git a/homeassistant/components/sabnzbd/binary_sensor.py b/homeassistant/components/sabnzbd/binary_sensor.py
index 1d65bf01211..59ef17237e2 100644
--- a/homeassistant/components/sabnzbd/binary_sensor.py
+++ b/homeassistant/components/sabnzbd/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SabnzbdConfigEntry
from .entity import SabnzbdEntity
@@ -40,7 +40,7 @@ BINARY_SENSORS: tuple[SabnzbdBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SabnzbdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Sabnzbd sensor entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/sabnzbd/button.py b/homeassistant/components/sabnzbd/button.py
index 1ff26b41655..25c11f6b2ec 100644
--- a/homeassistant/components/sabnzbd/button.py
+++ b/homeassistant/components/sabnzbd/button.py
@@ -9,7 +9,7 @@ from pysabnzbd import SabnzbdApiException
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator
@@ -40,7 +40,7 @@ BUTTON_DESCRIPTIONS: tuple[SabnzbdButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: SabnzbdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up buttons from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/sabnzbd/number.py b/homeassistant/components/sabnzbd/number.py
index 53c8d462f11..63b2206ac70 100644
--- a/homeassistant/components/sabnzbd/number.py
+++ b/homeassistant/components/sabnzbd/number.py
@@ -15,7 +15,7 @@ from homeassistant.components.number import (
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SabnzbdConfigEntry, SabnzbdUpdateCoordinator
@@ -48,7 +48,7 @@ NUMBER_DESCRIPTIONS: tuple[SabnzbdNumberEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SabnzbdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SABnzbd number entity."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py
index 662ae739d15..5e871b4bf40 100644
--- a/homeassistant/components/sabnzbd/sensor.py
+++ b/homeassistant/components/sabnzbd/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfDataRate, UnitOfInformation
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import SabnzbdConfigEntry
@@ -115,7 +115,7 @@ SENSOR_TYPES: tuple[SabnzbdSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SabnzbdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Sabnzbd sensor entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py
index e416cd35765..eef9a06ab8a 100644
--- a/homeassistant/components/samsungtv/__init__.py
+++ b/homeassistant/components/samsungtv/__init__.py
@@ -258,7 +258,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: SamsungTVConfigEntry) -
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+async def async_migrate_entry(
+ hass: HomeAssistant, config_entry: SamsungTVConfigEntry
+) -> bool:
"""Migrate old entry."""
version = config_entry.version
minor_version = config_entry.minor_version
diff --git a/homeassistant/components/samsungtv/device_trigger.py b/homeassistant/components/samsungtv/device_trigger.py
index 2b3d9dbe666..749276b61c4 100644
--- a/homeassistant/components/samsungtv/device_trigger.py
+++ b/homeassistant/components/samsungtv/device_trigger.py
@@ -15,6 +15,7 @@ from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo
from homeassistant.helpers.typing import ConfigType
from . import trigger
+from .const import DOMAIN
from .helpers import (
async_get_client_by_device_entry,
async_get_device_entry_by_device_id,
@@ -75,4 +76,8 @@ async def async_attach_trigger(
hass, trigger_config, action, trigger_info
)
- raise HomeAssistantError(f"Unhandled trigger type {trigger_type}")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="unhandled_trigger_type",
+ translation_placeholders={"trigger_type": trigger_type},
+ )
diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py
index 61aa8abce53..f3ecee373e3 100644
--- a/homeassistant/components/samsungtv/entity.py
+++ b/homeassistant/components/samsungtv/entity.py
@@ -106,5 +106,7 @@ class SamsungTVEntity(CoordinatorEntity[SamsungTVDataUpdateCoordinator], Entity)
self.entity_id,
)
raise HomeAssistantError(
- f"Entity {self.entity_id} does not support this service."
+ translation_domain=DOMAIN,
+ translation_key="service_unsupported",
+ translation_placeholders={"entity": self.entity_id},
)
diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json
index 6a30efd64f8..5bb69e7f121 100644
--- a/homeassistant/components/samsungtv/manifest.json
+++ b/homeassistant/components/samsungtv/manifest.json
@@ -39,7 +39,7 @@
"samsungctl[websocket]==0.7.1",
"samsungtvws[async,encrypted]==2.7.2",
"wakeonlan==2.1.0",
- "async-upnp-client==0.43.0"
+ "async-upnp-client==0.44.0"
],
"ssdp": [
{
diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py
index 9db9916c24a..1c475ee6c25 100644
--- a/homeassistant/components/samsungtv/media_player.py
+++ b/homeassistant/components/samsungtv/media_player.py
@@ -31,7 +31,7 @@ from homeassistant.components.media_player import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.async_ import create_eager_task
from .bridge import SamsungTVWSBridge
@@ -59,11 +59,14 @@ SUPPORT_SAMSUNGTV = (
# Max delay waiting for app_list to return, as some TVs simply ignore the request
APP_LIST_DELAY = 3
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
entry: SamsungTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Samsung TV from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py
index 3d2529153be..2c6b46c8bb2 100644
--- a/homeassistant/components/samsungtv/remote.py
+++ b/homeassistant/components/samsungtv/remote.py
@@ -7,17 +7,20 @@ from typing import Any
from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER
from .coordinator import SamsungTVConfigEntry
from .entity import SamsungTVEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
entry: SamsungTVConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Samsung TV from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json
index c9d08f756d0..6e72c2b8d13 100644
--- a/homeassistant/components/samsungtv/strings.json
+++ b/homeassistant/components/samsungtv/strings.json
@@ -9,7 +9,8 @@
"name": "[%key:common::config_flow::data::name%]"
},
"data_description": {
- "host": "The hostname or IP address of your TV."
+ "host": "The hostname or IP address of your TV.",
+ "name": "The name of your TV. This will be used to identify the device in Home Assistant."
}
},
"confirm": {
@@ -22,10 +23,22 @@
"description": "After submitting, accept the popup on {device} requesting authorization within 30 seconds or input PIN."
},
"encrypted_pairing": {
- "description": "Please enter the PIN displayed on {device}."
+ "description": "Please enter the PIN displayed on {device}.",
+ "data": {
+ "pin": "[%key:common::config_flow::data::pin%]"
+ },
+ "data_description": {
+ "pin": "The PIN displayed on your TV."
+ }
},
"reauth_confirm_encrypted": {
- "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]"
+ "description": "[%key:component::samsungtv::config::step::encrypted_pairing::description%]",
+ "data": {
+ "pin": "[%key:common::config_flow::data::pin%]"
+ },
+ "data_description": {
+ "pin": "[%key:component::samsungtv::config::step::encrypted_pairing::data_description::pin%]"
+ }
}
},
"error": {
@@ -47,5 +60,13 @@
"trigger_type": {
"samsungtv.turn_on": "Device is requested to turn on"
}
+ },
+ "exceptions": {
+ "unhandled_trigger_type": {
+ "message": "Unhandled trigger type {trigger_type}."
+ },
+ "service_unsupported": {
+ "message": "Entity {entity} does not support this action."
+ }
}
}
diff --git a/homeassistant/components/sanix/sensor.py b/homeassistant/components/sanix/sensor.py
index 39a1c593433..d2a1aecb099 100644
--- a/homeassistant/components/sanix/sensor.py
+++ b/homeassistant/components/sanix/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfLength
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
@@ -82,7 +82,9 @@ SENSOR_TYPES: tuple[SanixSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sanix Sensor entities based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/schedule/strings.json b/homeassistant/components/schedule/strings.json
index 8638e4a8a84..bb81c029dbf 100644
--- a/homeassistant/components/schedule/strings.json
+++ b/homeassistant/components/schedule/strings.json
@@ -28,7 +28,7 @@
},
"get_schedule": {
"name": "Get schedule",
- "description": "Retrieve one or multiple schedules."
+ "description": "Retrieves the configured time ranges of one or multiple schedules."
}
}
}
diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py
index 280853237d4..62e69b5cb4a 100644
--- a/homeassistant/components/schlage/binary_sensor.py
+++ b/homeassistant/components/schlage/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -39,7 +39,7 @@ _DESCRIPTIONS: tuple[SchlageBinarySensorEntityDescription] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SchlageConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary_sensors based on a config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py
index 697c2e8399f..83abf9214e3 100644
--- a/homeassistant/components/schlage/lock.py
+++ b/homeassistant/components/schlage/lock.py
@@ -6,7 +6,7 @@ from typing import Any
from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -15,7 +15,7 @@ from .entity import SchlageEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SchlageConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Schlage WiFi locks based on a config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/schlage/select.py b/homeassistant/components/schlage/select.py
index f93eee78d34..4648686aaac 100644
--- a/homeassistant/components/schlage/select.py
+++ b/homeassistant/components/schlage/select.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -32,7 +32,7 @@ _DESCRIPTIONS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SchlageConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up selects based on a config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/schlage/sensor.py b/homeassistant/components/schlage/sensor.py
index f7fb7c63b22..494efc7585a 100644
--- a/homeassistant/components/schlage/sensor.py
+++ b/homeassistant/components/schlage/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -29,7 +29,7 @@ _SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SchlageConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors based on a config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json
index 56e72c2d2c0..42bd51de9d0 100644
--- a/homeassistant/components/schlage/strings.json
+++ b/homeassistant/components/schlage/strings.json
@@ -33,9 +33,9 @@
},
"select": {
"auto_lock_time": {
- "name": "Auto-Lock time",
+ "name": "Auto-lock time",
"state": {
- "0": "Disabled",
+ "0": "[%key:common::state::disabled%]",
"15": "15 seconds",
"30": "30 seconds",
"60": "1 minute",
diff --git a/homeassistant/components/schlage/switch.py b/homeassistant/components/schlage/switch.py
index 56ff0ebe360..c40d0c41e88 100644
--- a/homeassistant/components/schlage/switch.py
+++ b/homeassistant/components/schlage/switch.py
@@ -16,7 +16,7 @@ from homeassistant.components.switch import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LockData, SchlageConfigEntry, SchlageDataUpdateCoordinator
from .entity import SchlageEntity
@@ -56,7 +56,7 @@ SWITCHES: tuple[SchlageSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SchlageConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches based on a config entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json
index 56b9470b4f7..28e08372d68 100644
--- a/homeassistant/components/scrape/manifest.json
+++ b/homeassistant/components/scrape/manifest.json
@@ -6,5 +6,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/scrape",
"iot_class": "cloud_polling",
- "requirements": ["beautifulsoup4==4.12.3", "lxml==5.3.0"]
+ "requirements": ["beautifulsoup4==4.13.3", "lxml==5.3.0"]
}
diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py
index 5ee837f32d1..b8ad9cb8a56 100644
--- a/homeassistant/components/scrape/sensor.py
+++ b/homeassistant/components/scrape/sensor.py
@@ -21,7 +21,10 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.template import Template
from homeassistant.helpers.trigger_template_entity import (
CONF_AVAILABILITY,
@@ -92,7 +95,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
entry: ScrapeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Scrape sensor entry."""
entities: list = []
diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py
index 4a178c60d81..a846a9fa4e3 100644
--- a/homeassistant/components/screenlogic/binary_sensor.py
+++ b/homeassistant/components/screenlogic/binary_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ScreenlogicDataUpdateCoordinator
from .entity import (
@@ -195,7 +195,7 @@ SUPPORTED_SCG_SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ScreenLogicConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py
index e44d9b18ae1..03aebadbba6 100644
--- a/homeassistant/components/screenlogic/climate.py
+++ b/homeassistant/components/screenlogic/climate.py
@@ -21,7 +21,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .entity import ScreenLogicPushEntity, ScreenLogicPushEntityDescription
@@ -42,7 +42,7 @@ SUPPORTED_PRESETS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ScreenLogicConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py
index 412b2df5f81..b0bd154b66d 100644
--- a/homeassistant/components/screenlogic/light.py
+++ b/homeassistant/components/screenlogic/light.py
@@ -12,7 +12,7 @@ from homeassistant.components.light import (
LightEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LIGHT_CIRCUIT_FUNCTIONS
from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription
@@ -22,7 +22,7 @@ from .types import ScreenLogicConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ScreenLogicConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
entities: list[ScreenLogicLight] = []
diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py
index 3634147e509..ea9bf8ac95d 100644
--- a/homeassistant/components/screenlogic/number.py
+++ b/homeassistant/components/screenlogic/number.py
@@ -17,7 +17,7 @@ from homeassistant.components.number import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ScreenlogicDataUpdateCoordinator
from .entity import (
@@ -104,7 +104,7 @@ SUPPORTED_SCG_NUMBERS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ScreenLogicConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
entities: list[ScreenLogicNumber] = []
diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py
index 7a5e910923c..95a7e3a5c75 100644
--- a/homeassistant/components/screenlogic/sensor.py
+++ b/homeassistant/components/screenlogic/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ScreenlogicDataUpdateCoordinator
from .entity import (
@@ -272,7 +272,7 @@ SUPPORTED_SCG_SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ScreenLogicConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py
index 1d36ee00b94..dfbb1c1781d 100644
--- a/homeassistant/components/screenlogic/switch.py
+++ b/homeassistant/components/screenlogic/switch.py
@@ -8,7 +8,7 @@ from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LIGHT_CIRCUIT_FUNCTIONS
from .entity import (
@@ -29,7 +29,7 @@ class ScreenLogicCircuitSwitchDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ScreenLogicConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
entities: list[ScreenLogicSwitchingEntity] = []
diff --git a/homeassistant/components/script/blueprints/confirmable_notification.yaml b/homeassistant/components/script/blueprints/confirmable_notification.yaml
index c5f42494f02..0106a4e16c5 100644
--- a/homeassistant/components/script/blueprints/confirmable_notification.yaml
+++ b/homeassistant/components/script/blueprints/confirmable_notification.yaml
@@ -71,11 +71,11 @@ sequence:
title: !input dismiss_text
- alias: "Awaiting response"
wait_for_trigger:
- - platform: event
+ - trigger: event
event_type: mobile_app_notification_action
event_data:
action: "{{ action_confirm }}"
- - platform: event
+ - trigger: event
event_type: mobile_app_notification_action
event_data:
action: "{{ action_dismiss }}"
diff --git a/homeassistant/components/search/manifest.json b/homeassistant/components/search/manifest.json
index cd372139451..42a54fe8b55 100644
--- a/homeassistant/components/search/manifest.json
+++ b/homeassistant/components/search/manifest.json
@@ -1,7 +1,6 @@
{
"domain": "search",
"name": "Search",
- "after_dependencies": ["scene", "group", "automation", "script"],
"codeowners": ["@home-assistant/core"],
"dependencies": ["websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/search",
diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py
index 96744db1d02..bdc24883c90 100644
--- a/homeassistant/components/season/sensor.py
+++ b/homeassistant/components/season/sensor.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from .const import DOMAIN, TYPE_ASTRONOMICAL
@@ -37,7 +37,7 @@ HEMISPHERE_SEASON_SWAP = {
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform from config entry."""
hemisphere = EQUATOR
diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py
index d06b3a62937..3bb8a32b8e4 100644
--- a/homeassistant/components/sense/binary_sensor.py
+++ b/homeassistant/components/sense/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SenseConfigEntry
from .const import DOMAIN
@@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SenseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Sense binary sensor."""
sense_monitor_id = config_entry.runtime_data.data.sense_monitor_id
diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json
index 966488b6a48..0a21dbf4cc3 100644
--- a/homeassistant/components/sense/manifest.json
+++ b/homeassistant/components/sense/manifest.json
@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/sense",
"iot_class": "cloud_polling",
"loggers": ["sense_energy"],
- "requirements": ["sense-energy==0.13.4"]
+ "requirements": ["sense-energy==0.13.7"]
}
diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py
index 2f5c82675d5..8cb4bdd3e56 100644
--- a/homeassistant/components/sense/sensor.py
+++ b/homeassistant/components/sense/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SenseConfigEntry
from .const import (
@@ -66,7 +66,7 @@ TREND_SENSOR_VARIANTS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SenseConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Sense sensor."""
data = config_entry.runtime_data.data
diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py
index a66ab46c882..c7116db7954 100644
--- a/homeassistant/components/sensibo/binary_sensor.py
+++ b/homeassistant/components/sensibo/binary_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SensiboConfigEntry
from .const import LOGGER
@@ -118,7 +118,7 @@ DESCRIPTION_BY_MODELS = {"pure": PURE_SENSOR_TYPES}
async def async_setup_entry(
hass: HomeAssistant,
entry: SensiboConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensibo binary sensor platform."""
@@ -130,9 +130,10 @@ async def async_setup_entry(
"""Handle additions of devices and sensors."""
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
nonlocal added_devices
- new_devices, remove_devices, added_devices = coordinator.get_devices(
+ new_devices, remove_devices, new_added_devices = coordinator.get_devices(
added_devices
)
+ added_devices = new_added_devices
if LOGGER.isEnabledFor(logging.DEBUG):
LOGGER.debug(
@@ -168,8 +169,7 @@ async def async_setup_entry(
device_data.model, DEVICE_SENSOR_TYPES
)
)
-
- async_add_entities(entities)
+ async_add_entities(entities)
entry.async_on_unload(coordinator.async_add_listener(_add_remove_devices))
_add_remove_devices()
diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py
index df8d4625840..d36967dae06 100644
--- a/homeassistant/components/sensibo/button.py
+++ b/homeassistant/components/sensibo/button.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SensiboConfigEntry
from .coordinator import SensiboDataUpdateCoordinator
@@ -35,7 +35,7 @@ DEVICE_BUTTON_TYPES = SensiboButtonEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: SensiboConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensibo button platform."""
@@ -46,7 +46,8 @@ async def async_setup_entry(
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
- new_devices, _, added_devices = coordinator.get_devices(added_devices)
+ new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
+ added_devices = new_added_devices
if new_devices:
async_add_entities(
diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py
index 5d1c6ff9e79..906c4259ce5 100644
--- a/homeassistant/components/sensibo/climate.py
+++ b/homeassistant/components/sensibo/climate.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, SupportsResponse
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
from . import SensiboConfigEntry
@@ -138,7 +138,7 @@ def _find_valid_target_temp(target: float, valid_targets: list[int]) -> int:
async def async_setup_entry(
hass: HomeAssistant,
entry: SensiboConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Sensibo climate entry."""
@@ -149,7 +149,8 @@ async def async_setup_entry(
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
- new_devices, _, added_devices = coordinator.get_devices(added_devices)
+ new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
+ added_devices = new_added_devices
if new_devices:
async_add_entities(
diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py
index e19f24295b9..3fa8a6e5dae 100644
--- a/homeassistant/components/sensibo/coordinator.py
+++ b/homeassistant/components/sensibo/coordinator.py
@@ -56,18 +56,31 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]):
) -> tuple[set[str], set[str], set[str]]:
"""Addition and removal of devices."""
data = self.data
- motion_sensors = {
+ current_motion_sensors = {
sensor_id
for device_data in data.parsed.values()
if device_data.motion_sensors
for sensor_id in device_data.motion_sensors
}
- devices: set[str] = set(data.parsed)
- new_devices: set[str] = motion_sensors | devices - added_devices
- remove_devices = added_devices - devices - motion_sensors
- added_devices = (added_devices - remove_devices) | new_devices
+ current_devices: set[str] = set(data.parsed)
+ LOGGER.debug(
+ "Current devices: %s, moption sensors: %s",
+ current_devices,
+ current_motion_sensors,
+ )
+ new_devices: set[str] = (
+ current_motion_sensors | current_devices
+ ) - added_devices
+ remove_devices = added_devices - current_devices - current_motion_sensors
+ new_added_devices = (added_devices - remove_devices) | new_devices
- return (new_devices, remove_devices, added_devices)
+ LOGGER.debug(
+ "New devices: %s, Removed devices: %s, Added devices: %s",
+ new_devices,
+ remove_devices,
+ new_added_devices,
+ )
+ return (new_devices, remove_devices, new_added_devices)
async def _async_update_data(self) -> SensiboData:
"""Fetch data from Sensibo."""
diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json
index e6398c5076e..610695aaf7b 100644
--- a/homeassistant/components/sensibo/manifest.json
+++ b/homeassistant/components/sensibo/manifest.json
@@ -14,5 +14,6 @@
},
"iot_class": "cloud_polling",
"loggers": ["pysensibo"],
+ "quality_scale": "platinum",
"requirements": ["pysensibo==1.1.0"]
}
diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py
index aa46c7f8c1e..e71ed6f0235 100644
--- a/homeassistant/components/sensibo/number.py
+++ b/homeassistant/components/sensibo/number.py
@@ -15,7 +15,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SensiboConfigEntry
from .coordinator import SensiboDataUpdateCoordinator
@@ -65,7 +65,7 @@ DEVICE_NUMBER_TYPES = (
async def async_setup_entry(
hass: HomeAssistant,
entry: SensiboConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensibo number platform."""
@@ -76,7 +76,8 @@ async def async_setup_entry(
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
- new_devices, _, added_devices = coordinator.get_devices(added_devices)
+ new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
+ added_devices = new_added_devices
if new_devices:
async_add_entities(
diff --git a/homeassistant/components/sensibo/quality_scale.yaml b/homeassistant/components/sensibo/quality_scale.yaml
index c21cf100e9d..3d71d0ad3ba 100644
--- a/homeassistant/components/sensibo/quality_scale.yaml
+++ b/homeassistant/components/sensibo/quality_scale.yaml
@@ -19,9 +19,9 @@ rules:
comment: |
No integrations services.
common-modules: done
- docs-high-level-description: todo
+ docs-high-level-description: done
docs-installation-instructions: done
- docs-removal-instructions: todo
+ docs-removal-instructions: done
docs-actions: done
brands: done
# Silver
@@ -39,9 +39,7 @@ rules:
comment: |
Tests are very complex and needs a rewrite for future additions
integration-owner: done
- docs-installation-parameters:
- status: todo
- comment: configuration_basic
+ docs-installation-parameters: done
docs-configuration-parameters:
status: exempt
comment: |
@@ -71,13 +69,13 @@ rules:
status: exempt
comment: |
This integration doesn't have any cases where raising an issue is needed.
- docs-use-cases: todo
- docs-supported-devices: todo
- docs-supported-functions: todo
- docs-data-update: todo
- docs-known-limitations: todo
- docs-troubleshooting: todo
- docs-examples: todo
+ docs-use-cases: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-data-update: done
+ docs-known-limitations: done
+ docs-troubleshooting: done
+ docs-examples: done
# Platinum
async-dependency: done
diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py
index 51521b59f03..5a0546b1aa2 100644
--- a/homeassistant/components/sensibo/select.py
+++ b/homeassistant/components/sensibo/select.py
@@ -17,7 +17,7 @@ from homeassistant.components.select import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import (
IssueSeverity,
async_create_issue,
@@ -67,7 +67,7 @@ DEVICE_SELECT_TYPES = (
async def async_setup_entry(
hass: HomeAssistant,
entry: SensiboConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensibo select platform."""
@@ -115,7 +115,8 @@ async def async_setup_entry(
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
- new_devices, _, added_devices = coordinator.get_devices(added_devices)
+ new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
+ added_devices = new_added_devices
if new_devices:
async_add_entities(
diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py
index b242f38febe..09f095bfaec 100644
--- a/homeassistant/components/sensibo/sensor.py
+++ b/homeassistant/components/sensibo/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import SensiboConfigEntry
@@ -240,7 +240,7 @@ DESCRIPTION_BY_MODELS = {
async def async_setup_entry(
hass: HomeAssistant,
entry: SensiboConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensibo sensor platform."""
@@ -253,9 +253,8 @@ async def async_setup_entry(
entities: list[SensiboMotionSensor | SensiboDeviceSensor] = []
nonlocal added_devices
- new_devices, remove_devices, added_devices = coordinator.get_devices(
- added_devices
- )
+ new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
+ added_devices = new_added_devices
if new_devices:
entities.extend(
diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json
index 6c5210d12bf..4dce104d1c7 100644
--- a/homeassistant/components/sensibo/strings.json
+++ b/homeassistant/components/sensibo/strings.json
@@ -115,7 +115,7 @@
"sensitivity": {
"name": "Pure sensitivity",
"state": {
- "n": "Normal",
+ "n": "[%key:common::state::normal%]",
"s": "Sensitive"
}
},
@@ -139,11 +139,11 @@
"fanlevel": {
"name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]",
"state": {
- "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]",
- "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]",
- "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]",
+ "auto": "[%key:common::state::auto%]",
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
"medium_low": "Medium low",
- "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]",
+ "medium": "[%key:common::state::medium%]",
"medium_high": "Medium high",
"strong": "Strong",
"quiet": "Quiet"
@@ -175,10 +175,10 @@
"name": "Mode",
"state": {
"off": "[%key:common::state::off%]",
+ "auto": "[%key:common::state::auto%]",
"heat": "[%key:component::climate::entity_component::_::state::heat%]",
"cool": "[%key:component::climate::entity_component::_::state::cool%]",
"heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]",
- "auto": "[%key:component::climate::entity_component::_::state::auto%]",
"dry": "[%key:component::climate::entity_component::_::state::dry%]",
"fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]"
}
@@ -225,11 +225,11 @@
"fanlevel": {
"name": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::name%]",
"state": {
- "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]",
- "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]",
- "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]",
+ "auto": "[%key:common::state::auto%]",
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
"medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]",
- "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]",
+ "medium": "[%key:common::state::medium%]",
"medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]",
"strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]",
"quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]"
@@ -261,10 +261,10 @@
"name": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::mode::name%]",
"state": {
"off": "[%key:common::state::off%]",
+ "auto": "[%key:common::state::auto%]",
"heat": "[%key:component::climate::entity_component::_::state::heat%]",
"cool": "[%key:component::climate::entity_component::_::state::cool%]",
"heat_cool": "[%key:component::climate::entity_component::_::state::heat_cool%]",
- "auto": "[%key:component::climate::entity_component::_::state::auto%]",
"dry": "[%key:component::climate::entity_component::_::state::dry%]",
"fan_only": "[%key:component::climate::entity_component::_::state::fan_only%]"
}
@@ -330,7 +330,7 @@
"timer_on_switch": {
"name": "Timer",
"state_attributes": {
- "id": { "name": "Id" },
+ "id": { "name": "ID" },
"turn_on": {
"name": "Turns on",
"state": {
@@ -364,12 +364,12 @@
"state": {
"quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]",
"strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]",
- "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]",
+ "low": "[%key:common::state::low%]",
"medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]",
- "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]",
+ "medium": "[%key:common::state::medium%]",
"medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]",
- "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]",
- "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]"
+ "high": "[%key:common::state::high%]",
+ "auto": "[%key:common::state::auto%]"
}
},
"swing_mode": {
@@ -429,16 +429,16 @@
}
},
"enable_pure_boost": {
- "name": "Enable pure boost",
+ "name": "Enable Pure Boost",
"description": "Enables and configures Pure Boost settings.",
"fields": {
"ac_integration": {
"name": "AC integration",
- "description": "Integrate with Air Conditioner."
+ "description": "Integrate with air conditioner."
},
"geo_integration": {
"name": "Geo integration",
- "description": "Integrate with Presence."
+ "description": "Integrate with presence."
},
"indoor_integration": {
"name": "Indoor air quality",
@@ -468,7 +468,7 @@
},
"fan_mode": {
"name": "Fan mode",
- "description": "set fan mode."
+ "description": "Set fan mode."
},
"swing_mode": {
"name": "Swing mode",
@@ -524,7 +524,7 @@
"selector": {
"sensitivity": {
"options": {
- "normal": "[%key:component::sensibo::entity::sensor::sensitivity::state::n%]",
+ "normal": "[%key:common::state::normal%]",
"sensitive": "[%key:component::sensibo::entity::sensor::sensitivity::state::s%]"
}
},
@@ -536,12 +536,12 @@
},
"hvac_mode": {
"options": {
+ "off": "[%key:common::state::off%]",
+ "auto": "[%key:common::state::auto%]",
"cool": "[%key:component::climate::entity_component::_::state::cool%]",
"heat": "[%key:component::climate::entity_component::_::state::heat%]",
"fan": "[%key:component::climate::entity_component::_::state::fan_only%]",
- "auto": "[%key:component::climate::entity_component::_::state::auto%]",
- "dry": "[%key:component::climate::entity_component::_::state::dry%]",
- "off": "[%key:common::state::off%]"
+ "dry": "[%key:component::climate::entity_component::_::state::dry%]"
}
},
"light_mode": {
@@ -594,7 +594,7 @@
"issues": {
"deprecated_entity_horizontalswing": {
"title": "The Sensibo {name} entity is deprecated",
- "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\n, Disable the `{entity}` and reload the config entry or restart Home Assistant to fix this issue."
+ "description": "The Sensibo entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your automations and scripts to use the `horizontal_swing` attribute part of the `climate` entity instead.\nDisable `{entity}` and reload the config entry or restart Home Assistant to fix this issue."
}
}
}
diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py
index 0bc2c55a706..03e7c12ec2b 100644
--- a/homeassistant/components/sensibo/switch.py
+++ b/homeassistant/components/sensibo/switch.py
@@ -15,7 +15,7 @@ from homeassistant.components.switch import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SensiboConfigEntry
from .const import DOMAIN
@@ -78,7 +78,7 @@ DESCRIPTION_BY_MODELS = {"pure": PURE_SWITCH_TYPES}
async def async_setup_entry(
hass: HomeAssistant,
entry: SensiboConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensibo Switch platform."""
@@ -89,7 +89,8 @@ async def async_setup_entry(
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
- new_devices, _, added_devices = coordinator.get_devices(added_devices)
+ new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
+ added_devices = new_added_devices
if new_devices:
async_add_entities(
diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py
index 0b02264b3e0..6f868e5f366 100644
--- a/homeassistant/components/sensibo/update.py
+++ b/homeassistant/components/sensibo/update.py
@@ -14,7 +14,7 @@ from homeassistant.components.update import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SensiboConfigEntry
from .coordinator import SensiboDataUpdateCoordinator
@@ -45,7 +45,7 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceUpdateEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: SensiboConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensibo Update platform."""
@@ -56,7 +56,8 @@ async def async_setup_entry(
def _add_remove_devices() -> None:
"""Handle additions of devices and sensors."""
nonlocal added_devices
- new_devices, _, added_devices = coordinator.get_devices(added_devices)
+ new_devices, _, new_added_devices = coordinator.get_devices(added_devices)
+ added_devices = new_added_devices
if new_devices:
async_add_entities(
diff --git a/homeassistant/components/sensirion_ble/sensor.py b/homeassistant/components/sensirion_ble/sensor.py
index a7254fd3609..16f7571f392 100644
--- a/homeassistant/components/sensirion_ble/sensor.py
+++ b/homeassistant/components/sensirion_ble/sensor.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
@@ -106,7 +106,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Sensirion BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py
index 89f39d4fb8c..e06ee85cd03 100644
--- a/homeassistant/components/sensor/__init__.py
+++ b/homeassistant/components/sensor/__init__.py
@@ -44,6 +44,7 @@ from .const import ( # noqa: F401
DEVICE_CLASSES_SCHEMA,
DOMAIN,
NON_NUMERIC_DEVICE_CLASSES,
+ STATE_CLASS_UNITS,
STATE_CLASSES,
STATE_CLASSES_SCHEMA,
UNIT_CONVERTERS,
@@ -675,22 +676,13 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
):
# Deduce the precision by finding the decimal point, if any
value_s = str(value)
- precision = (
- len(value_s) - value_s.index(".") - 1 if "." in value_s else 0
- )
-
# Scale the precision when converting to a larger unit
# For example 1.1 Wh should be rendered as 0.0011 kWh, not 0.0 kWh
- ratio_log = max(
- 0,
- log10(
- converter.get_unit_ratio(
- native_unit_of_measurement, unit_of_measurement
- )
- ),
+ precision = (
+ len(value_s) - value_s.index(".") - 1 if "." in value_s else 0
+ ) + converter.get_unit_floored_log_ratio(
+ native_unit_of_measurement, unit_of_measurement
)
- precision = precision + floor(ratio_log)
-
value = f"{converted_numerical_value:z.{precision}f}"
else:
value = converted_numerical_value
@@ -722,6 +714,18 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
report_issue,
)
+ # Validate unit of measurement used for sensors with a state class
+ if (
+ state_class
+ and (units := STATE_CLASS_UNITS.get(state_class)) is not None
+ and native_unit_of_measurement not in units
+ ):
+ raise ValueError(
+ f"Sensor {self.entity_id} ({type(self)}) is using native unit of "
+ f"measurement '{native_unit_of_measurement}' which is not a valid unit "
+ f"for the state class ('{state_class}') it is using; expected one of {units};"
+ )
+
return value
def _display_precision_or_none(self) -> int | None:
diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py
index c46aca548c8..c845980e9df 100644
--- a/homeassistant/components/sensor/const.py
+++ b/homeassistant/components/sensor/const.py
@@ -11,6 +11,7 @@ from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
+ DEGREE,
LIGHT_LUX,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS,
@@ -185,7 +186,7 @@ class SensorDeviceClass(StrEnum):
DURATION = "duration"
"""Fixed duration.
- Unit of measurement: `d`, `h`, `min`, `s`, `ms`
+ Unit of measurement: `d`, `h`, `min`, `s`, `ms`, `µs`
"""
ENERGY = "energy"
@@ -351,7 +352,7 @@ class SensorDeviceClass(StrEnum):
REACTIVE_POWER = "reactive_power"
"""Reactive power.
- Unit of measurement: `var`
+ Unit of measurement: `var`, `kvar`
"""
SIGNAL_STRENGTH = "signal_strength"
@@ -454,6 +455,12 @@ class SensorDeviceClass(StrEnum):
- USCS / imperial: `oz`, `lb`
"""
+ WIND_DIRECTION = "wind_direction"
+ """Wind direction.
+
+ Unit of measurement: `°`
+ """
+
WIND_SPEED = "wind_speed"
"""Wind speed.
@@ -484,6 +491,9 @@ class SensorStateClass(StrEnum):
MEASUREMENT = "measurement"
"""The state represents a measurement in present time."""
+ MEASUREMENT_ANGLE = "measurement_angle"
+ """The state represents a angle measurement in present time. Currently only degrees are supported."""
+
TOTAL = "total"
"""The state represents a total amount.
@@ -551,6 +561,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
UnitOfTime.MINUTES,
UnitOfTime.SECONDS,
UnitOfTime.MILLISECONDS,
+ UnitOfTime.MICROSECONDS,
},
SensorDeviceClass.ENERGY: set(UnitOfEnergy),
SensorDeviceClass.ENERGY_DISTANCE: set(UnitOfEnergyDistance),
@@ -575,6 +586,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.PM25: {CONCENTRATION_MICROGRAMS_PER_CUBIC_METER},
SensorDeviceClass.POWER_FACTOR: {PERCENTAGE, None},
SensorDeviceClass.POWER: {
+ UnitOfPower.MILLIWATT,
UnitOfPower.WATT,
UnitOfPower.KILO_WATT,
UnitOfPower.MEGA_WATT,
@@ -584,7 +596,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
SensorDeviceClass.PRECIPITATION: set(UnitOfPrecipitationDepth),
SensorDeviceClass.PRECIPITATION_INTENSITY: set(UnitOfVolumetricFlux),
SensorDeviceClass.PRESSURE: set(UnitOfPressure),
- SensorDeviceClass.REACTIVE_POWER: {UnitOfReactivePower.VOLT_AMPERE_REACTIVE},
+ SensorDeviceClass.REACTIVE_POWER: set(UnitOfReactivePower),
SensorDeviceClass.SIGNAL_STRENGTH: {
SIGNAL_STRENGTH_DECIBELS,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
@@ -612,6 +624,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = {
UnitOfVolume.LITERS,
},
SensorDeviceClass.WEIGHT: set(UnitOfMass),
+ SensorDeviceClass.WIND_DIRECTION: {DEGREE},
SensorDeviceClass.WIND_SPEED: set(UnitOfSpeed),
}
@@ -683,5 +696,11 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = {
SensorStateClass.TOTAL,
SensorStateClass.TOTAL_INCREASING,
},
+ SensorDeviceClass.WIND_DIRECTION: {SensorStateClass.MEASUREMENT_ANGLE},
SensorDeviceClass.WIND_SPEED: {SensorStateClass.MEASUREMENT},
}
+
+
+STATE_CLASS_UNITS: dict[SensorStateClass | str, set[type[StrEnum] | str | None]] = {
+ SensorStateClass.MEASUREMENT_ANGLE: {DEGREE},
+}
diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py
index 4a68fbabe8f..f52393f28ff 100644
--- a/homeassistant/components/sensor/device_condition.py
+++ b/homeassistant/components/sensor/device_condition.py
@@ -83,6 +83,7 @@ CONF_IS_VOLUME = "is_volume"
CONF_IS_VOLUME_FLOW_RATE = "is_volume_flow_rate"
CONF_IS_WATER = "is_water"
CONF_IS_WEIGHT = "is_weight"
+CONF_IS_WIND_DIRECTION = "is_wind_direction"
CONF_IS_WIND_SPEED = "is_wind_speed"
ENTITY_CONDITIONS = {
@@ -145,6 +146,7 @@ ENTITY_CONDITIONS = {
SensorDeviceClass.VOLUME_FLOW_RATE: [{CONF_TYPE: CONF_IS_VOLUME_FLOW_RATE}],
SensorDeviceClass.WATER: [{CONF_TYPE: CONF_IS_WATER}],
SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_IS_WEIGHT}],
+ SensorDeviceClass.WIND_DIRECTION: [{CONF_TYPE: CONF_IS_WIND_DIRECTION}],
SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_IS_WIND_SPEED}],
DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_VALUE}],
}
@@ -204,6 +206,7 @@ CONDITION_SCHEMA = vol.All(
CONF_IS_VOLUME_FLOW_RATE,
CONF_IS_WATER,
CONF_IS_WEIGHT,
+ CONF_IS_WIND_DIRECTION,
CONF_IS_WIND_SPEED,
CONF_IS_VALUE,
]
diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py
index 0003b83d05a..dee48434294 100644
--- a/homeassistant/components/sensor/device_trigger.py
+++ b/homeassistant/components/sensor/device_trigger.py
@@ -82,6 +82,7 @@ CONF_VOLUME = "volume"
CONF_VOLUME_FLOW_RATE = "volume_flow_rate"
CONF_WATER = "water"
CONF_WEIGHT = "weight"
+CONF_WIND_DIRECTION = "wind_direction"
CONF_WIND_SPEED = "wind_speed"
ENTITY_TRIGGERS = {
@@ -144,6 +145,7 @@ ENTITY_TRIGGERS = {
SensorDeviceClass.VOLUME_FLOW_RATE: [{CONF_TYPE: CONF_VOLUME_FLOW_RATE}],
SensorDeviceClass.WATER: [{CONF_TYPE: CONF_WATER}],
SensorDeviceClass.WEIGHT: [{CONF_TYPE: CONF_WEIGHT}],
+ SensorDeviceClass.WIND_DIRECTION: [{CONF_TYPE: CONF_WIND_DIRECTION}],
SensorDeviceClass.WIND_SPEED: [{CONF_TYPE: CONF_WIND_SPEED}],
DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_VALUE}],
}
@@ -204,6 +206,7 @@ TRIGGER_SCHEMA = vol.All(
CONF_VOLUME_FLOW_RATE,
CONF_WATER,
CONF_WEIGHT,
+ CONF_WIND_DIRECTION,
CONF_WIND_SPEED,
CONF_VALUE,
]
diff --git a/homeassistant/components/sensor/icons.json b/homeassistant/components/sensor/icons.json
index 5f770765ee3..497c1544b3b 100644
--- a/homeassistant/components/sensor/icons.json
+++ b/homeassistant/components/sensor/icons.json
@@ -156,6 +156,9 @@
"weight": {
"default": "mdi:weight"
},
+ "wind_direction": {
+ "default": "mdi:compass-rose"
+ },
"wind_speed": {
"default": "mdi:weather-windy"
}
diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py
index 675d24b9240..c321caa616d 100644
--- a/homeassistant/components/sensor/recorder.py
+++ b/homeassistant/components/sensor/recorder.py
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections import defaultdict
from collections.abc import Callable, Iterable
from contextlib import suppress
+from dataclasses import dataclass
import datetime
import itertools
import logging
@@ -21,6 +22,7 @@ from homeassistant.components.recorder import (
)
from homeassistant.components.recorder.models import (
StatisticData,
+ StatisticMeanType,
StatisticMetaData,
StatisticResult,
)
@@ -52,10 +54,22 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
+
+@dataclass
+class _StatisticsConfig:
+ types: set[str]
+ mean_type: StatisticMeanType = StatisticMeanType.NONE
+
+
DEFAULT_STATISTICS = {
- SensorStateClass.MEASUREMENT: {"mean", "min", "max"},
- SensorStateClass.TOTAL: {"sum"},
- SensorStateClass.TOTAL_INCREASING: {"sum"},
+ SensorStateClass.MEASUREMENT: _StatisticsConfig(
+ {"mean", "min", "max"}, StatisticMeanType.ARITHMETIC
+ ),
+ SensorStateClass.MEASUREMENT_ANGLE: _StatisticsConfig(
+ {"mean"}, StatisticMeanType.CIRCULAR
+ ),
+ SensorStateClass.TOTAL: _StatisticsConfig({"sum"}),
+ SensorStateClass.TOTAL_INCREASING: _StatisticsConfig({"sum"}),
}
EQUIVALENT_UNITS = {
@@ -76,8 +90,15 @@ WARN_NEGATIVE: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_total_increasing_nega
# Keep track of entities for which a warning about unsupported unit has been logged
WARN_UNSUPPORTED_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unsupported_unit")
WARN_UNSTABLE_UNIT: HassKey[set[str]] = HassKey(f"{DOMAIN}_warn_unstable_unit")
+# Keep track of entities for which a warning about statistics mean algorithm change has been logged
+WARN_STATISTICS_MEAN_CHANGED: HassKey[set[str]] = HassKey(
+ f"{DOMAIN}_warn_statistics_mean_change"
+)
# Link to dev statistics where issues around LTS can be fixed
LINK_DEV_STATISTICS = "https://my.home-assistant.io/redirect/developer_statistics"
+STATE_CLASS_REMOVED_ISSUE = "state_class_removed"
+UNITS_CHANGED_ISSUE = "units_changed"
+MEAN_TYPE_CHANGED_ISSUE = "mean_type_changed"
def _get_sensor_states(hass: HomeAssistant) -> list[State]:
@@ -99,7 +120,7 @@ def _get_sensor_states(hass: HomeAssistant) -> list[State]:
]
-def _time_weighted_average(
+def _time_weighted_arithmetic_mean(
fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime
) -> float:
"""Calculate a time weighted average.
@@ -134,16 +155,44 @@ def _time_weighted_average(
duration = end - old_start_time
accumulated += old_fstate * duration.total_seconds()
- period_seconds = (end - start).total_seconds()
- if period_seconds == 0:
- # If the only state changed that happened was at the exact moment
- # at the end of the period, we can't calculate a meaningful average
- # so we return 0.0 since it represents a time duration smaller than
- # we can measure. This probably means the precision of statistics
- # column schema in the database is incorrect but it is actually possible
- # to happen if the state change event fired at the exact microsecond
- return 0.0
- return accumulated / period_seconds
+ return accumulated / (end - start).total_seconds()
+
+
+def _time_weighted_circular_mean(
+ fstates: list[tuple[float, State]], start: datetime.datetime, end: datetime.datetime
+) -> tuple[float, float]:
+ """Calculate a time weighted circular mean.
+
+ The circular mean is calculated by weighting the states by duration in seconds between
+ state changes.
+ Note: there's no interpolation of values between state changes.
+ """
+ old_fstate: float | None = None
+ old_start_time: datetime.datetime | None = None
+ values: list[tuple[float, float]] = []
+
+ for fstate, state in fstates:
+ # The recorder will give us the last known state, which may be well
+ # before the requested start time for the statistics
+ start_time = max(state.last_updated, start)
+ if old_start_time is None:
+ # Adjust start time, if there was no last known state
+ start = start_time
+ else:
+ duration = (start_time - old_start_time).total_seconds()
+ assert old_fstate is not None
+ values.append((old_fstate, duration))
+
+ old_fstate = fstate
+ old_start_time = start_time
+
+ if old_fstate is not None:
+ # Add last value weighted by duration until end of the period
+ assert old_start_time is not None
+ duration = (end - old_start_time).total_seconds()
+ values.append((old_fstate, duration))
+
+ return statistics.weighted_circular_mean(values)
def _get_units(fstates: list[tuple[float, State]]) -> set[str | None]:
@@ -371,7 +420,7 @@ def reset_detected(
return fstate < 0.9 * previous_fstate
-def _wanted_statistics(sensor_states: list[State]) -> dict[str, set[str]]:
+def _wanted_statistics(sensor_states: list[State]) -> dict[str, _StatisticsConfig]:
"""Prepare a dict with wanted statistics for entities."""
return {
state.entity_id: DEFAULT_STATISTICS[state.attributes[ATTR_STATE_CLASS]]
@@ -415,7 +464,9 @@ def compile_statistics( # noqa: C901
wanted_statistics = _wanted_statistics(sensor_states)
# Get history between start and end
entities_full_history = [
- i.entity_id for i in sensor_states if "sum" in wanted_statistics[i.entity_id]
+ i.entity_id
+ for i in sensor_states
+ if "sum" in wanted_statistics[i.entity_id].types
]
history_list: dict[str, list[State]] = {}
if entities_full_history:
@@ -430,7 +481,7 @@ def compile_statistics( # noqa: C901
entities_significant_history = [
i.entity_id
for i in sensor_states
- if "sum" not in wanted_statistics[i.entity_id]
+ if "sum" not in wanted_statistics[i.entity_id].types
]
if entities_significant_history:
_history_list = history.get_full_significant_states_with_session(
@@ -447,7 +498,11 @@ def compile_statistics( # noqa: C901
entity_id = _state.entity_id
# If there are no recent state changes, the sensor's state may already be pruned
# from the recorder. Get the state from the state machine instead.
- if not (entity_history := history_list.get(entity_id, [_state])):
+ try:
+ entity_history = history_list[entity_id]
+ except KeyError:
+ entity_history = [_state] if _state.last_changed < end else []
+ if not entity_history:
continue
if not (float_states := _entity_history_to_float_and_state(entity_history)):
continue
@@ -476,7 +531,7 @@ def compile_statistics( # noqa: C901
continue
state_class: str = _state.attributes[ATTR_STATE_CLASS]
to_process.append((entity_id, statistics_unit, state_class, valid_float_states))
- if "sum" in wanted_statistics[entity_id]:
+ if "sum" in wanted_statistics[entity_id].types:
to_query.add(entity_id)
last_stats = statistics.get_latest_short_term_statistics_with_session(
@@ -488,6 +543,10 @@ def compile_statistics( # noqa: C901
state_class,
valid_float_states,
) in to_process:
+ mean_type = StatisticMeanType.NONE
+ if "mean" in wanted_statistics[entity_id].types:
+ mean_type = wanted_statistics[entity_id].mean_type
+
# Check metadata
if old_metadata := old_metadatas.get(entity_id):
if not _equivalent_units(
@@ -513,10 +572,34 @@ def compile_statistics( # noqa: C901
)
continue
+ if (
+ mean_type is not StatisticMeanType.NONE
+ and (old_mean_type := old_metadata[1]["mean_type"])
+ is not StatisticMeanType.NONE
+ and mean_type != old_mean_type
+ ):
+ if WARN_STATISTICS_MEAN_CHANGED not in hass.data:
+ hass.data[WARN_STATISTICS_MEAN_CHANGED] = set()
+ if entity_id not in hass.data[WARN_STATISTICS_MEAN_CHANGED]:
+ hass.data[WARN_STATISTICS_MEAN_CHANGED].add(entity_id)
+ _LOGGER.warning(
+ (
+ "The statistics mean algorithm for %s have changed from %s to %s."
+ " Generation of long term statistics will be suppressed"
+ " unless it changes back or go to %s to delete the old"
+ " statistics"
+ ),
+ entity_id,
+ old_mean_type.name,
+ mean_type.name,
+ LINK_DEV_STATISTICS,
+ )
+ continue
+
# Set meta data
meta: StatisticMetaData = {
- "has_mean": "mean" in wanted_statistics[entity_id],
- "has_sum": "sum" in wanted_statistics[entity_id],
+ "mean_type": mean_type,
+ "has_sum": "sum" in wanted_statistics[entity_id].types,
"name": None,
"source": RECORDER_DOMAIN,
"statistic_id": entity_id,
@@ -525,19 +608,26 @@ def compile_statistics( # noqa: C901
# Make calculations
stat: StatisticData = {"start": start}
- if "max" in wanted_statistics[entity_id]:
+ if "max" in wanted_statistics[entity_id].types:
stat["max"] = max(
*itertools.islice(zip(*valid_float_states, strict=False), 1)
)
- if "min" in wanted_statistics[entity_id]:
+ if "min" in wanted_statistics[entity_id].types:
stat["min"] = min(
*itertools.islice(zip(*valid_float_states, strict=False), 1)
)
- if "mean" in wanted_statistics[entity_id]:
- stat["mean"] = _time_weighted_average(valid_float_states, start, end)
+ match mean_type:
+ case StatisticMeanType.ARITHMETIC:
+ stat["mean"] = _time_weighted_arithmetic_mean(
+ valid_float_states, start, end
+ )
+ case StatisticMeanType.CIRCULAR:
+ stat["mean"], stat["mean_weight"] = _time_weighted_circular_mean(
+ valid_float_states, start, end
+ )
- if "sum" in wanted_statistics[entity_id]:
+ if "sum" in wanted_statistics[entity_id].types:
last_reset = old_last_reset = None
new_state = old_state = None
_sum = 0.0
@@ -661,18 +751,25 @@ def list_statistic_ids(
attributes = state.attributes
state_class = attributes[ATTR_STATE_CLASS]
provided_statistics = DEFAULT_STATISTICS[state_class]
- if statistic_type is not None and statistic_type not in provided_statistics:
+ if (
+ statistic_type is not None
+ and statistic_type not in provided_statistics.types
+ ):
continue
if (
- (has_sum := "sum" in provided_statistics)
+ (has_sum := "sum" in provided_statistics.types)
and ATTR_LAST_RESET not in attributes
and state_class == SensorStateClass.MEASUREMENT
):
continue
+ mean_type = StatisticMeanType.NONE
+ if "mean" in provided_statistics.types:
+ mean_type = provided_statistics.mean_type
+
result[entity_id] = {
- "has_mean": "mean" in provided_statistics,
+ "mean_type": mean_type,
"has_sum": has_sum,
"name": None,
"source": RECORDER_DOMAIN,
@@ -702,7 +799,7 @@ def _update_issues(
if numeric and state_class is None:
# Sensor no longer has a valid state class
report_issue(
- "state_class_removed",
+ STATE_CLASS_REMOVED_ISSUE,
entity_id,
{"statistic_id": entity_id},
)
@@ -713,7 +810,7 @@ def _update_issues(
if numeric and not _equivalent_units({state_unit, metadata_unit}):
# The unit has changed, and it's not possible to convert
report_issue(
- "units_changed",
+ UNITS_CHANGED_ISSUE,
entity_id,
{
"statistic_id": entity_id,
@@ -727,7 +824,7 @@ def _update_issues(
valid_units = (unit or "" for unit in converter.VALID_UNITS)
valid_units_str = ", ".join(sorted(valid_units))
report_issue(
- "units_changed",
+ UNITS_CHANGED_ISSUE,
entity_id,
{
"statistic_id": entity_id,
@@ -737,6 +834,23 @@ def _update_issues(
},
)
+ if (
+ (metadata_mean_type := metadata[1]["mean_type"]) is not None
+ and state_class
+ and (state_mean_type := DEFAULT_STATISTICS[state_class].mean_type)
+ != metadata_mean_type
+ ):
+ # The mean type has changed and the old statistics are not valid anymore
+ report_issue(
+ MEAN_TYPE_CHANGED_ISSUE,
+ entity_id,
+ {
+ "statistic_id": entity_id,
+ "metadata_mean_type": metadata_mean_type,
+ "state_mean_type": state_mean_type,
+ },
+ )
+
def update_statistics_issues(
hass: HomeAssistant,
@@ -759,7 +873,11 @@ def update_statistics_issues(
issue.domain != DOMAIN
or not (issue_data := issue.data)
or issue_data.get("issue_type")
- not in ("state_class_removed", "units_changed")
+ not in (
+ STATE_CLASS_REMOVED_ISSUE,
+ UNITS_CHANGED_ISSUE,
+ MEAN_TYPE_CHANGED_ISSUE,
+ )
):
continue
issues.add(issue.issue_id)
diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json
index dcbb4d3c826..123c30da72e 100644
--- a/homeassistant/components/sensor/strings.json
+++ b/homeassistant/components/sensor/strings.json
@@ -52,6 +52,7 @@
"is_volume_flow_rate": "Current {entity_name} volume flow rate",
"is_water": "Current {entity_name} water",
"is_weight": "Current {entity_name} weight",
+ "is_wind_direction": "Current {entity_name} wind direction",
"is_wind_speed": "Current {entity_name} wind speed"
},
"trigger_type": {
@@ -105,6 +106,7 @@
"volume_flow_rate": "{entity_name} volume flow rate changes",
"water": "{entity_name} water changes",
"weight": "{entity_name} weight changes",
+ "wind_direction": "{entity_name} wind direction changes",
"wind_speed": "{entity_name} wind speed changes"
},
"extra_fields": {
@@ -276,10 +278,10 @@
"name": "Timestamp"
},
"volatile_organic_compounds": {
- "name": "VOCs"
+ "name": "Volatile organic compounds"
},
"volatile_organic_compounds_parts": {
- "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]"
+ "name": "Volatile organic compounds parts"
},
"voltage": {
"name": "Voltage"
@@ -299,11 +301,18 @@
"weight": {
"name": "Weight"
},
+ "wind_direction": {
+ "name": "Wind direction"
+ },
"wind_speed": {
"name": "Wind speed"
}
},
"issues": {
+ "mean_type_changed": {
+ "title": "The mean type of {statistic_id} has changed",
+ "description": ""
+ },
"state_class_removed": {
"title": "{statistic_id} no longer has a state class",
"description": ""
diff --git a/homeassistant/components/sensorpro/sensor.py b/homeassistant/components/sensorpro/sensor.py
index b972aac04fb..997fa0db995 100644
--- a/homeassistant/components/sensorpro/sensor.py
+++ b/homeassistant/components/sensorpro/sensor.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
@@ -111,7 +111,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SensorPro BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/sensorpush/sensor.py b/homeassistant/components/sensorpush/sensor.py
index 6eea5c10f78..730277350b5 100644
--- a/homeassistant/components/sensorpush/sensor.py
+++ b/homeassistant/components/sensorpush/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from . import SensorPushConfigEntry
@@ -97,7 +97,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: SensorPushConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SensorPush BLE sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/sensorpush_cloud/__init__.py b/homeassistant/components/sensorpush_cloud/__init__.py
new file mode 100644
index 00000000000..2d9d299c132
--- /dev/null
+++ b/homeassistant/components/sensorpush_cloud/__init__.py
@@ -0,0 +1,28 @@
+"""The SensorPush Cloud integration."""
+
+from __future__ import annotations
+
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+from .coordinator import SensorPushCloudConfigEntry, SensorPushCloudCoordinator
+
+PLATFORMS: list[Platform] = [Platform.SENSOR]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: SensorPushCloudConfigEntry
+) -> bool:
+ """Set up SensorPush Cloud from a config entry."""
+ coordinator = SensorPushCloudCoordinator(hass, entry)
+ entry.runtime_data = coordinator
+ await coordinator.async_config_entry_first_refresh()
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistant, entry: SensorPushCloudConfigEntry
+) -> bool:
+ """Unload a config entry."""
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/sensorpush_cloud/config_flow.py b/homeassistant/components/sensorpush_cloud/config_flow.py
new file mode 100644
index 00000000000..d06fde2eba1
--- /dev/null
+++ b/homeassistant/components/sensorpush_cloud/config_flow.py
@@ -0,0 +1,64 @@
+"""Config flow for the SensorPush Cloud integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from sensorpush_ha import SensorPushCloudApi, SensorPushCloudAuthError
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.selector import (
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
+
+from .const import DOMAIN, LOGGER
+
+
+class SensorPushCloudConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for SensorPush Cloud."""
+
+ 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:
+ email, password = user_input[CONF_EMAIL], user_input[CONF_PASSWORD]
+ await self.async_set_unique_id(email)
+ self._abort_if_unique_id_configured()
+ clientsession = async_get_clientsession(self.hass)
+ api = SensorPushCloudApi(email, password, clientsession)
+ try:
+ await api.async_authorize()
+ except SensorPushCloudAuthError:
+ errors["base"] = "invalid_auth"
+ except Exception: # noqa: BLE001
+ LOGGER.exception("Unexpected error")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(title=email, data=user_input)
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_EMAIL): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.EMAIL, autocomplete="username"
+ )
+ ),
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ autocomplete="current-password",
+ )
+ ),
+ }
+ ),
+ errors=errors,
+ )
diff --git a/homeassistant/components/sensorpush_cloud/const.py b/homeassistant/components/sensorpush_cloud/const.py
new file mode 100644
index 00000000000..9e66dacfaba
--- /dev/null
+++ b/homeassistant/components/sensorpush_cloud/const.py
@@ -0,0 +1,12 @@
+"""Constants for the SensorPush Cloud integration."""
+
+from datetime import timedelta
+import logging
+from typing import Final
+
+LOGGER = logging.getLogger(__package__)
+
+DOMAIN: Final = "sensorpush_cloud"
+
+UPDATE_INTERVAL: Final = timedelta(seconds=60)
+MAX_TIME_BETWEEN_UPDATES: Final = UPDATE_INTERVAL * 60
diff --git a/homeassistant/components/sensorpush_cloud/coordinator.py b/homeassistant/components/sensorpush_cloud/coordinator.py
new file mode 100644
index 00000000000..9885538b55a
--- /dev/null
+++ b/homeassistant/components/sensorpush_cloud/coordinator.py
@@ -0,0 +1,45 @@
+"""Coordinator for the SensorPush Cloud integration."""
+
+from __future__ import annotations
+
+from sensorpush_ha import (
+ SensorPushCloudApi,
+ SensorPushCloudData,
+ SensorPushCloudError,
+ SensorPushCloudHelper,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import LOGGER, UPDATE_INTERVAL
+
+type SensorPushCloudConfigEntry = ConfigEntry[SensorPushCloudCoordinator]
+
+
+class SensorPushCloudCoordinator(DataUpdateCoordinator[dict[str, SensorPushCloudData]]):
+ """SensorPush Cloud coordinator."""
+
+ def __init__(self, hass: HomeAssistant, entry: SensorPushCloudConfigEntry) -> None:
+ """Initialize the coordinator."""
+ super().__init__(
+ hass,
+ LOGGER,
+ name=entry.title,
+ update_interval=UPDATE_INTERVAL,
+ config_entry=entry,
+ )
+ email, password = entry.data[CONF_EMAIL], entry.data[CONF_PASSWORD]
+ clientsession = async_get_clientsession(hass)
+ api = SensorPushCloudApi(email, password, clientsession)
+ self.helper = SensorPushCloudHelper(api)
+
+ async def _async_update_data(self) -> dict[str, SensorPushCloudData]:
+ """Fetch data from API endpoints."""
+ try:
+ return await self.helper.async_get_data()
+ except SensorPushCloudError as e:
+ raise UpdateFailed(e) from e
diff --git a/homeassistant/components/sensorpush_cloud/manifest.json b/homeassistant/components/sensorpush_cloud/manifest.json
new file mode 100644
index 00000000000..6fd6513ad2d
--- /dev/null
+++ b/homeassistant/components/sensorpush_cloud/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "sensorpush_cloud",
+ "name": "SensorPush Cloud",
+ "codeowners": ["@sstallion"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/sensorpush_cloud",
+ "iot_class": "cloud_polling",
+ "loggers": ["sensorpush_api", "sensorpush_ha"],
+ "quality_scale": "bronze",
+ "requirements": ["sensorpush-api==2.1.2", "sensorpush-ha==1.3.2"]
+}
diff --git a/homeassistant/components/sensorpush_cloud/quality_scale.yaml b/homeassistant/components/sensorpush_cloud/quality_scale.yaml
new file mode 100644
index 00000000000..96816e1d50d
--- /dev/null
+++ b/homeassistant/components/sensorpush_cloud/quality_scale.yaml
@@ -0,0 +1,68 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration does not register 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: Integration does not register custom actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ 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: Integration does not register custom actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: Integration does not support options flow.
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: todo
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: todo
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/sensorpush_cloud/sensor.py b/homeassistant/components/sensorpush_cloud/sensor.py
new file mode 100644
index 00000000000..d2855f63a62
--- /dev/null
+++ b/homeassistant/components/sensorpush_cloud/sensor.py
@@ -0,0 +1,158 @@
+"""Support for SensorPush Cloud sensors."""
+
+from __future__ import annotations
+
+from typing import Final
+
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
+from homeassistant.const import (
+ ATTR_TEMPERATURE,
+ PERCENTAGE,
+ SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ UnitOfElectricPotential,
+ UnitOfLength,
+ UnitOfPressure,
+ UnitOfTemperature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.typing import StateType
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from homeassistant.util import dt as dt_util
+
+from .const import DOMAIN, MAX_TIME_BETWEEN_UPDATES
+from .coordinator import SensorPushCloudConfigEntry, SensorPushCloudCoordinator
+
+ATTR_ALTITUDE: Final = "altitude"
+ATTR_ATMOSPHERIC_PRESSURE: Final = "atmospheric_pressure"
+ATTR_BATTERY_VOLTAGE: Final = "battery_voltage"
+ATTR_DEWPOINT: Final = "dewpoint"
+ATTR_HUMIDITY: Final = "humidity"
+ATTR_SIGNAL_STRENGTH: Final = "signal_strength"
+ATTR_VAPOR_PRESSURE: Final = "vapor_pressure"
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
+SENSORS: Final[tuple[SensorEntityDescription, ...]] = (
+ SensorEntityDescription(
+ key=ATTR_ALTITUDE,
+ device_class=SensorDeviceClass.DISTANCE,
+ entity_registry_enabled_default=False,
+ translation_key="altitude",
+ native_unit_of_measurement=UnitOfLength.FEET,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key=ATTR_ATMOSPHERIC_PRESSURE,
+ device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=UnitOfPressure.INHG,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key=ATTR_BATTERY_VOLTAGE,
+ device_class=SensorDeviceClass.VOLTAGE,
+ entity_registry_enabled_default=False,
+ translation_key="battery_voltage",
+ native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key=ATTR_DEWPOINT,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ entity_registry_enabled_default=False,
+ translation_key="dewpoint",
+ native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key=ATTR_HUMIDITY,
+ device_class=SensorDeviceClass.HUMIDITY,
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key=ATTR_SIGNAL_STRENGTH,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ entity_registry_enabled_default=False,
+ native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key=ATTR_TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ SensorEntityDescription(
+ key=ATTR_VAPOR_PRESSURE,
+ device_class=SensorDeviceClass.PRESSURE,
+ entity_registry_enabled_default=False,
+ translation_key="vapor_pressure",
+ native_unit_of_measurement=UnitOfPressure.KPA,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SensorPushCloudConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up SensorPush Cloud sensors."""
+ coordinator = entry.runtime_data
+ async_add_entities(
+ SensorPushCloudSensor(coordinator, entity_description, device_id)
+ for entity_description in SENSORS
+ for device_id in coordinator.data
+ )
+
+
+class SensorPushCloudSensor(
+ CoordinatorEntity[SensorPushCloudCoordinator], SensorEntity
+):
+ """SensorPush Cloud sensor."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self,
+ coordinator: SensorPushCloudCoordinator,
+ entity_description: SensorEntityDescription,
+ device_id: str,
+ ) -> None:
+ """Initialize the sensor."""
+ super().__init__(coordinator)
+ self.entity_description = entity_description
+ self.device_id = device_id
+
+ device = coordinator.data[device_id]
+ self._attr_unique_id = f"{device.device_id}_{entity_description.key}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, device.device_id)},
+ manufacturer=device.manufacturer,
+ model=device.model,
+ name=device.name,
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return true if entity is available."""
+ if self.device_id in self.coordinator.data:
+ last_update = self.coordinator.data[self.device_id].last_update
+ if dt_util.utcnow() >= (last_update + MAX_TIME_BETWEEN_UPDATES):
+ return False
+ return super().available
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the value reported by the sensor."""
+ return self.coordinator.data[self.device_id][self.entity_description.key]
diff --git a/homeassistant/components/sensorpush_cloud/strings.json b/homeassistant/components/sensorpush_cloud/strings.json
new file mode 100644
index 00000000000..8467a123b6f
--- /dev/null
+++ b/homeassistant/components/sensorpush_cloud/strings.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "To activate API access, log in to the [Gateway Cloud Dashboard](https://dashboard.sensorpush.com/) and agree to the terms of service. Devices are not available until activated with the SensorPush app on iOS or Android.",
+ "data": {
+ "email": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "email": "The email address used to log in to the SensorPush Gateway Cloud Dashboard",
+ "password": "The password used to log in to the SensorPush Gateway Cloud Dashboard"
+ }
+ }
+ },
+ "error": {
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+ }
+ },
+ "entity": {
+ "sensor": {
+ "altitude": {
+ "name": "Altitude"
+ },
+ "battery_voltage": {
+ "name": "Battery voltage"
+ },
+ "dewpoint": {
+ "name": "Dew point"
+ },
+ "vapor_pressure": {
+ "name": "Vapor pressure"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/sensoterra/sensor.py b/homeassistant/components/sensoterra/sensor.py
index a32fe3d98c9..56f47ade212 100644
--- a/homeassistant/components/sensoterra/sensor.py
+++ b/homeassistant/components/sensoterra/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -84,7 +84,7 @@ SENSORS: dict[ProbeSensorType, SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: SensoterraConfigEntry,
- async_add_devices: AddEntitiesCallback,
+ async_add_devices: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sensoterra sensor."""
diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json
index 425225e07ef..4c3a7518085 100644
--- a/homeassistant/components/sentry/manifest.json
+++ b/homeassistant/components/sentry/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sentry",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["sentry-sdk==1.40.3"]
+ "requirements": ["sentry-sdk==1.45.1"]
}
diff --git a/homeassistant/components/sentry/strings.json b/homeassistant/components/sentry/strings.json
index efcdb631f3c..22f7b355e0e 100644
--- a/homeassistant/components/sentry/strings.json
+++ b/homeassistant/components/sentry/strings.json
@@ -24,7 +24,7 @@
"event_handled": "Send handled events",
"event_third_party_packages": "Send events from third-party packages",
"logging_event_level": "The log level Sentry will register an event for",
- "logging_level": "The log level Sentry will record logs as breadcrums for",
+ "logging_level": "The log level Sentry will record events as breadcrumbs for",
"tracing": "Enable performance tracing",
"tracing_sample_rate": "Tracing sample rate; between 0.0 and 1.0 (1.0 = 100%)"
}
diff --git a/homeassistant/components/senz/climate.py b/homeassistant/components/senz/climate.py
index d5749a3f040..48eeee54974 100644
--- a/homeassistant/components/senz/climate.py
+++ b/homeassistant/components/senz/climate.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SENZDataUpdateCoordinator
@@ -26,7 +26,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SENZ climate entities from a config entry."""
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json
index cfe9196f596..2a5d3c78737 100644
--- a/homeassistant/components/serial/manifest.json
+++ b/homeassistant/components/serial/manifest.json
@@ -4,5 +4,5 @@
"codeowners": ["@fabaff"],
"documentation": "https://www.home-assistant.io/integrations/serial",
"iot_class": "local_polling",
- "requirements": ["pyserial-asyncio-fast==0.14"]
+ "requirements": ["pyserial-asyncio-fast==0.16"]
}
diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json
index cdc3b16f95d..6107a6057d1 100644
--- a/homeassistant/components/seven_segments/manifest.json
+++ b/homeassistant/components/seven_segments/manifest.json
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/seven_segments",
"iot_class": "local_polling",
"quality_scale": "legacy",
- "requirements": ["Pillow==11.1.0"]
+ "requirements": ["Pillow==11.2.1"]
}
diff --git a/homeassistant/components/seventeentrack/icons.json b/homeassistant/components/seventeentrack/icons.json
index a5cac0a9f84..c48e147e973 100644
--- a/homeassistant/components/seventeentrack/icons.json
+++ b/homeassistant/components/seventeentrack/icons.json
@@ -19,7 +19,7 @@
"delivered": {
"default": "mdi:package"
},
- "returned": {
+ "alert": {
"default": "mdi:package"
},
"package": {
diff --git a/homeassistant/components/seventeentrack/manifest.json b/homeassistant/components/seventeentrack/manifest.json
index a130fbe9aee..34019208a14 100644
--- a/homeassistant/components/seventeentrack/manifest.json
+++ b/homeassistant/components/seventeentrack/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["pyseventeentrack"],
- "requirements": ["pyseventeentrack==1.0.1"]
+ "requirements": ["pyseventeentrack==1.0.2"]
}
diff --git a/homeassistant/components/seventeentrack/sensor.py b/homeassistant/components/seventeentrack/sensor.py
index dade9efb67c..c6fd7942655 100644
--- a/homeassistant/components/seventeentrack/sensor.py
+++ b/homeassistant/components/seventeentrack/sensor.py
@@ -2,33 +2,22 @@
from __future__ import annotations
-from typing import Any
-
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SeventeenTrackCoordinator
-from .const import (
- ATTR_INFO_TEXT,
- ATTR_PACKAGES,
- ATTR_STATUS,
- ATTR_TIMESTAMP,
- ATTR_TRACKING_NUMBER,
- ATTRIBUTION,
- DOMAIN,
-)
+from .const import ATTRIBUTION, DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a 17Track sensor entry."""
@@ -81,22 +70,3 @@ class SeventeenTrackSummarySensor(SeventeenTrackSensor):
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.coordinator.data.summary[self._status]["quantity"]
-
- # This has been deprecated in 2024.8, will be removed in 2025.2
- @property
- def extra_state_attributes(self) -> dict[str, Any] | None:
- """Return the state attributes."""
- packages = self.coordinator.data.summary[self._status]["packages"]
- return {
- ATTR_PACKAGES: [
- {
- ATTR_TRACKING_NUMBER: package.tracking_number,
- ATTR_LOCATION: package.location,
- ATTR_STATUS: package.status,
- ATTR_TIMESTAMP: package.timestamp,
- ATTR_INFO_TEXT: package.info_text,
- ATTR_FRIENDLY_NAME: package.friendly_name,
- }
- for package in packages
- ]
- }
diff --git a/homeassistant/components/seventeentrack/services.yaml b/homeassistant/components/seventeentrack/services.yaml
index d4592dc8aab..45d7c0a530a 100644
--- a/homeassistant/components/seventeentrack/services.yaml
+++ b/homeassistant/components/seventeentrack/services.yaml
@@ -11,7 +11,7 @@ get_packages:
- "ready_to_be_picked_up"
- "undelivered"
- "delivered"
- - "returned"
+ - "alert"
translation_key: package_state
config_entry_id:
required: true
diff --git a/homeassistant/components/seventeentrack/strings.json b/homeassistant/components/seventeentrack/strings.json
index 982b15ab629..c95a553ae7b 100644
--- a/homeassistant/components/seventeentrack/strings.json
+++ b/homeassistant/components/seventeentrack/strings.json
@@ -57,8 +57,8 @@
"delivered": {
"name": "Delivered"
},
- "returned": {
- "name": "Returned"
+ "alert": {
+ "name": "Alert"
},
"package": {
"name": "Package {name}"
@@ -68,7 +68,7 @@
"services": {
"get_packages": {
"name": "Get packages",
- "description": "Get packages from 17Track",
+ "description": "Queries the 17track API for the latest package data.",
"fields": {
"package_state": {
"name": "Package states",
@@ -82,7 +82,7 @@
},
"archive_package": {
"name": "Archive package",
- "description": "Archive a package",
+ "description": "Archives a package using the 17track API.",
"fields": {
"package_tracking_number": {
"name": "Package tracking number",
@@ -104,7 +104,7 @@
"ready_to_be_picked_up": "[%key:component::seventeentrack::entity::sensor::ready_to_be_picked_up::name%]",
"undelivered": "[%key:component::seventeentrack::entity::sensor::undelivered::name%]",
"delivered": "[%key:component::seventeentrack::entity::sensor::delivered::name%]",
- "returned": "[%key:component::seventeentrack::entity::sensor::returned::name%]"
+ "alert": "[%key:component::seventeentrack::entity::sensor::alert::name%]"
}
}
}
diff --git a/homeassistant/components/sfr_box/binary_sensor.py b/homeassistant/components/sfr_box/binary_sensor.py
index 4ef5e87761d..de40291b0b6 100644
--- a/homeassistant/components/sfr_box/binary_sensor.py
+++ b/homeassistant/components/sfr_box/binary_sensor.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -62,7 +62,9 @@ WAN_SENSOR_TYPES: tuple[SFRBoxBinarySensorEntityDescription[WanInfo], ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors."""
data: DomainData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/sfr_box/button.py b/homeassistant/components/sfr_box/button.py
index bddb1e8f926..9798602ef6b 100644
--- a/homeassistant/components/sfr_box/button.py
+++ b/homeassistant/components/sfr_box/button.py
@@ -21,7 +21,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .models import DomainData
@@ -65,7 +65,9 @@ BUTTON_TYPES: tuple[SFRBoxButtonEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the buttons."""
data: DomainData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/sfr_box/sensor.py b/homeassistant/components/sfr_box/sensor.py
index ee3285a8f38..8b495da56c3 100644
--- a/homeassistant/components/sfr_box/sensor.py
+++ b/homeassistant/components/sfr_box/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -123,7 +123,7 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = (
entity_registry_enabled_default=False,
options=[
"no_defect",
- "of_frame",
+ "loss_of_frame",
"loss_of_signal",
"loss_of_power",
"loss_of_signal_quality",
@@ -217,7 +217,9 @@ def _get_temperature(value: float | None) -> float | None:
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors."""
data: DomainData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/sfr_box/strings.json b/homeassistant/components/sfr_box/strings.json
index 6f0001e97ce..35e9b1869ff 100644
--- a/homeassistant/components/sfr_box/strings.json
+++ b/homeassistant/components/sfr_box/strings.json
@@ -64,11 +64,11 @@
"dsl_line_status": {
"name": "DSL line status",
"state": {
- "no_defect": "No Defect",
- "of_frame": "Of Frame",
- "loss_of_signal": "Loss Of Signal",
- "loss_of_power": "Loss Of Power",
- "loss_of_signal_quality": "Loss Of Signal Quality",
+ "no_defect": "No defect",
+ "loss_of_frame": "Loss of frame",
+ "loss_of_signal": "Loss of signal",
+ "loss_of_power": "Loss of power",
+ "loss_of_signal_quality": "Loss of signal quality",
"unknown": "Unknown"
}
},
diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json
index 0e07dd96902..9f9009693e5 100644
--- a/homeassistant/components/sharkiq/manifest.json
+++ b/homeassistant/components/sharkiq/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/sharkiq",
"iot_class": "cloud_polling",
"loggers": ["sharkiq"],
- "requirements": ["sharkiq==1.0.2"]
+ "requirements": ["sharkiq==1.1.0"]
}
diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json
index 3c4c98db38f..33826baaf5b 100644
--- a/homeassistant/components/sharkiq/strings.json
+++ b/homeassistant/components/sharkiq/strings.json
@@ -3,7 +3,7 @@
"flow_title": "Add Shark IQ account",
"step": {
"user": {
- "description": "Sign into your SharkClean account to control your devices.",
+ "description": "Sign in to your SharkClean account to control your devices.",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py
index 332d95b0a3e..daea195a770 100644
--- a/homeassistant/components/sharkiq/vacuum.py
+++ b/homeassistant/components/sharkiq/vacuum.py
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, LOGGER, SERVICE_CLEAN_ROOM, SHARK
@@ -50,7 +50,7 @@ ATTR_ROOMS = "rooms"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Shark IQ vacuum cleaner."""
coordinator: SharkIqUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py
index 5ca58ec7d01..ee28c41f18b 100644
--- a/homeassistant/components/shelly/__init__.py
+++ b/homeassistant/components/shelly/__init__.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from typing import Final
+from aioshelly.ble.const import BLE_SCRIPT_NAME
from aioshelly.block_device import BlockDevice
from aioshelly.common import ConnectionOptions
from aioshelly.const import DEFAULT_COAP_PORT, RPC_GENERATIONS
@@ -11,12 +12,19 @@ from aioshelly.exceptions import (
DeviceConnectionError,
InvalidAuthError,
MacAddressMismatchError,
+ RpcCallError,
)
-from aioshelly.rpc_device import RpcDevice
+from aioshelly.rpc_device import RpcDevice, bluetooth_mac_from_primary_mac
import voluptuous as vol
from homeassistant.components.bluetooth import async_remove_scanner
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_MODEL,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ Platform,
+)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import (
@@ -53,6 +61,7 @@ from .utils import (
get_coap_context,
get_device_entry_gen,
get_http_port,
+ get_rpc_scripts_event_types,
get_ws_context,
)
@@ -102,6 +111,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> bool:
"""Set up Shelly from a config entry."""
+ entry.runtime_data = ShellyEntryData([])
+
# The custom component for Shelly devices uses shelly domain as well as core
# integration. If the user removes the custom component but doesn't remove the
# config entry, core integration will try to configure that config entry with an
@@ -153,13 +164,14 @@ async def _async_setup_block_entry(
device_entry = None
sleep_period = entry.data.get(CONF_SLEEP_PERIOD)
- runtime_data = entry.runtime_data = ShellyEntryData(BLOCK_SLEEPING_PLATFORMS)
+ runtime_data = entry.runtime_data
+ runtime_data.platforms = BLOCK_SLEEPING_PLATFORMS
# Some old firmware have a wrong sleep period hardcoded value.
# Following code block will force the right value for affected devices
if (
sleep_period == BLOCK_WRONG_SLEEP_PERIOD
- and entry.data["model"] in MODELS_WITH_WRONG_SLEEP_PERIOD
+ and entry.data[CONF_MODEL] in MODELS_WITH_WRONG_SLEEP_PERIOD
):
LOGGER.warning(
"Updating stored sleep period for %s: from %s to %s",
@@ -180,13 +192,25 @@ async def _async_setup_block_entry(
if not device.firmware_supported:
async_create_issue_unsupported_firmware(hass, entry)
await device.shutdown()
- raise ConfigEntryNotReady
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="firmware_unsupported",
+ translation_placeholders={"device": entry.title},
+ )
except (DeviceConnectionError, MacAddressMismatchError) as err:
await device.shutdown()
- raise ConfigEntryNotReady(repr(err)) from err
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="device_communication_error",
+ translation_placeholders={"device": entry.title},
+ ) from err
except InvalidAuthError as err:
await device.shutdown()
- raise ConfigEntryAuthFailed(repr(err)) from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_error",
+ translation_placeholders={"device": entry.title},
+ ) from err
runtime_data.block = ShellyBlockCoordinator(hass, entry, device)
runtime_data.block.async_setup()
@@ -252,7 +276,8 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
device_entry = None
sleep_period = entry.data.get(CONF_SLEEP_PERIOD)
- runtime_data = entry.runtime_data = ShellyEntryData(RPC_SLEEPING_PLATFORMS)
+ runtime_data = entry.runtime_data
+ runtime_data.platforms = RPC_SLEEPING_PLATFORMS
if sleep_period == 0:
# Not a sleeping device, finish setup
@@ -263,13 +288,30 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry)
if not device.firmware_supported:
async_create_issue_unsupported_firmware(hass, entry)
await device.shutdown()
- raise ConfigEntryNotReady
- except (DeviceConnectionError, MacAddressMismatchError) as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="firmware_unsupported",
+ translation_placeholders={"device": entry.title},
+ )
+ runtime_data.rpc_supports_scripts = await device.supports_scripts()
+ if runtime_data.rpc_supports_scripts:
+ runtime_data.rpc_script_events = await get_rpc_scripts_event_types(
+ device, ignore_scripts=[BLE_SCRIPT_NAME]
+ )
+ except (DeviceConnectionError, MacAddressMismatchError, RpcCallError) as err:
await device.shutdown()
- raise ConfigEntryNotReady(repr(err)) from err
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="device_communication_error",
+ translation_placeholders={"device": entry.title},
+ ) from err
except InvalidAuthError as err:
await device.shutdown()
- raise ConfigEntryAuthFailed(repr(err)) from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="auth_error",
+ translation_placeholders={"device": entry.title},
+ ) from err
runtime_data.rpc = ShellyRpcCoordinator(hass, entry, device)
runtime_data.rpc.async_setup()
@@ -339,4 +381,5 @@ async def async_remove_entry(hass: HomeAssistant, entry: ShellyConfigEntry) -> N
if get_device_entry_gen(entry) in RPC_GENERATIONS and (
mac_address := entry.unique_id
):
- async_remove_scanner(hass, mac_address)
+ source = dr.format_mac(bluetooth_mac_from_primary_mac(mac_address)).upper()
+ async_remove_scanner(hass, source)
diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py
index fb253c682d8..b74578f1fb3 100644
--- a/homeassistant/components/shelly/binary_sensor.py
+++ b/homeassistant/components/shelly/binary_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import CONF_SLEEP_PERIOD
@@ -130,6 +130,7 @@ SENSORS: dict[tuple[str, str], BlockBinarySensorDescription] = {
device_class=BinarySensorDeviceClass.GAS,
translation_key="gas",
value=lambda value: value in ["mild", "heavy"],
+ # Deprecated, remove in 2025.10
extra_state_attributes=lambda block: {"detected": block.gas},
),
("sensor", "smoke"): BlockBinarySensorDescription(
@@ -290,7 +291,7 @@ RPC_SENSORS: Final = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for device."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
diff --git a/homeassistant/components/shelly/bluetooth/__init__.py b/homeassistant/components/shelly/bluetooth/__init__.py
index d7eb020d671..2b772bd1b78 100644
--- a/homeassistant/components/shelly/bluetooth/__init__.py
+++ b/homeassistant/components/shelly/bluetooth/__init__.py
@@ -7,15 +7,22 @@ from typing import TYPE_CHECKING
from aioshelly.ble import async_start_scanner, create_scanner
from aioshelly.ble.const import BLE_SCAN_RESULT_EVENT, BLE_SCAN_RESULT_VERSION
-from homeassistant.components.bluetooth import async_register_scanner
+from homeassistant.components.bluetooth import (
+ BluetoothScanningMode,
+ async_register_scanner,
+)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback as hass_callback
-from homeassistant.helpers.device_registry import format_mac
from ..const import BLEScannerMode
if TYPE_CHECKING:
from ..coordinator import ShellyRpcCoordinator
+BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE = {
+ BLEScannerMode.PASSIVE: BluetoothScanningMode.PASSIVE,
+ BLEScannerMode.ACTIVE: BluetoothScanningMode.ACTIVE,
+}
+
async def async_connect_scanner(
hass: HomeAssistant,
@@ -26,8 +33,13 @@ async def async_connect_scanner(
"""Connect scanner."""
device = coordinator.device
entry = coordinator.config_entry
- source = format_mac(coordinator.mac).upper()
- scanner = create_scanner(source, entry.title)
+ bluetooth_scanning_mode = BLE_SCANNER_MODE_TO_BLUETOOTH_SCANNING_MODE[scanner_mode]
+ scanner = create_scanner(
+ coordinator.bluetooth_source,
+ entry.title,
+ requested_mode=bluetooth_scanning_mode,
+ current_mode=bluetooth_scanning_mode,
+ )
unload_callbacks = [
async_register_scanner(
hass,
diff --git a/homeassistant/components/shelly/button.py b/homeassistant/components/shelly/button.py
index f1e2f8ef885..06dffba5ead 100644
--- a/homeassistant/components/shelly/button.py
+++ b/homeassistant/components/shelly/button.py
@@ -2,12 +2,13 @@
from __future__ import annotations
-from collections.abc import Callable, Coroutine
+from collections.abc import Callable
from dataclasses import dataclass
from functools import partial
from typing import TYPE_CHECKING, Any, Final
-from aioshelly.const import RPC_GENERATIONS
+from aioshelly.const import BLU_TRV_IDENTIFIER, MODEL_BLU_GATEWAY, RPC_GENERATIONS
+from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -16,15 +17,20 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.device_registry import (
+ CONNECTION_BLUETOOTH,
+ CONNECTION_NETWORK_MAC,
+ DeviceInfo,
+)
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
-from .const import LOGGER, SHELLY_GAS_MODELS
+from .const import DOMAIN, LOGGER, SHELLY_GAS_MODELS
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
-from .utils import get_device_entry_gen
+from .utils import get_device_entry_gen, get_rpc_key_ids
@dataclass(frozen=True, kw_only=True)
@@ -33,7 +39,7 @@ class ShellyButtonDescription[
](ButtonEntityDescription):
"""Class to describe a Button entity."""
- press_action: Callable[[_ShellyCoordinatorT], Coroutine[Any, Any, None]]
+ press_action: str
supported: Callable[[_ShellyCoordinatorT], bool] = lambda _: True
@@ -44,14 +50,14 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [
name="Reboot",
device_class=ButtonDeviceClass.RESTART,
entity_category=EntityCategory.CONFIG,
- press_action=lambda coordinator: coordinator.device.trigger_reboot(),
+ press_action="trigger_reboot",
),
ShellyButtonDescription[ShellyBlockCoordinator](
key="self_test",
name="Self test",
translation_key="self_test",
entity_category=EntityCategory.DIAGNOSTIC,
- press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_self_test(),
+ press_action="trigger_shelly_gas_self_test",
supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS,
),
ShellyButtonDescription[ShellyBlockCoordinator](
@@ -59,7 +65,7 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [
name="Mute",
translation_key="mute",
entity_category=EntityCategory.CONFIG,
- press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_mute(),
+ press_action="trigger_shelly_gas_mute",
supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS,
),
ShellyButtonDescription[ShellyBlockCoordinator](
@@ -67,11 +73,22 @@ BUTTONS: Final[list[ShellyButtonDescription[Any]]] = [
name="Unmute",
translation_key="unmute",
entity_category=EntityCategory.CONFIG,
- press_action=lambda coordinator: coordinator.device.trigger_shelly_gas_unmute(),
+ press_action="trigger_shelly_gas_unmute",
supported=lambda coordinator: coordinator.device.model in SHELLY_GAS_MODELS,
),
]
+BLU_TRV_BUTTONS: Final[list[ShellyButtonDescription]] = [
+ ShellyButtonDescription[ShellyRpcCoordinator](
+ key="calibrate",
+ name="Calibrate",
+ translation_key="calibrate",
+ entity_category=EntityCategory.CONFIG,
+ press_action="trigger_blu_trv_calibration",
+ supported=lambda coordinator: coordinator.device.model == MODEL_BLU_GATEWAY,
+ ),
+]
+
@callback
def async_migrate_unique_ids(
@@ -106,7 +123,7 @@ def async_migrate_unique_ids(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set buttons for device."""
entry_data = config_entry.runtime_data
@@ -123,14 +140,28 @@ async def async_setup_entry(
hass, config_entry.entry_id, partial(async_migrate_unique_ids, coordinator)
)
- async_add_entities(
+ entities: list[ShellyButton | ShellyBluTrvButton] = []
+
+ entities.extend(
ShellyButton(coordinator, button)
for button in BUTTONS
if button.supported(coordinator)
)
+ if blutrv_key_ids := get_rpc_key_ids(coordinator.device.status, BLU_TRV_IDENTIFIER):
+ if TYPE_CHECKING:
+ assert isinstance(coordinator, ShellyRpcCoordinator)
-class ShellyButton(
+ entities.extend(
+ ShellyBluTrvButton(coordinator, button, id_)
+ for id_ in blutrv_key_ids
+ for button in BLU_TRV_BUTTONS
+ )
+
+ async_add_entities(entities)
+
+
+class ShellyBaseButton(
CoordinatorEntity[ShellyRpcCoordinator | ShellyBlockCoordinator], ButtonEntity
):
"""Defines a Shelly base button."""
@@ -148,14 +179,98 @@ class ShellyButton(
) -> None:
"""Initialize Shelly button."""
super().__init__(coordinator)
+
self.entity_description = description
+ async def async_press(self) -> None:
+ """Triggers the Shelly button press service."""
+ try:
+ await self._press_method()
+ except DeviceConnectionError as err:
+ self.coordinator.last_update_success = False
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="device_communication_action_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "device": self.coordinator.name,
+ },
+ ) from err
+ except RpcCallError as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="rpc_call_action_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "device": self.coordinator.name,
+ },
+ ) from err
+ except InvalidAuthError:
+ await self.coordinator.async_shutdown_device_and_start_reauth()
+
+ async def _press_method(self) -> None:
+ """Press method."""
+ raise NotImplementedError
+
+
+class ShellyButton(ShellyBaseButton):
+ """Defines a Shelly button."""
+
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator | ShellyBlockCoordinator,
+ description: ShellyButtonDescription[
+ ShellyRpcCoordinator | ShellyBlockCoordinator
+ ],
+ ) -> None:
+ """Initialize Shelly button."""
+ super().__init__(coordinator, description)
+
self._attr_name = f"{coordinator.device.name} {description.name}"
self._attr_unique_id = f"{coordinator.mac}_{description.key}"
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, coordinator.mac)}
)
- async def async_press(self) -> None:
- """Triggers the Shelly button press service."""
- await self.entity_description.press_action(self.coordinator)
+ async def _press_method(self) -> None:
+ """Press method."""
+ method = getattr(self.coordinator.device, self.entity_description.press_action)
+
+ if TYPE_CHECKING:
+ assert method is not None
+
+ await method()
+
+
+class ShellyBluTrvButton(ShellyBaseButton):
+ """Represent a Shelly BLU TRV button."""
+
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ description: ShellyButtonDescription,
+ id_: int,
+ ) -> None:
+ """Initialize."""
+ super().__init__(coordinator, description)
+
+ ble_addr: str = coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["addr"]
+ device_name = (
+ coordinator.device.config[f"{BLU_TRV_IDENTIFIER}:{id_}"]["name"]
+ or f"shellyblutrv-{ble_addr.replace(':', '')}"
+ )
+ self._attr_name = f"{device_name} {description.name}"
+ self._attr_unique_id = f"{ble_addr}_{description.key}"
+ self._attr_device_info = DeviceInfo(
+ connections={(CONNECTION_BLUETOOTH, ble_addr)}
+ )
+ self._id = id_
+
+ async def _press_method(self) -> None:
+ """Press method."""
+ method = getattr(self.coordinator.device, self.entity_description.press_action)
+
+ if TYPE_CHECKING:
+ assert method is not None
+
+ await method(self._id)
diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py
index f1491acdd81..498f2d3dba9 100644
--- a/homeassistant/components/shelly/climate.py
+++ b/homeassistant/components/shelly/climate.py
@@ -7,7 +7,12 @@ from dataclasses import asdict, dataclass
from typing import Any, cast
from aioshelly.block_device import Block
-from aioshelly.const import BLU_TRV_IDENTIFIER, BLU_TRV_MODEL_NAME, RPC_GENERATIONS
+from aioshelly.const import (
+ BLU_TRV_IDENTIFIER,
+ BLU_TRV_MODEL_NAME,
+ BLU_TRV_TIMEOUT,
+ RPC_GENERATIONS,
+)
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
from homeassistant.components.climate import (
@@ -27,7 +32,7 @@ from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
DeviceInfo,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -36,7 +41,6 @@ from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from .const import (
BLU_TRV_TEMPERATURE_SETTINGS,
- BLU_TRV_TIMEOUT,
DOMAIN,
LOGGER,
NOT_CALIBRATED_ISSUE_ID,
@@ -56,7 +60,7 @@ from .utils import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up climate device."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
@@ -75,7 +79,7 @@ async def async_setup_entry(
@callback
def async_setup_climate_entities(
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
coordinator: ShellyBlockCoordinator,
) -> None:
"""Set up online climate devices."""
@@ -102,7 +106,7 @@ def async_setup_climate_entities(
def async_restore_climate_entities(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
coordinator: ShellyBlockCoordinator,
) -> None:
"""Restore sleeping climate devices."""
@@ -124,7 +128,7 @@ def async_restore_climate_entities(
def async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
coordinator = config_entry.runtime_data.rpc
@@ -322,8 +326,12 @@ class BlockSleepingClimate(
except DeviceConnectionError as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
- f"Setting state for entity {self.name} failed, state: {kwargs}, error:"
- f" {err!r}"
+ translation_domain=DOMAIN,
+ translation_key="device_communication_action_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "device": self.coordinator.name,
+ },
) from err
except InvalidAuthError:
await self.coordinator.async_shutdown_device_and_start_reauth()
diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py
index f53da8bd766..6e41df282ef 100644
--- a/homeassistant/components/shelly/config_flow.py
+++ b/homeassistant/components/shelly/config_flow.py
@@ -12,20 +12,17 @@ from aioshelly.exceptions import (
CustomPortNotSupported,
DeviceConnectionError,
InvalidAuthError,
+ InvalidHostError,
MacAddressMismatchError,
)
from aioshelly.rpc_device import RpcDevice
import voluptuous as vol
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
+ CONF_MODEL,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
@@ -41,10 +38,9 @@ from .const import (
CONF_SLEEP_PERIOD,
DOMAIN,
LOGGER,
- MODEL_WALL_DISPLAY,
BLEScannerMode,
)
-from .coordinator import async_reconnect_soon
+from .coordinator import ShellyConfigEntry, async_reconnect_soon
from .utils import (
get_block_device_sleep_period,
get_coap_context,
@@ -112,7 +108,9 @@ async def validate_input(
return {
"title": rpc_device.name,
CONF_SLEEP_PERIOD: sleep_period,
- "model": rpc_device.shelly.get("model"),
+ CONF_MODEL: (
+ rpc_device.xmod_info.get("p") or rpc_device.shelly.get(CONF_MODEL)
+ ),
CONF_GEN: gen,
}
@@ -132,7 +130,7 @@ async def validate_input(
return {
"title": block_device.name,
CONF_SLEEP_PERIOD: sleep_period,
- "model": block_device.model,
+ CONF_MODEL: block_device.model,
CONF_GEN: gen,
}
@@ -160,11 +158,15 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
self.info = await self._async_get_info(host, port)
except DeviceConnectionError:
errors["base"] = "cannot_connect"
+ except InvalidHostError:
+ errors["base"] = "invalid_host"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
- await self.async_set_unique_id(self.info[CONF_MAC])
+ await self.async_set_unique_id(
+ self.info[CONF_MAC], raise_on_progress=False
+ )
self._abort_if_unique_id_configured({CONF_HOST: host})
self.host = host
self.port = port
@@ -185,14 +187,14 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
- if device_info["model"]:
+ if device_info[CONF_MODEL]:
return self.async_create_entry(
title=device_info["title"],
data={
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD],
- "model": device_info["model"],
+ CONF_MODEL: device_info[CONF_MODEL],
CONF_GEN: device_info[CONF_GEN],
},
)
@@ -224,7 +226,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
- if device_info["model"]:
+ if device_info[CONF_MODEL]:
return self.async_create_entry(
title=device_info["title"],
data={
@@ -232,7 +234,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: self.host,
CONF_PORT: self.port,
CONF_SLEEP_PERIOD: device_info[CONF_SLEEP_PERIOD],
- "model": device_info["model"],
+ CONF_MODEL: device_info[CONF_MODEL],
CONF_GEN: device_info[CONF_GEN],
},
)
@@ -330,7 +332,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle discovery confirm."""
errors: dict[str, str] = {}
- if not self.device_info["model"]:
+ if not self.device_info[CONF_MODEL]:
errors["base"] = "firmware_not_fully_provisioned"
model = "Shelly"
else:
@@ -339,9 +341,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(
title=self.device_info["title"],
data={
- "host": self.host,
+ CONF_HOST: self.host,
CONF_SLEEP_PERIOD: self.device_info[CONF_SLEEP_PERIOD],
- "model": self.device_info["model"],
+ CONF_MODEL: self.device_info[CONF_MODEL],
CONF_GEN: self.device_info[CONF_GEN],
},
)
@@ -350,8 +352,8 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form(
step_id="confirm_discovery",
description_placeholders={
- "model": model,
- "host": self.host,
+ CONF_MODEL: model,
+ CONF_HOST: self.host,
},
errors=errors,
)
@@ -449,19 +451,17 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
- def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler:
+ def async_get_options_flow(config_entry: ShellyConfigEntry) -> OptionsFlowHandler:
"""Get the options flow for this handler."""
return OptionsFlowHandler()
@classmethod
@callback
- def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool:
+ def async_supports_options_flow(cls, config_entry: ShellyConfigEntry) -> bool:
"""Return options flow support for this handler."""
- return (
- get_device_entry_gen(config_entry) in RPC_GENERATIONS
- and not config_entry.data.get(CONF_SLEEP_PERIOD)
- and config_entry.data.get("model") != MODEL_WALL_DISPLAY
- )
+ return get_device_entry_gen(
+ config_entry
+ ) in RPC_GENERATIONS and not config_entry.data.get(CONF_SLEEP_PERIOD)
class OptionsFlowHandler(OptionsFlow):
@@ -471,6 +471,13 @@ class OptionsFlowHandler(OptionsFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle options flow."""
+ if (
+ supports_scripts := self.config_entry.runtime_data.rpc_supports_scripts
+ ) is None:
+ return self.async_abort(reason="cannot_connect")
+ if not supports_scripts:
+ return self.async_abort(reason="no_scripts_support")
+
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py
index d47f2b0ae80..cc3ec564b3f 100644
--- a/homeassistant/components/shelly/const.py
+++ b/homeassistant/components/shelly/const.py
@@ -25,6 +25,7 @@ from aioshelly.const import (
MODEL_VALVE,
MODEL_VINTAGE_V2,
MODEL_WALL_DISPLAY,
+ MODEL_WALL_DISPLAY_X2,
)
from homeassistant.components.number import NumberMode
@@ -208,7 +209,7 @@ KELVIN_MIN_VALUE_COLOR: Final = 3000
BLOCK_WRONG_SLEEP_PERIOD = 21600
BLOCK_EXPECTED_SLEEP_PERIOD = 43200
-UPTIME_DEVIATION: Final = 5
+UPTIME_DEVIATION: Final = 60
# Time to wait before reloading entry upon device config change
ENTRY_RELOAD_COOLDOWN = 60
@@ -245,6 +246,7 @@ GEN2_RELEASE_URL = "https://shelly-api-docs.shelly.cloud/gen2/changelog/"
GEN2_BETA_RELEASE_URL = f"{GEN2_RELEASE_URL}#unreleased"
DEVICES_WITHOUT_FIRMWARE_CHANGELOG = (
MODEL_WALL_DISPLAY,
+ MODEL_WALL_DISPLAY_X2,
MODEL_MOTION,
MODEL_MOTION_2,
MODEL_VALVE,
@@ -271,10 +273,11 @@ API_WS_URL = "/api/shelly/ws"
COMPONENT_ID_PATTERN = re.compile(r"[a-z\d]+:\d+")
-# value confirmed by Shelly team
-BLU_TRV_TIMEOUT = 60
-
ROLE_TO_DEVICE_CLASS_MAP = {
"current_humidity": SensorDeviceClass.HUMIDITY,
"current_temperature": SensorDeviceClass.TEMPERATURE,
}
+
+# We want to check only the first 5 KB of the script if it contains emitEvent()
+# so that the integration startup remains fast.
+MAX_SCRIPT_SIZE = 5120
diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py
index ad35ec32299..4a1ea72f38a 100644
--- a/homeassistant/components/shelly/coordinator.py
+++ b/homeassistant/components/shelly/coordinator.py
@@ -10,7 +10,7 @@ from typing import Any, cast
from aioshelly.ble import async_ensure_ble_enabled, async_stop_scanner
from aioshelly.block_device import BlockDevice, BlockUpdateType
-from aioshelly.const import MODEL_NAMES, MODEL_VALVE
+from aioshelly.const import MODEL_VALVE
from aioshelly.exceptions import (
DeviceConnectionError,
InvalidAuthError,
@@ -18,6 +18,7 @@ from aioshelly.exceptions import (
RpcCallError,
)
from aioshelly.rpc_device import RpcDevice, RpcUpdateType
+from aioshelly.rpc_device.utils import bluetooth_mac_from_primary_mac
from propcache.api import cached_property
from homeassistant.components.bluetooth import async_remove_scanner
@@ -25,6 +26,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import (
ATTR_DEVICE_ID,
CONF_HOST,
+ CONF_MODEL,
EVENT_HOMEASSISTANT_STOP,
Platform,
)
@@ -72,6 +74,7 @@ from .utils import (
get_http_port,
get_rpc_device_wakeup_period,
get_rpc_ws_url,
+ get_shelly_model_name,
update_device_fw_info,
)
@@ -85,6 +88,8 @@ class ShellyEntryData:
rest: ShellyRestCoordinator | None = None
rpc: ShellyRpcCoordinator | None = None
rpc_poll: ShellyRpcPollingCoordinator | None = None
+ rpc_script_events: dict[int, list[str]] | None = None
+ rpc_supports_scripts: bool | None = None
type ShellyConfigEntry = ConfigEntry[ShellyEntryData]
@@ -137,7 +142,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice](
@cached_property
def model(self) -> str:
"""Model of the device."""
- return cast(str, self.config_entry.data["model"])
+ return cast(str, self.config_entry.data[CONF_MODEL])
@cached_property
def mac(self) -> str:
@@ -164,7 +169,7 @@ class ShellyCoordinatorBase[_DeviceT: BlockDevice | RpcDevice](
connections={(CONNECTION_NETWORK_MAC, self.mac)},
identifiers={(DOMAIN, self.mac)},
manufacturer="Shelly",
- model=MODEL_NAMES.get(self.model),
+ model=get_shelly_model_name(self.model, self.sleep_period, self.device),
model_id=self.model,
sw_version=self.sw_version,
hw_version=f"gen{get_device_entry_gen(self.config_entry)}",
@@ -374,14 +379,23 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]):
if self.sleep_period:
# Sleeping device, no point polling it, just mark it unavailable
raise UpdateFailed(
- f"Sleeping device did not update within {self.sleep_period} seconds interval"
+ translation_domain=DOMAIN,
+ translation_key="update_error_sleeping_device",
+ translation_placeholders={
+ "device": self.name,
+ "period": str(self.sleep_period),
+ },
)
LOGGER.debug("Polling Shelly Block Device - %s", self.name)
try:
await self.device.update()
except DeviceConnectionError as err:
- raise UpdateFailed(repr(err)) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ translation_placeholders={"device": self.name},
+ ) from err
except InvalidAuthError:
await self.async_shutdown_device_and_start_reauth()
@@ -466,7 +480,11 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]):
return
await self.device.update_shelly()
except (DeviceConnectionError, MacAddressMismatchError) as err:
- raise UpdateFailed(repr(err)) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ translation_placeholders={"device": self.name},
+ ) from err
except InvalidAuthError:
await self.async_shutdown_device_and_start_reauth()
else:
@@ -495,6 +513,15 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
self._connect_task: asyncio.Task | None = None
entry.async_on_unload(entry.add_update_listener(self._async_update_listener))
+ @cached_property
+ def bluetooth_source(self) -> str:
+ """Return the Bluetooth source address.
+
+ This is the Bluetooth MAC address of the device that is used
+ for the Bluetooth scanner.
+ """
+ return format_mac(bluetooth_mac_from_primary_mac(self.mac)).upper()
+
async def async_device_online(self, source: str) -> None:
"""Handle device going online."""
if not self.sleep_period:
@@ -623,7 +650,12 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
if self.sleep_period:
# Sleeping device, no point polling it, just mark it unavailable
raise UpdateFailed(
- f"Sleeping device did not update within {self.sleep_period} seconds interval"
+ translation_domain=DOMAIN,
+ translation_key="update_error_sleeping_device",
+ translation_placeholders={
+ "device": self.name,
+ "period": str(self.sleep_period),
+ },
)
async with self._connection_lock:
@@ -631,7 +663,11 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
return
if not await self._async_device_connect_task():
- raise UpdateFailed("Device reconnect error")
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error_reconnect_error",
+ translation_placeholders={"device": self.name},
+ )
async def _async_disconnected(self, reconnect: bool) -> None:
"""Handle device disconnected."""
@@ -681,7 +717,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
is updated.
"""
if not self.sleep_period:
- await self._async_connect_ble_scanner()
+ if self.config_entry.runtime_data.rpc_supports_scripts:
+ await self._async_connect_ble_scanner()
else:
await self._async_setup_outbound_websocket()
@@ -705,7 +742,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]):
)
if ble_scanner_mode == BLEScannerMode.DISABLED and self.connected:
await async_stop_scanner(self.device)
- async_remove_scanner(self.hass, format_mac(self.mac).upper())
+ async_remove_scanner(self.hass, self.bluetooth_source)
return
if await async_ensure_ble_enabled(self.device):
# BLE enable required a reboot, don't bother connecting
@@ -807,13 +844,21 @@ class ShellyRpcPollingCoordinator(ShellyCoordinatorBase[RpcDevice]):
async def _async_update_data(self) -> None:
"""Fetch data."""
if not self.device.connected:
- raise UpdateFailed("Device disconnected")
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error_device_disconnected",
+ translation_placeholders={"device": self.name},
+ )
LOGGER.debug("Polling Shelly RPC Device - %s", self.name)
try:
await self.device.poll()
except (DeviceConnectionError, RpcCallError) as err:
- raise UpdateFailed(f"Device disconnected: {err!r}") from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_error",
+ translation_placeholders={"device": self.name},
+ ) from err
except InvalidAuthError:
await self.async_shutdown_device_and_start_reauth()
diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py
index 09e8279bf9b..e9eb5acf161 100644
--- a/homeassistant/components/shelly/cover.py
+++ b/homeassistant/components/shelly/cover.py
@@ -15,7 +15,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import ShellyBlockEntity, ShellyRpcEntity
@@ -25,7 +25,7 @@ from .utils import get_device_entry_gen, get_rpc_key_ids
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up covers for device."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
@@ -38,7 +38,7 @@ async def async_setup_entry(
def async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up cover for device."""
coordinator = config_entry.runtime_data.block
@@ -55,7 +55,7 @@ def async_setup_block_entry(
def async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
coordinator = config_entry.runtime_data.rpc
diff --git a/homeassistant/components/shelly/device_trigger.py b/homeassistant/components/shelly/device_trigger.py
index 6e96eb5ed21..740e6aae9b2 100644
--- a/homeassistant/components/shelly/device_trigger.py
+++ b/homeassistant/components/shelly/device_trigger.py
@@ -105,7 +105,9 @@ async def async_validate_trigger_config(
return config
raise InvalidDeviceAutomationConfig(
- f"Invalid ({CONF_TYPE},{CONF_SUBTYPE}): {trigger}"
+ translation_domain=DOMAIN,
+ translation_key="invalid_trigger",
+ translation_placeholders={"trigger": str(trigger)},
)
@@ -137,7 +139,11 @@ async def async_get_triggers(
return triggers
- raise InvalidDeviceAutomationConfig(f"Device not found: {device_id}")
+ raise InvalidDeviceAutomationConfig(
+ translation_domain=DOMAIN,
+ translation_key="device_not_found",
+ translation_placeholders={"device": device_id},
+ )
async def async_attach_trigger(
diff --git a/homeassistant/components/shelly/diagnostics.py b/homeassistant/components/shelly/diagnostics.py
index a5fe1f5b6c0..2a9699e0a08 100644
--- a/homeassistant/components/shelly/diagnostics.py
+++ b/homeassistant/components/shelly/diagnostics.py
@@ -6,9 +6,14 @@ from typing import Any
from homeassistant.components.bluetooth import async_scanner_by_source
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.const import (
+ ATTR_MODEL,
+ ATTR_NAME,
+ ATTR_SW_VERSION,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import format_mac
from .coordinator import ShellyConfigEntry
from .utils import get_rpc_ws_url
@@ -31,9 +36,9 @@ async def async_get_config_entry_diagnostics(
block_coordinator = shelly_entry_data.block
assert block_coordinator
device_info = {
- "name": block_coordinator.name,
- "model": block_coordinator.model,
- "sw_version": block_coordinator.sw_version,
+ ATTR_NAME: block_coordinator.name,
+ ATTR_MODEL: block_coordinator.model,
+ ATTR_SW_VERSION: block_coordinator.sw_version,
}
if block_coordinator.device.initialized:
device_settings = {
@@ -66,28 +71,29 @@ async def async_get_config_entry_diagnostics(
rpc_coordinator = shelly_entry_data.rpc
assert rpc_coordinator
device_info = {
- "name": rpc_coordinator.name,
- "model": rpc_coordinator.model,
- "sw_version": rpc_coordinator.sw_version,
+ ATTR_NAME: rpc_coordinator.name,
+ ATTR_MODEL: rpc_coordinator.model,
+ ATTR_SW_VERSION: rpc_coordinator.sw_version,
}
if rpc_coordinator.device.initialized:
device_settings = {
k: v for k, v in rpc_coordinator.device.config.items() if k in ["cloud"]
}
- ws_config = rpc_coordinator.device.config["ws"]
- device_settings["ws_outbound_enabled"] = ws_config["enable"]
- if ws_config["enable"]:
- device_settings["ws_outbound_server_valid"] = bool(
- ws_config["server"] == get_rpc_ws_url(hass)
- )
+ if not (ws_config := rpc_coordinator.device.config.get("ws", {})):
+ device_settings["ws_outbound"] = "not supported"
+ if (ws_outbound_enabled := ws_config.get("enable")) is not None:
+ device_settings["ws_outbound_enabled"] = ws_outbound_enabled
+ if ws_outbound_enabled:
+ device_settings["ws_outbound_server_valid"] = bool(
+ ws_config["server"] == get_rpc_ws_url(hass)
+ )
device_status = {
k: v
for k, v in rpc_coordinator.device.status.items()
if k in ["sys", "wifi"]
}
- source = format_mac(rpc_coordinator.mac).upper()
- if scanner := async_scanner_by_source(hass, source):
+ if scanner := async_scanner_by_source(hass, rpc_coordinator.bluetooth_source):
bluetooth = {
"scanner": await scanner.async_diagnostics(),
}
diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py
index 001727c74b3..9ed3f47b41a 100644
--- a/homeassistant/components/shelly/entity.py
+++ b/homeassistant/components/shelly/entity.py
@@ -19,7 +19,7 @@ from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import CONF_SLEEP_PERIOD, LOGGER
+from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .utils import (
async_remove_shelly_entity,
@@ -296,7 +296,6 @@ class RpcEntityDescription(EntityDescription):
value: Callable[[Any, Any], Any] | None = None
available: Callable[[dict], bool] | None = None
removal_condition: Callable[[dict, dict, str], bool] | None = None
- extra_state_attributes: Callable[[dict, dict], dict | None] | None = None
use_polling_coordinator: bool = False
supported: Callable = lambda _: False
unit: Callable[[dict], str | None] | None = None
@@ -313,7 +312,6 @@ class RestEntityDescription(EntityDescription):
name: str = ""
value: Callable[[dict, Any], Any] | None = None
- extra_state_attributes: Callable[[dict], dict | None] | None = None
class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]):
@@ -347,8 +345,12 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]):
except DeviceConnectionError as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
- f"Setting state for entity {self.name} failed, state: {kwargs}, error:"
- f" {err!r}"
+ translation_domain=DOMAIN,
+ translation_key="device_communication_action_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "device": self.coordinator.name,
+ },
) from err
except InvalidAuthError:
await self.coordinator.async_shutdown_device_and_start_reauth()
@@ -408,13 +410,21 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]):
except DeviceConnectionError as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
- f"Call RPC for {self.name} connection error, method: {method}, params:"
- f" {params}, error: {err!r}"
+ translation_domain=DOMAIN,
+ translation_key="device_communication_action_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "device": self.coordinator.name,
+ },
) from err
except RpcCallError as err:
raise HomeAssistantError(
- f"Call RPC for {self.name} request error, method: {method}, params:"
- f" {params}, error: {err!r}"
+ translation_domain=DOMAIN,
+ translation_key="rpc_call_action_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "device": self.coordinator.name,
+ },
) from err
except InvalidAuthError:
await self.coordinator.async_shutdown_device_and_start_reauth()
diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py
index 78093bec8aa..ec5810581b1 100644
--- a/homeassistant/components/shelly/event.py
+++ b/homeassistant/components/shelly/event.py
@@ -18,7 +18,7 @@ from homeassistant.components.event import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -34,7 +34,6 @@ from .utils import (
get_device_entry_gen,
get_rpc_entity_name,
get_rpc_key_instances,
- get_rpc_script_event_types,
is_block_momentary_input,
is_rpc_momentary_input,
)
@@ -83,7 +82,7 @@ SCRIPT_EVENT: Final = ShellyRpcEventDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for device."""
entities: list[ShellyBlockEvent | ShellyRpcEvent] = []
@@ -109,18 +108,15 @@ async def async_setup_entry(
script_instances = get_rpc_key_instances(
coordinator.device.status, SCRIPT_EVENT.key
)
+ script_events = config_entry.runtime_data.rpc_script_events
for script in script_instances:
script_name = get_rpc_entity_name(coordinator.device, script)
if script_name == BLE_SCRIPT_NAME:
continue
- event_types = await get_rpc_script_event_types(
- coordinator.device, int(script.split(":")[-1])
- )
- if not event_types:
- continue
-
- entities.append(ShellyRpcScriptEvent(coordinator, script, event_types))
+ script_id = int(script.split(":")[-1])
+ if script_events and (event_types := script_events[script_id]):
+ entities.append(ShellyRpcScriptEvent(coordinator, script, event_types))
# If a script is removed, from the device configuration, we need to remove orphaned entities
async_remove_orphaned_entities(
diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json
index f93abf6b854..08b269a73c5 100644
--- a/homeassistant/components/shelly/icons.json
+++ b/homeassistant/components/shelly/icons.json
@@ -23,12 +23,18 @@
"gas_concentration": {
"default": "mdi:gauge"
},
+ "gas_detected": {
+ "default": "mdi:gas-burner"
+ },
"lamp_life": {
"default": "mdi:progress-wrench"
},
"operation": {
"default": "mdi:cog-transfer"
},
+ "self_test": {
+ "default": "mdi:progress-wrench"
+ },
"tilt": {
"default": "mdi:angle-acute"
},
diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py
index 5d7bad810b4..ce31533b557 100644
--- a/homeassistant/components/shelly/light.py
+++ b/homeassistant/components/shelly/light.py
@@ -21,7 +21,7 @@ from homeassistant.components.light import (
brightness_supported,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
BLOCK_MAX_TRANSITION_TIME_MS,
@@ -53,7 +53,7 @@ from .utils import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lights for device."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
@@ -66,7 +66,7 @@ async def async_setup_entry(
def async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for block device."""
coordinator = config_entry.runtime_data.block
@@ -96,7 +96,7 @@ def async_setup_block_entry(
def async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
coordinator = config_entry.runtime_data.rpc
diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json
index 4c9927f515a..19ccd1354a7 100644
--- a/homeassistant/components/shelly/manifest.json
+++ b/homeassistant/components/shelly/manifest.json
@@ -8,11 +8,14 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioshelly"],
- "requirements": ["aioshelly==12.4.2"],
+ "requirements": ["aioshelly==13.4.1"],
"zeroconf": [
{
"type": "_http._tcp.local.",
"name": "shelly*"
+ },
+ {
+ "type": "_shelly._tcp.local."
}
]
}
diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py
index 1fc47b23bdb..c629eb4a57a 100644
--- a/homeassistant/components/shelly/number.py
+++ b/homeassistant/components/shelly/number.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final, cast
from aioshelly.block_device import Block
-from aioshelly.const import RPC_GENERATIONS
+from aioshelly.const import BLU_TRV_TIMEOUT, RPC_GENERATIONS
from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
from homeassistant.components.number import (
@@ -22,10 +22,10 @@ from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
-from .const import BLU_TRV_TIMEOUT, CONF_SLEEP_PERIOD, LOGGER, VIRTUAL_NUMBER_MODE_MAP
+from .const import CONF_SLEEP_PERIOD, DOMAIN, LOGGER, VIRTUAL_NUMBER_MODE_MAP
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
@@ -238,7 +238,7 @@ RPC_NUMBERS: Final = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up numbers for device."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
@@ -324,8 +324,12 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
except DeviceConnectionError as err:
self.coordinator.last_update_success = False
raise HomeAssistantError(
- f"Setting state for entity {self.name} failed, state: {params}, error:"
- f" {err!r}"
+ translation_domain=DOMAIN,
+ translation_key="device_communication_action_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "device": self.coordinator.name,
+ },
) from err
except InvalidAuthError:
await self.coordinator.async_shutdown_device_and_start_reauth()
diff --git a/homeassistant/components/shelly/quality_scale.yaml b/homeassistant/components/shelly/quality_scale.yaml
new file mode 100644
index 00000000000..ac2a0756b5b
--- /dev/null
+++ b/homeassistant/components/shelly/quality_scale.yaml
@@ -0,0 +1,72 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: The integration does not register services.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage:
+ status: todo
+ comment: make sure flows end with created entry or abort
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: The integration does not register services.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: todo
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: The integration does not register 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: todo
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: todo
+ docs-examples: done
+ docs-known-limitations: done
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: done
+ dynamic-devices:
+ status: exempt
+ comment: The integration connects to a single device.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: todo
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: done
+ repair-issues: done
+ stale-devices:
+ status: todo
+ comment: BLU TRV needs to be removed when un-paired
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/shelly/select.py b/homeassistant/components/shelly/select.py
index 0caf4661240..1fb3dfb3447 100644
--- a/homeassistant/components/shelly/select.py
+++ b/homeassistant/components/shelly/select.py
@@ -13,7 +13,7 @@ from homeassistant.components.select import (
SelectEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
@@ -45,7 +45,7 @@ RPC_SELECT_ENTITIES: Final = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up selectors for device."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py
index c492fc1de9e..79e4c97aead 100644
--- a/homeassistant/components/shelly/sensor.py
+++ b/homeassistant/components/shelly/sensor.py
@@ -35,11 +35,11 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.typing import StateType
-from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP, SHAIR_MAX_WORK_HOURS
+from .const import CONF_SLEEP_PERIOD, ROLE_TO_DEVICE_CLASS_MAP
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
@@ -58,6 +58,7 @@ from .utils import (
async_remove_orphaned_entities,
get_device_entry_gen,
get_device_uptime,
+ get_shelly_air_lamp_life,
get_virtual_component_ids,
is_rpc_wifi_stations_disabled,
)
@@ -355,8 +356,9 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
name="Lamp life",
native_unit_of_measurement=PERCENTAGE,
translation_key="lamp_life",
- value=lambda value: 100 - (value / 3600 / SHAIR_MAX_WORK_HOURS),
+ value=get_shelly_air_lamp_life,
suggested_display_precision=1,
+ # Deprecated, remove in 2025.10
extra_state_attributes=lambda block: {
"Operational hours": round(cast(int, block.totalWorkTime) / 3600, 1)
},
@@ -374,9 +376,10 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
key="sensor|sensorOp",
name="Operation",
device_class=SensorDeviceClass.ENUM,
- options=["unknown", "warmup", "normal", "fault"],
+ options=["warmup", "normal", "fault"],
translation_key="operation",
- value=lambda value: value,
+ value=lambda value: None if value == "unknown" else value,
+ # Deprecated, remove in 2025.10
extra_state_attributes=lambda block: {"self_test": block.selfTest},
),
("valve", "valve"): BlockSensorDescription(
@@ -391,11 +394,33 @@ SENSORS: dict[tuple[str, str], BlockSensorDescription] = {
"failure",
"opened",
"opening",
- "unknown",
],
+ value=lambda value: None if value == "unknown" else value,
entity_category=EntityCategory.DIAGNOSTIC,
removal_condition=lambda _, block: block.valve == "not_connected",
),
+ ("sensor", "gas"): BlockSensorDescription(
+ key="sensor|gas",
+ name="Gas detected",
+ translation_key="gas_detected",
+ device_class=SensorDeviceClass.ENUM,
+ options=[
+ "none",
+ "mild",
+ "heavy",
+ "test",
+ ],
+ value=lambda value: None if value == "unknown" else value,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ ("sensor", "selfTest"): BlockSensorDescription(
+ key="sensor|selfTest",
+ name="Self test",
+ translation_key="self_test",
+ device_class=SensorDeviceClass.ENUM,
+ options=["not_completed", "completed", "running", "pending"],
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
}
REST_SENSORS: Final = {
@@ -1324,7 +1349,7 @@ RPC_SENSORS: Final = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for device."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json
index eb869b54e4c..2f07742898c 100644
--- a/homeassistant/components/shelly/strings.json
+++ b/homeassistant/components/shelly/strings.json
@@ -17,12 +17,20 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "Username for the device's web panel.",
+ "password": "Password for the device's web panel."
}
},
"reauth_confirm": {
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::shelly::config::step::credentials::data_description::username%]",
+ "password": "[%key:component::shelly::config::step::credentials::data_description::password%]"
}
},
"confirm_discovery": {
@@ -43,6 +51,7 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "invalid_host": "[%key:common::config_flow::error::invalid_host%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"firmware_not_fully_provisioned": "Device not fully provisioned. Please contact Shelly support",
"custom_port_not_supported": "Gen1 device does not support custom port.",
@@ -87,8 +96,15 @@
"description": "Bluetooth scanning can be active or passive. With active, the Shelly requests data from nearby devices; with passive, the Shelly receives unsolicited data from nearby devices.",
"data": {
"ble_scanner_mode": "Bluetooth scanner mode"
+ },
+ "data_description": {
+ "ble_scanner_mode": "The scanner mode to use for Bluetooth scanning."
}
}
+ },
+ "abort": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "no_scripts_support": "Device does not support scripts and cannot be used as a Bluetooth scanner."
}
},
"selector": {
@@ -106,7 +122,6 @@
"state_attributes": {
"detected": {
"state": {
- "unknown": "Unknown",
"none": "None",
"mild": "Mild",
"heavy": "Heavy",
@@ -139,20 +154,55 @@
}
},
"sensor": {
+ "gas_detected": {
+ "state": {
+ "none": "None",
+ "mild": "Mild",
+ "heavy": "Heavy",
+ "test": "Test"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "none": "[%key:component::shelly::entity::sensor::gas_detected::state::none%]",
+ "mild": "[%key:component::shelly::entity::sensor::gas_detected::state::mild%]",
+ "heavy": "[%key:component::shelly::entity::sensor::gas_detected::state::heavy%]",
+ "test": "[%key:component::shelly::entity::sensor::gas_detected::state::test%]"
+ }
+ }
+ }
+ },
"operation": {
"state": {
- "unknown": "Unknown",
"warmup": "Warm-up",
- "normal": "Normal",
+ "normal": "[%key:common::state::normal%]",
"fault": "Fault"
},
"state_attributes": {
"self_test": {
"state": {
- "not_completed": "Not completed",
- "completed": "Completed",
- "running": "Running",
- "pending": "Pending"
+ "not_completed": "[%key:component::shelly::entity::sensor::self_test::state::not_completed%]",
+ "completed": "[%key:component::shelly::entity::sensor::self_test::state::completed%]",
+ "running": "[%key:component::shelly::entity::sensor::self_test::state::running%]",
+ "pending": "[%key:component::shelly::entity::sensor::self_test::state::pending%]"
+ }
+ }
+ }
+ },
+ "self_test": {
+ "state": {
+ "not_completed": "Not completed",
+ "completed": "Completed",
+ "running": "Running",
+ "pending": "Pending"
+ },
+ "state_attributes": {
+ "options": {
+ "state": {
+ "not_completed": "[%key:component::shelly::entity::sensor::self_test::state::not_completed%]",
+ "completed": "[%key:component::shelly::entity::sensor::self_test::state::completed%]",
+ "running": "[%key:component::shelly::entity::sensor::self_test::state::running%]",
+ "pending": "[%key:component::shelly::entity::sensor::self_test::state::pending%]"
}
}
}
@@ -161,15 +211,55 @@
"state": {
"checking": "Checking",
"closed": "[%key:common::state::closed%]",
- "closing": "Closing",
+ "closing": "[%key:common::state::closing%]",
"failure": "Failure",
"opened": "Opened",
- "opening": "Opening",
- "unknown": "[%key:component::shelly::entity::sensor::operation::state::unknown%]"
+ "opening": "[%key:common::state::opening%]"
}
}
}
},
+ "exceptions": {
+ "auth_error": {
+ "message": "Authentication failed for {device}, please update your credentials"
+ },
+ "device_communication_error": {
+ "message": "Device communication error occurred for {device}"
+ },
+ "device_communication_action_error": {
+ "message": "Device communication error occurred while calling action for {entity} of {device}"
+ },
+ "device_not_found": {
+ "message": "{device} not found while configuring device automation triggers"
+ },
+ "firmware_unsupported": {
+ "message": "{device} is running an unsupported firmware, please update the firmware"
+ },
+ "invalid_trigger": {
+ "message": "Invalid device automation trigger (type, subtype): {trigger}"
+ },
+ "ota_update_connection_error": {
+ "message": "Device communication error occurred while triggering OTA update for {device}"
+ },
+ "ota_update_rpc_error": {
+ "message": "RPC call error occurred while triggering OTA update for {device}"
+ },
+ "rpc_call_action_error": {
+ "message": "RPC call error occurred while calling action for {entity} of {device}"
+ },
+ "update_error": {
+ "message": "An error occurred while retrieving data from {device}"
+ },
+ "update_error_device_disconnected": {
+ "message": "An error occurred while retrieving data from {device} because it is disconnected"
+ },
+ "update_error_reconnect_error": {
+ "message": "An error occurred while reconnecting to {device}"
+ },
+ "update_error_sleeping_device": {
+ "message": "Sleeping device did not update within {period} seconds interval"
+ }
+ },
"issues": {
"device_not_calibrated": {
"title": "Shelly device {device_name} is not calibrated",
diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py
index 8a33dae0938..ce9e4f065fb 100644
--- a/homeassistant/components/shelly/switch.py
+++ b/homeassistant/components/shelly/switch.py
@@ -2,12 +2,14 @@
from __future__ import annotations
+from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, cast
from aioshelly.block_device import Block
-from aioshelly.const import MODEL_2, MODEL_25, MODEL_WALL_DISPLAY, RPC_GENERATIONS
+from aioshelly.const import RPC_GENERATIONS
+from homeassistant.components.climate import DOMAIN as CLIMATE_PLATFORM
from homeassistant.components.switch import (
DOMAIN as SWITCH_PLATFORM,
SwitchEntity,
@@ -15,32 +17,26 @@ from homeassistant.components.switch import (
)
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant, State, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.restore_state import RestoreEntity
-from .const import CONF_SLEEP_PERIOD, MOTION_MODELS
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
RpcEntityDescription,
- ShellyBlockEntity,
+ ShellyBlockAttributeEntity,
ShellyRpcAttributeEntity,
- ShellyRpcEntity,
ShellySleepingBlockAttributeEntity,
async_setup_entry_attribute_entities,
- async_setup_rpc_attribute_entities,
+ async_setup_entry_rpc,
)
from .utils import (
async_remove_orphaned_entities,
- async_remove_shelly_entity,
get_device_entry_gen,
- get_rpc_key_ids,
get_virtual_component_ids,
- is_block_channel_type_light,
- is_rpc_channel_type_light,
- is_rpc_thermostat_internal_actuator,
- is_rpc_thermostat_mode,
+ is_block_exclude_from_relay,
+ is_rpc_exclude_from_relay,
)
@@ -49,35 +45,70 @@ class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription):
"""Class to describe a BLOCK switch."""
-MOTION_SWITCH = BlockSwitchDescription(
- key="sensor|motionActive",
- name="Motion detection",
- entity_category=EntityCategory.CONFIG,
-)
+BLOCK_RELAY_SWITCHES = {
+ ("relay", "output"): BlockSwitchDescription(
+ key="relay|output",
+ removal_condition=is_block_exclude_from_relay,
+ )
+}
+
+BLOCK_SLEEPING_MOTION_SWITCH = {
+ ("sensor", "motionActive"): BlockSwitchDescription(
+ key="sensor|motionActive",
+ name="Motion detection",
+ entity_category=EntityCategory.CONFIG,
+ )
+}
@dataclass(frozen=True, kw_only=True)
class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription):
"""Class to describe a RPC virtual switch."""
+ is_on: Callable[[dict[str, Any]], bool]
+ method_on: str
+ method_off: str
+ method_params_fn: Callable[[int | None, bool], dict]
-RPC_VIRTUAL_SWITCH = RpcSwitchDescription(
- key="boolean",
- sub_key="value",
-)
-RPC_SCRIPT_SWITCH = RpcSwitchDescription(
- key="script",
- sub_key="running",
- entity_registry_enabled_default=False,
- entity_category=EntityCategory.CONFIG,
-)
+RPC_RELAY_SWITCHES = {
+ "switch": RpcSwitchDescription(
+ key="switch",
+ sub_key="output",
+ removal_condition=is_rpc_exclude_from_relay,
+ is_on=lambda status: bool(status["output"]),
+ method_on="Switch.Set",
+ method_off="Switch.Set",
+ method_params_fn=lambda id, value: {"id": id, "on": value},
+ ),
+}
+
+RPC_SWITCHES = {
+ "boolean": RpcSwitchDescription(
+ key="boolean",
+ sub_key="value",
+ is_on=lambda status: bool(status["value"]),
+ method_on="Boolean.Set",
+ method_off="Boolean.Set",
+ method_params_fn=lambda id, value: {"id": id, "value": value},
+ ),
+ "script": RpcSwitchDescription(
+ key="script",
+ sub_key="running",
+ is_on=lambda status: bool(status["running"]),
+ method_on="Script.Start",
+ method_off="Script.Stop",
+ method_params_fn=lambda id, _: {"id": id},
+ entity_registry_enabled_default=False,
+ entity_category=EntityCategory.CONFIG,
+ ),
+}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches for device."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
@@ -90,104 +121,41 @@ async def async_setup_entry(
def async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for block device."""
coordinator = config_entry.runtime_data.block
assert coordinator
- # Add Shelly Motion as a switch
- if coordinator.model in MOTION_MODELS:
- async_setup_entry_attribute_entities(
- hass,
- config_entry,
- async_add_entities,
- {("sensor", "motionActive"): MOTION_SWITCH},
- BlockSleepingMotionSwitch,
- )
- return
+ async_setup_entry_attribute_entities(
+ hass, config_entry, async_add_entities, BLOCK_RELAY_SWITCHES, BlockRelaySwitch
+ )
- if config_entry.data[CONF_SLEEP_PERIOD]:
- return
-
- # In roller mode the relay blocks exist but do not contain required info
- if (
- coordinator.model in [MODEL_2, MODEL_25]
- and coordinator.device.settings["mode"] != "relay"
- ):
- return
-
- relay_blocks = []
- assert coordinator.device.blocks
- for block in coordinator.device.blocks:
- if block.type != "relay" or (
- block.channel is not None
- and is_block_channel_type_light(
- coordinator.device.settings, int(block.channel)
- )
- ):
- continue
-
- relay_blocks.append(block)
- unique_id = f"{coordinator.mac}-{block.type}_{block.channel}"
- async_remove_shelly_entity(hass, "light", unique_id)
-
- if not relay_blocks:
- return
-
- async_add_entities(BlockRelaySwitch(coordinator, block) for block in relay_blocks)
+ async_setup_entry_attribute_entities(
+ hass,
+ config_entry,
+ async_add_entities,
+ BLOCK_SLEEPING_MOTION_SWITCH,
+ BlockSleepingMotionSwitch,
+ )
@callback
def async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
coordinator = config_entry.runtime_data.rpc
assert coordinator
- switch_key_ids = get_rpc_key_ids(coordinator.device.status, "switch")
- switch_ids = []
- for id_ in switch_key_ids:
- if is_rpc_channel_type_light(coordinator.device.config, id_):
- continue
-
- if coordinator.model == MODEL_WALL_DISPLAY:
- # There are three configuration scenarios for WallDisplay:
- # - relay mode (no thermostat)
- # - thermostat mode using the internal relay as an actuator
- # - thermostat mode using an external (from another device) relay as
- # an actuator
- if not is_rpc_thermostat_mode(id_, coordinator.device.status):
- # The device is not in thermostat mode, we need to remove a climate
- # entity
- unique_id = f"{coordinator.mac}-thermostat:{id_}"
- async_remove_shelly_entity(hass, "climate", unique_id)
- elif is_rpc_thermostat_internal_actuator(coordinator.device.status):
- # The internal relay is an actuator, skip this ID so as not to create
- # a switch entity
- continue
-
- switch_ids.append(id_)
- unique_id = f"{coordinator.mac}-switch:{id_}"
- async_remove_shelly_entity(hass, "light", unique_id)
-
- async_setup_rpc_attribute_entities(
- hass,
- config_entry,
- async_add_entities,
- {"boolean": RPC_VIRTUAL_SWITCH},
- RpcVirtualSwitch,
+ async_setup_entry_rpc(
+ hass, config_entry, async_add_entities, RPC_RELAY_SWITCHES, RpcRelaySwitch
)
- async_setup_rpc_attribute_entities(
- hass,
- config_entry,
- async_add_entities,
- {"script": RPC_SCRIPT_SWITCH},
- RpcScriptSwitch,
+ async_setup_entry_rpc(
+ hass, config_entry, async_add_entities, RPC_SWITCHES, RpcSwitch
)
# the user can remove virtual components from the device configuration, so we need
@@ -215,10 +183,16 @@ def async_setup_rpc_entry(
"script",
)
- if not switch_ids:
- return
-
- async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids)
+ # if the climate is removed, from the device configuration, we need
+ # to remove orphaned entities
+ async_remove_orphaned_entities(
+ hass,
+ config_entry.entry_id,
+ coordinator.mac,
+ CLIMATE_PLATFORM,
+ coordinator.device.status,
+ "thermostat",
+ )
class BlockSleepingMotionSwitch(
@@ -269,13 +243,22 @@ class BlockSleepingMotionSwitch(
self.last_state = last_state
-class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity):
+class BlockRelaySwitch(ShellyBlockAttributeEntity, SwitchEntity):
"""Entity that controls a relay on Block based Shelly devices."""
- def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None:
+ entity_description: BlockSwitchDescription
+
+ def __init__(
+ self,
+ coordinator: ShellyBlockCoordinator,
+ block: Block,
+ attribute: str,
+ description: BlockSwitchDescription,
+ ) -> None:
"""Initialize relay switch."""
- super().__init__(coordinator, block)
+ super().__init__(coordinator, block, attribute, description)
self.control_result: dict[str, Any] | None = None
+ self._attr_unique_id: str = f"{coordinator.mac}-{block.description}"
@property
def is_on(self) -> bool:
@@ -302,30 +285,8 @@ class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity):
super()._update_callback()
-class RpcRelaySwitch(ShellyRpcEntity, SwitchEntity):
- """Entity that controls a relay on RPC based Shelly devices."""
-
- def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None:
- """Initialize relay switch."""
- super().__init__(coordinator, f"switch:{id_}")
- self._id = id_
-
- @property
- def is_on(self) -> bool:
- """If switch is on."""
- return bool(self.status["output"])
-
- async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn on relay."""
- await self.call_rpc("Switch.Set", {"id": self._id, "on": True})
-
- async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn off relay."""
- await self.call_rpc("Switch.Set", {"id": self._id, "on": False})
-
-
-class RpcVirtualSwitch(ShellyRpcAttributeEntity, SwitchEntity):
- """Entity that controls a virtual boolean component on RPC based Shelly devices."""
+class RpcSwitch(ShellyRpcAttributeEntity, SwitchEntity):
+ """Entity that controls a switch on RPC based Shelly devices."""
entity_description: RpcSwitchDescription
_attr_has_entity_name = True
@@ -333,32 +294,36 @@ class RpcVirtualSwitch(ShellyRpcAttributeEntity, SwitchEntity):
@property
def is_on(self) -> bool:
"""If switch is on."""
- return bool(self.attribute_value)
+ return self.entity_description.is_on(self.status)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on relay."""
- await self.call_rpc("Boolean.Set", {"id": self._id, "value": True})
+ await self.call_rpc(
+ self.entity_description.method_on,
+ self.entity_description.method_params_fn(self._id, True),
+ )
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off relay."""
- await self.call_rpc("Boolean.Set", {"id": self._id, "value": False})
+ await self.call_rpc(
+ self.entity_description.method_off,
+ self.entity_description.method_params_fn(self._id, False),
+ )
-class RpcScriptSwitch(ShellyRpcAttributeEntity, SwitchEntity):
- """Entity that controls a script component on RPC based Shelly devices."""
+class RpcRelaySwitch(RpcSwitch):
+ """Entity that controls a switch on RPC based Shelly devices."""
- entity_description: RpcSwitchDescription
- _attr_has_entity_name = True
+ # False to avoid double naming as True is inerithed from base class
+ _attr_has_entity_name = False
- @property
- def is_on(self) -> bool:
- """If switch is on."""
- return bool(self.status["running"])
-
- async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn on relay."""
- await self.call_rpc("Script.Start", {"id": self._id})
-
- async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn off relay."""
- await self.call_rpc("Script.Stop", {"id": self._id})
+ def __init__(
+ self,
+ coordinator: ShellyRpcCoordinator,
+ key: str,
+ attribute: str,
+ description: RpcEntityDescription,
+ ) -> None:
+ """Initialize the switch."""
+ super().__init__(coordinator, key, attribute, description)
+ self._attr_unique_id: str = f"{coordinator.mac}-{key}"
diff --git a/homeassistant/components/shelly/text.py b/homeassistant/components/shelly/text.py
index 66e2ee4c715..f64d1252b7e 100644
--- a/homeassistant/components/shelly/text.py
+++ b/homeassistant/components/shelly/text.py
@@ -13,7 +13,7 @@ from homeassistant.components.text import (
TextEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ShellyConfigEntry
from .entity import (
@@ -45,7 +45,7 @@ RPC_TEXT_ENTITIES: Final = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for device."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py
index f22547acf50..12ce6dc70cd 100644
--- a/homeassistant/components/shelly/update.py
+++ b/homeassistant/components/shelly/update.py
@@ -22,10 +22,17 @@ from homeassistant.components.update import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
-from .const import CONF_SLEEP_PERIOD, OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS
+from .const import (
+ CONF_SLEEP_PERIOD,
+ DOMAIN,
+ OTA_BEGIN,
+ OTA_ERROR,
+ OTA_PROGRESS,
+ OTA_SUCCESS,
+)
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
RestEntityDescription,
@@ -104,7 +111,7 @@ RPC_UPDATES: Final = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up update entities for Shelly component."""
if get_device_entry_gen(config_entry) in RPC_GENERATIONS:
@@ -198,7 +205,11 @@ class RestUpdateEntity(ShellyRestAttributeEntity, UpdateEntity):
try:
result = await self.coordinator.device.trigger_ota_update(beta=beta)
except DeviceConnectionError as err:
- raise HomeAssistantError(f"Error starting OTA update: {err!r}") from err
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="ota_update_connection_error",
+ translation_placeholders={"device": self.coordinator.name},
+ ) from err
except InvalidAuthError:
await self.coordinator.async_shutdown_device_and_start_reauth()
else:
@@ -310,9 +321,20 @@ class RpcUpdateEntity(ShellyRpcAttributeEntity, UpdateEntity):
try:
await self.coordinator.device.trigger_ota_update(beta=beta)
except DeviceConnectionError as err:
- raise HomeAssistantError(f"OTA update connection error: {err!r}") from err
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="ota_update_connection_error",
+ translation_placeholders={"device": self.coordinator.name},
+ ) from err
except RpcCallError as err:
- raise HomeAssistantError(f"OTA update request error: {err!r}") from err
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="ota_update_rpc_error",
+ translation_placeholders={
+ "entity": self.entity_id,
+ "device": self.coordinator.name,
+ },
+ ) from err
except InvalidAuthError:
await self.coordinator.async_shutdown_device_and_start_reauth()
else:
diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py
index fa310104424..9284afdd567 100644
--- a/homeassistant/components/shelly/utils.py
+++ b/homeassistant/components/shelly/utils.py
@@ -6,7 +6,7 @@ from collections.abc import Iterable
from datetime import datetime, timedelta
from ipaddress import IPv4Address, IPv6Address, ip_address
from types import MappingProxyType
-from typing import Any, cast
+from typing import TYPE_CHECKING, Any, cast
from aiohttp.web import Request, WebSocketResponse
from aioshelly.block_device import COAP, Block, BlockDevice
@@ -28,7 +28,12 @@ from yarl import URL
from homeassistant.components import network
from homeassistant.components.http import HomeAssistantView
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_PORT, EVENT_HOMEASSISTANT_STOP
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_MODEL,
+ CONF_PORT,
+ EVENT_HOMEASSISTANT_STOP,
+)
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import (
device_registry as dr,
@@ -53,7 +58,9 @@ from .const import (
GEN2_BETA_RELEASE_URL,
GEN2_RELEASE_URL,
LOGGER,
+ MAX_SCRIPT_SIZE,
RPC_INPUTS_EVENTS_TYPES,
+ SHAIR_MAX_WORK_HOURS,
SHBTN_INPUTS_EVENTS_TYPES,
SHBTN_MODELS,
SHELLY_EMIT_EVENT_PATTERN,
@@ -175,14 +182,36 @@ def is_block_momentary_input(
return button_type in momentary_types
+def is_block_exclude_from_relay(settings: dict[str, Any], block: Block) -> bool:
+ """Return true if block should be excluded from switch platform."""
+
+ if settings.get("mode") == "roller":
+ return True
+
+ if TYPE_CHECKING:
+ assert block.channel is not None
+
+ return is_block_channel_type_light(settings, int(block.channel))
+
+
def get_device_uptime(uptime: float, last_uptime: datetime | None) -> datetime:
"""Return device uptime string, tolerate up to 5 seconds deviation."""
delta_uptime = utcnow() - timedelta(seconds=uptime)
if (
not last_uptime
- or abs((delta_uptime - last_uptime).total_seconds()) > UPTIME_DEVIATION
+ or (diff := abs((delta_uptime - last_uptime).total_seconds()))
+ > UPTIME_DEVIATION
):
+ if last_uptime:
+ LOGGER.debug(
+ "Time deviation %s > %s: uptime=%s, last_uptime=%s, delta_uptime=%s",
+ diff,
+ UPTIME_DEVIATION,
+ uptime,
+ last_uptime,
+ delta_uptime,
+ )
return delta_uptime
return last_uptime
@@ -310,11 +339,32 @@ def get_info_gen(info: dict[str, Any]) -> int:
def get_model_name(info: dict[str, Any]) -> str:
"""Return the device model name."""
if get_info_gen(info) in RPC_GENERATIONS:
- return cast(str, MODEL_NAMES.get(info["model"], info["model"]))
+ return cast(str, MODEL_NAMES.get(info[CONF_MODEL], info[CONF_MODEL]))
return cast(str, MODEL_NAMES.get(info["type"], info["type"]))
+def get_shelly_model_name(
+ model: str,
+ sleep_period: int,
+ device: BlockDevice | RpcDevice,
+) -> str | None:
+ """Get Shelly model name.
+
+ Assume that XMOD devices are not sleepy devices.
+ """
+ if (
+ sleep_period == 0
+ and isinstance(device, RpcDevice)
+ and (model_name := device.xmod_info.get("n"))
+ ):
+ # Use the model name from XMOD data
+ return cast(str, model_name)
+
+ # Use the model name from aioshelly
+ return cast(str, MODEL_NAMES.get(model))
+
+
def get_rpc_channel_name(device: RpcDevice, key: str) -> str:
"""Get name based on device and channel name."""
key = key.replace("emdata", "em")
@@ -481,7 +531,7 @@ def async_create_issue_unsupported_firmware(
translation_key="unsupported_firmware",
translation_placeholders={
"device_name": entry.title,
- "ip_address": entry.data["host"],
+ "ip_address": entry.data[CONF_HOST],
},
)
@@ -603,6 +653,42 @@ def get_rpc_ws_url(hass: HomeAssistant) -> str | None:
async def get_rpc_script_event_types(device: RpcDevice, id: int) -> list[str]:
"""Return a list of event types for a specific script."""
- code_response = await device.script_getcode(id)
+ code_response = await device.script_getcode(id, bytes_to_read=MAX_SCRIPT_SIZE)
matches = SHELLY_EMIT_EVENT_PATTERN.finditer(code_response["data"])
return sorted([*{str(event_type.group(1)) for event_type in matches}])
+
+
+def is_rpc_exclude_from_relay(
+ settings: dict[str, Any], status: dict[str, Any], channel: str
+) -> bool:
+ """Return true if rpc channel should be excludeed from switch platform."""
+ ch = int(channel.split(":")[1])
+ if is_rpc_thermostat_internal_actuator(status):
+ return True
+
+ return is_rpc_channel_type_light(settings, ch)
+
+
+def get_shelly_air_lamp_life(lamp_seconds: int) -> float:
+ """Return Shelly Air lamp life in percentage."""
+ lamp_hours = lamp_seconds / 3600
+ if lamp_hours >= SHAIR_MAX_WORK_HOURS:
+ return 0.0
+ return 100 * (1 - lamp_hours / SHAIR_MAX_WORK_HOURS)
+
+
+async def get_rpc_scripts_event_types(
+ device: RpcDevice, ignore_scripts: list[str]
+) -> dict[int, list[str]]:
+ """Return a dict of all scripts and their event types."""
+ script_instances = get_rpc_key_instances(device.status, "script")
+ script_events = {}
+ for script in script_instances:
+ script_name = get_rpc_entity_name(device, script)
+ if script_name in ignore_scripts:
+ continue
+
+ script_id = int(script.split(":")[-1])
+ script_events[script_id] = await get_rpc_script_event_types(device, script_id)
+
+ return script_events
diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py
index ea6feaabe69..1829f663b22 100644
--- a/homeassistant/components/shelly/valve.py
+++ b/homeassistant/components/shelly/valve.py
@@ -15,7 +15,7 @@ from homeassistant.components.valve import (
ValveEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry
from .entity import (
@@ -42,7 +42,7 @@ GAS_VALVE = BlockValveDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up valves for device."""
if get_device_entry_gen(config_entry) in BLOCK_GENERATIONS:
@@ -53,7 +53,7 @@ async def async_setup_entry(
def async_setup_block_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up valve for device."""
coordinator = config_entry.runtime_data.block
diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py
index 82b6cbfc7f5..2952c283082 100644
--- a/homeassistant/components/shopping_list/todo.py
+++ b/homeassistant/components/shopping_list/todo.py
@@ -11,7 +11,7 @@ from homeassistant.components.todo import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NoMatchingShoppingListItem, ShoppingData
from .const import DOMAIN
@@ -20,7 +20,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the shopping_list todo platform."""
shopping_data = hass.data[DOMAIN]
diff --git a/homeassistant/components/sia/alarm_control_panel.py b/homeassistant/components/sia/alarm_control_panel.py
index 7ea878f538d..bb6a0669a99 100644
--- a/homeassistant/components/sia/alarm_control_panel.py
+++ b/homeassistant/components/sia/alarm_control_panel.py
@@ -16,7 +16,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_ACCOUNT, CONF_ACCOUNTS, CONF_ZONES, KEY_ALARM, PREVIOUS_STATE
from .entity import SIABaseEntity, SIAEntityDescription
@@ -69,7 +69,7 @@ ENTITY_DESCRIPTION_ALARM = SIAAlarmControlPanelEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SIA alarm_control_panel(s) from a config entry."""
async_add_entities(
diff --git a/homeassistant/components/sia/binary_sensor.py b/homeassistant/components/sia/binary_sensor.py
index 4c8e4ca6130..e1b40dc2e55 100644
--- a/homeassistant/components/sia/binary_sensor.py
+++ b/homeassistant/components/sia/binary_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, EntityCategory
from homeassistant.core import HomeAssistant, State, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_ACCOUNT,
@@ -105,7 +105,7 @@ def generate_binary_sensors(entry: ConfigEntry) -> Iterable[SIABinarySensor]:
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SIA binary sensors from a config entry."""
async_add_entities(generate_binary_sensors(entry))
diff --git a/homeassistant/components/siemens/__init__.py b/homeassistant/components/siemens/__init__.py
new file mode 100644
index 00000000000..314b7c63da9
--- /dev/null
+++ b/homeassistant/components/siemens/__init__.py
@@ -0,0 +1 @@
+"""Siemens virtual integration."""
diff --git a/homeassistant/components/siemens/manifest.json b/homeassistant/components/siemens/manifest.json
new file mode 100644
index 00000000000..e53aca0895f
--- /dev/null
+++ b/homeassistant/components/siemens/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "siemens",
+ "name": "Siemens",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+}
diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json
index e1226fd344d..cee768b6ad0 100644
--- a/homeassistant/components/sighthound/manifest.json
+++ b/homeassistant/components/sighthound/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["simplehound"],
"quality_scale": "legacy",
- "requirements": ["Pillow==11.1.0", "simplehound==0.3"]
+ "requirements": ["Pillow==11.2.1", "simplehound==0.3"]
}
diff --git a/homeassistant/components/simplefin/binary_sensor.py b/homeassistant/components/simplefin/binary_sensor.py
index 66d920fb309..af97fe9a394 100644
--- a/homeassistant/components/simplefin/binary_sensor.py
+++ b/homeassistant/components/simplefin/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SimpleFinConfigEntry
from .entity import SimpleFinEntity
@@ -39,7 +39,7 @@ SIMPLEFIN_BINARY_SENSORS: tuple[SimpleFinBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SimpleFinConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SimpleFIN sensors for config entries."""
diff --git a/homeassistant/components/simplefin/sensor.py b/homeassistant/components/simplefin/sensor.py
index 51a96bae2be..183a198040b 100644
--- a/homeassistant/components/simplefin/sensor.py
+++ b/homeassistant/components/simplefin/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import SimpleFinConfigEntry
@@ -55,7 +55,7 @@ SIMPLEFIN_SENSORS: tuple[SimpleFinSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SimpleFinConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SimpleFIN sensors for config entries."""
diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py
index 2f19c5117a4..8a75baa69c6 100644
--- a/homeassistant/components/simplisafe/__init__.py
+++ b/homeassistant/components/simplisafe/__init__.py
@@ -39,7 +39,7 @@ from simplipy.websocket import (
)
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_CODE,
ATTR_DEVICE_ID,
@@ -402,12 +402,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# If this is the last loaded instance of SimpliSafe, deregister any services
# defined during integration setup:
for service_name in SERVICES:
diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py
index 18f2d8ddcd5..c5a1b2bc708 100644
--- a/homeassistant/components/simplisafe/alarm_control_panel.py
+++ b/homeassistant/components/simplisafe/alarm_control_panel.py
@@ -31,7 +31,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SimpliSafe
from .const import (
@@ -103,7 +103,9 @@ WEBSOCKET_EVENTS_TO_LISTEN_FOR = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a SimpliSafe alarm control panel based on a config entry."""
simplisafe = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py
index 0310e958e6e..38a80ddd354 100644
--- a/homeassistant/components/simplisafe/binary_sensor.py
+++ b/homeassistant/components/simplisafe/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SimpliSafe
from .const import DOMAIN, LOGGER
@@ -34,6 +34,7 @@ SUPPORTED_BATTERY_SENSOR_TYPES = [
DeviceTypes.PANIC_BUTTON,
DeviceTypes.REMOTE,
DeviceTypes.SIREN,
+ DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX,
DeviceTypes.SMOKE,
DeviceTypes.SMOKE_AND_CARBON_MONOXIDE,
DeviceTypes.TEMPERATURE,
@@ -47,6 +48,7 @@ TRIGGERED_SENSOR_TYPES = {
DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION,
DeviceTypes.MOTION_V2: BinarySensorDeviceClass.MOTION,
DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY,
+ DeviceTypes.OUTDOOR_ALARM_SECURITY_BELL_BOX: BinarySensorDeviceClass.SAFETY,
DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE,
# Although this sensor can technically apply to both smoke and carbon, we use the
# SMOKE device class for simplicity:
@@ -55,7 +57,9 @@ TRIGGERED_SENSOR_TYPES = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SimpliSafe binary sensors based on a config entry."""
simplisafe = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/simplisafe/button.py b/homeassistant/components/simplisafe/button.py
index f0272d09f61..129209354c3 100644
--- a/homeassistant/components/simplisafe/button.py
+++ b/homeassistant/components/simplisafe/button.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SimpliSafe
from .const import DOMAIN
@@ -46,7 +46,9 @@ BUTTON_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SimpliSafe buttons based on a config entry."""
simplisafe = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py
index c610223bff1..9e29bb2051b 100644
--- a/homeassistant/components/simplisafe/lock.py
+++ b/homeassistant/components/simplisafe/lock.py
@@ -13,7 +13,7 @@ from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SimpliSafe
from .const import DOMAIN, LOGGER
@@ -31,7 +31,9 @@ WEBSOCKET_EVENTS_TO_LISTEN_FOR = (EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SimpliSafe locks based on a config entry."""
simplisafe = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/simplisafe/sensor.py b/homeassistant/components/simplisafe/sensor.py
index a5f46e87a7c..b82162f0fe7 100644
--- a/homeassistant/components/simplisafe/sensor.py
+++ b/homeassistant/components/simplisafe/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SimpliSafe
from .const import DOMAIN, LOGGER
@@ -22,7 +22,9 @@ from .entity import SimpliSafeEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SimpliSafe freeze sensors based on a config entry."""
simplisafe = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/sky_hub/manifest.json b/homeassistant/components/sky_hub/manifest.json
index 1030da4d0ff..b3c61aad2db 100644
--- a/homeassistant/components/sky_hub/manifest.json
+++ b/homeassistant/components/sky_hub/manifest.json
@@ -1,7 +1,7 @@
{
"domain": "sky_hub",
"name": "Sky Hub",
- "codeowners": ["@rogerselwyn"],
+ "codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/sky_hub",
"iot_class": "local_polling",
"loggers": ["pyskyqhub"],
diff --git a/homeassistant/components/sky_remote/config_flow.py b/homeassistant/components/sky_remote/config_flow.py
index 13cddf99332..51cf9c9bf64 100644
--- a/homeassistant/components/sky_remote/config_flow.py
+++ b/homeassistant/components/sky_remote/config_flow.py
@@ -12,6 +12,8 @@ from homeassistant.helpers import config_validation as cv
from .const import DEFAULT_PORT, DOMAIN, LEGACY_PORT
+_LOGGER = logging.getLogger(__name__)
+
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
@@ -21,7 +23,7 @@ DATA_SCHEMA = vol.Schema(
async def async_find_box_port(host: str) -> int:
"""Find port box uses for communication."""
- logging.debug("Attempting to find port to connect to %s on", host)
+ _LOGGER.debug("Attempting to find port to connect to %s on", host)
remote = RemoteControl(host, DEFAULT_PORT)
try:
await remote.check_connectable()
@@ -46,12 +48,12 @@ class SkyRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
- logging.debug("user_input: %s", user_input)
+ _LOGGER.debug("user_input: %s", user_input)
self._async_abort_entries_match(user_input)
try:
port = await async_find_box_port(user_input[CONF_HOST])
except SkyBoxConnectionError:
- logging.exception("while finding port of skybox")
+ _LOGGER.exception("While finding port of skybox")
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
diff --git a/homeassistant/components/sky_remote/remote.py b/homeassistant/components/sky_remote/remote.py
index 05a464f73a6..1ecd6c3716e 100644
--- a/homeassistant/components/sky_remote/remote.py
+++ b/homeassistant/components/sky_remote/remote.py
@@ -10,7 +10,7 @@ from homeassistant.components.remote import RemoteEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SkyRemoteConfigEntry
from .const import DOMAIN
@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config: SkyRemoteConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Sky remote platform."""
async_add_entities(
diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py
index 3c2d90b2630..cc42da48b26 100644
--- a/homeassistant/components/skybell/binary_sensor.py
+++ b/homeassistant/components/skybell/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .coordinator import SkybellDataUpdateCoordinator
@@ -31,7 +31,9 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Skybell binary sensor."""
async_add_entities(
diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py
index 683b840debe..4ee873f8350 100644
--- a/homeassistant/components/skybell/camera.py
+++ b/homeassistant/components/skybell/camera.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SkybellDataUpdateCoordinator
@@ -30,7 +30,9 @@ CAMERA_TYPES: tuple[CameraEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Skybell camera."""
entities = []
diff --git a/homeassistant/components/skybell/config_flow.py b/homeassistant/components/skybell/config_flow.py
index a32441f4cf8..9893d0dd93a 100644
--- a/homeassistant/components/skybell/config_flow.py
+++ b/homeassistant/components/skybell/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
+import logging
from typing import Any
from aioskybell import Skybell, exceptions
@@ -14,6 +15,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
class SkybellFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Skybell."""
@@ -95,6 +98,7 @@ class SkybellFlowHandler(ConfigFlow, domain=DOMAIN):
return None, "invalid_auth"
except exceptions.SkybellException:
return None, "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
return None, "unknown"
return skybell.user_id, None
diff --git a/homeassistant/components/skybell/light.py b/homeassistant/components/skybell/light.py
index cba9e70c848..3f924f68da8 100644
--- a/homeassistant/components/skybell/light.py
+++ b/homeassistant/components/skybell/light.py
@@ -15,14 +15,16 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import SkybellEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Skybell switch."""
async_add_entities(
diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py
index 5f0df77ecfa..a67fdae3b35 100644
--- a/homeassistant/components/skybell/sensor.py
+++ b/homeassistant/components/skybell/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .entity import DOMAIN, SkybellEntity
@@ -88,7 +88,9 @@ SENSOR_TYPES: tuple[SkybellSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Skybell sensor."""
async_add_entities(
diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py
index fa4f723573f..858363043ca 100644
--- a/homeassistant/components/skybell/switch.py
+++ b/homeassistant/components/skybell/switch.py
@@ -7,7 +7,7 @@ from typing import Any, cast
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import SkybellEntity
@@ -29,7 +29,9 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SkyBell switch."""
async_add_entities(
diff --git a/homeassistant/components/slack/__init__.py b/homeassistant/components/slack/__init__.py
index aa67739016d..899b46ee7e8 100644
--- a/homeassistant/components/slack/__init__.py
+++ b/homeassistant/components/slack/__init__.py
@@ -5,7 +5,7 @@ from __future__ import annotations
import logging
from aiohttp.client_exceptions import ClientError
-from slack.errors import SlackApiError
+from slack_sdk.errors import SlackApiError
from slack_sdk.web.async_client import AsyncWebClient
from homeassistant.config_entries import ConfigEntry
diff --git a/homeassistant/components/slack/config_flow.py b/homeassistant/components/slack/config_flow.py
index fcdc2e8b362..551e9832b2b 100644
--- a/homeassistant/components/slack/config_flow.py
+++ b/homeassistant/components/slack/config_flow.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
-from slack.errors import SlackApiError
+from slack_sdk.errors import SlackApiError
from slack_sdk.web.async_client import AsyncSlackResponse, AsyncWebClient
import voluptuous as vol
diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py
index 16dd212301a..4c7f52e581f 100644
--- a/homeassistant/components/slack/notify.py
+++ b/homeassistant/components/slack/notify.py
@@ -10,7 +10,7 @@ from urllib.parse import urlparse
from aiohttp import BasicAuth
from aiohttp.client_exceptions import ClientError
-from slack.errors import SlackApiError
+from slack_sdk.errors import SlackApiError
from slack_sdk.web.async_client import AsyncWebClient
import voluptuous as vol
diff --git a/homeassistant/components/slack/sensor.py b/homeassistant/components/slack/sensor.py
index ca8c9830818..042ab00916e 100644
--- a/homeassistant/components/slack/sensor.py
+++ b/homeassistant/components/slack/sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import ATTR_SNOOZE, DOMAIN, SLACK_DATA
@@ -21,7 +21,7 @@ from .entity import SlackEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Slack select."""
async_add_entities(
diff --git a/homeassistant/components/sleepiq/binary_sensor.py b/homeassistant/components/sleepiq/binary_sensor.py
index cb56a516b9b..99fff9c49b0 100644
--- a/homeassistant/components/sleepiq/binary_sensor.py
+++ b/homeassistant/components/sleepiq/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, ICON_EMPTY, ICON_OCCUPIED, IS_IN_BED
from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator
@@ -18,7 +18,7 @@ from .entity import SleepIQSleeperEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SleepIQ bed binary sensors."""
data: SleepIQData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/sleepiq/button.py b/homeassistant/components/sleepiq/button.py
index 94b010066c9..74b1bc0789f 100644
--- a/homeassistant/components/sleepiq/button.py
+++ b/homeassistant/components/sleepiq/button.py
@@ -11,7 +11,7 @@ from asyncsleepiq import SleepIQBed
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SleepIQData
@@ -44,7 +44,7 @@ ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sleep number buttons."""
data: SleepIQData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/sleepiq/light.py b/homeassistant/components/sleepiq/light.py
index 781bd8e600a..542c212df27 100644
--- a/homeassistant/components/sleepiq/light.py
+++ b/homeassistant/components/sleepiq/light.py
@@ -8,7 +8,7 @@ from asyncsleepiq import SleepIQBed, SleepIQLight
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator
@@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SleepIQ bed lights."""
data: SleepIQData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py
index 905ceab18bd..53d6c366e46 100644
--- a/homeassistant/components/sleepiq/number.py
+++ b/homeassistant/components/sleepiq/number.py
@@ -17,7 +17,7 @@ from asyncsleepiq import (
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ACTUATOR,
@@ -138,7 +138,7 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SleepIQ bed sensors."""
data: SleepIQData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/sleepiq/select.py b/homeassistant/components/sleepiq/select.py
index 0a09aa4d657..7d059ba6b59 100644
--- a/homeassistant/components/sleepiq/select.py
+++ b/homeassistant/components/sleepiq/select.py
@@ -13,7 +13,7 @@ from asyncsleepiq import (
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, FOOT_WARMER
from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator
@@ -23,7 +23,7 @@ from .entity import SleepIQBedEntity, SleepIQSleeperEntity, sleeper_for_side
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SleepIQ foundation preset select entities."""
data: SleepIQData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/sleepiq/sensor.py b/homeassistant/components/sleepiq/sensor.py
index 413e8e4d856..ca4fbc186ed 100644
--- a/homeassistant/components/sleepiq/sensor.py
+++ b/homeassistant/components/sleepiq/sensor.py
@@ -7,7 +7,7 @@ from asyncsleepiq import SleepIQBed, SleepIQSleeper
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, PRESSURE, SLEEP_NUMBER
from .coordinator import SleepIQData, SleepIQDataUpdateCoordinator
@@ -19,7 +19,7 @@ SENSORS = [PRESSURE, SLEEP_NUMBER]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SleepIQ bed sensors."""
data: SleepIQData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/sleepiq/strings.json b/homeassistant/components/sleepiq/strings.json
index bdafbfb6c77..634202d6da8 100644
--- a/homeassistant/components/sleepiq/strings.json
+++ b/homeassistant/components/sleepiq/strings.json
@@ -28,10 +28,10 @@
"select": {
"foot_warmer_temp": {
"state": {
- "off": "Off",
- "low": "Low",
- "medium": "Medium",
- "high": "High"
+ "off": "[%key:common::state::off%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
}
}
diff --git a/homeassistant/components/sleepiq/switch.py b/homeassistant/components/sleepiq/switch.py
index 9fc8ca9d20e..8363782c064 100644
--- a/homeassistant/components/sleepiq/switch.py
+++ b/homeassistant/components/sleepiq/switch.py
@@ -9,7 +9,7 @@ from asyncsleepiq import SleepIQBed
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SleepIQData, SleepIQPauseUpdateCoordinator
@@ -19,7 +19,7 @@ from .entity import SleepIQBedEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sleep number switches."""
data: SleepIQData = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/slide_local/button.py b/homeassistant/components/slide_local/button.py
index 12474969ca6..3d5de33303d 100644
--- a/homeassistant/components/slide_local/button.py
+++ b/homeassistant/components/slide_local/button.py
@@ -13,7 +13,7 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SlideConfigEntry, SlideCoordinator
@@ -25,7 +25,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: SlideConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button for Slide platform."""
diff --git a/homeassistant/components/slide_local/cover.py b/homeassistant/components/slide_local/cover.py
index 0e5e647dea8..6bb3f338cb8 100644
--- a/homeassistant/components/slide_local/cover.py
+++ b/homeassistant/components/slide_local/cover.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity
from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_INVERT_POSITION, DEFAULT_OFFSET
from .coordinator import SlideConfigEntry, SlideCoordinator
@@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: SlideConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up cover(s) for Slide platform."""
diff --git a/homeassistant/components/slide_local/strings.json b/homeassistant/components/slide_local/strings.json
index 67514ff0d50..10efa4bc4f2 100644
--- a/homeassistant/components/slide_local/strings.json
+++ b/homeassistant/components/slide_local/strings.json
@@ -25,7 +25,7 @@
},
"zeroconf_confirm": {
"title": "Confirm setup for Slide",
- "description": "Do you want to setup {host}?"
+ "description": "Do you want to set up {host}?"
}
},
"abort": {
diff --git a/homeassistant/components/slide_local/switch.py b/homeassistant/components/slide_local/switch.py
index 8de608b7fc0..e83924c87ee 100644
--- a/homeassistant/components/slide_local/switch.py
+++ b/homeassistant/components/slide_local/switch.py
@@ -15,7 +15,7 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SlideConfigEntry, SlideCoordinator
@@ -27,7 +27,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: SlideConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch for Slide platform."""
diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py
index 42c50d21e75..417444961fe 100644
--- a/homeassistant/components/slimproto/media_player.py
+++ b/homeassistant/components/slimproto/media_player.py
@@ -22,7 +22,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from .const import DEFAULT_NAME, DOMAIN, PLAYER_EVENT
@@ -39,7 +39,7 @@ STATE_MAPPING = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SlimProto MediaPlayer(s) from Config Entry."""
slimserver: SlimServer = hass.data[DOMAIN]
diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py
index 6aae74922e4..27fa54e46dd 100644
--- a/homeassistant/components/sma/__init__.py
+++ b/homeassistant/components/sma/__init__.py
@@ -10,7 +10,9 @@ import pysma
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
+ ATTR_CONNECTIONS,
CONF_HOST,
+ CONF_MAC,
CONF_PASSWORD,
CONF_SCAN_INTERVAL,
CONF_SSL,
@@ -19,6 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -75,6 +78,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
serial_number=sma_device_info["serial"],
)
+ # Add the MAC address to connections, if it comes via DHCP
+ if CONF_MAC in entry.data:
+ device_info[ATTR_CONNECTIONS] = {
+ (dr.CONNECTION_NETWORK_MAC, entry.data[CONF_MAC])
+ }
+
# Define the coordinator
async def async_update_data():
"""Update the used SMA sensors."""
diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py
index 3f5eb635989..3210d904b6b 100644
--- a/homeassistant/components/sma/config_flow.py
+++ b/homeassistant/components/sma/config_flow.py
@@ -7,26 +7,43 @@ from typing import Any
import pysma
import voluptuous as vol
+from yarl import URL
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_MAC,
+ CONF_NAME,
+ CONF_PASSWORD,
+ CONF_SSL,
+ CONF_VERIFY_SSL,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import CONF_GROUP, DOMAIN, GROUPS
_LOGGER = logging.getLogger(__name__)
-async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
+async def validate_input(
+ hass: HomeAssistant,
+ user_input: dict[str, Any],
+ data: dict[str, Any] | None = None,
+) -> dict[str, Any]:
"""Validate the user input allows us to connect."""
- session = async_get_clientsession(hass, verify_ssl=data[CONF_VERIFY_SSL])
+ session = async_get_clientsession(hass, verify_ssl=user_input[CONF_VERIFY_SSL])
- protocol = "https" if data[CONF_SSL] else "http"
- url = f"{protocol}://{data[CONF_HOST]}"
+ protocol = "https" if user_input[CONF_SSL] else "http"
+ host = data[CONF_HOST] if data is not None else user_input[CONF_HOST]
+ url = URL.build(scheme=protocol, host=host)
- sma = pysma.SMA(session, url, data[CONF_PASSWORD], group=data[CONF_GROUP])
+ sma = pysma.SMA(
+ session, str(url), user_input[CONF_PASSWORD], group=user_input[CONF_GROUP]
+ )
# new_session raises SmaAuthenticationException on failure
await sma.new_session()
@@ -51,34 +68,53 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_GROUP: GROUPS[0],
CONF_PASSWORD: vol.UNDEFINED,
}
+ self._discovery_data: dict[str, Any] = {}
+
+ async def _handle_user_input(
+ self, user_input: dict[str, Any], discovery: bool = False
+ ) -> tuple[dict[str, str], dict[str, str]]:
+ """Handle the user input."""
+ errors: dict[str, str] = {}
+ device_info: dict[str, str] = {}
+
+ if not discovery:
+ self._data[CONF_HOST] = user_input[CONF_HOST]
+
+ self._data[CONF_SSL] = user_input[CONF_SSL]
+ self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL]
+ self._data[CONF_GROUP] = user_input[CONF_GROUP]
+ self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD]
+
+ try:
+ device_info = await validate_input(
+ self.hass, user_input=user_input, data=self._data
+ )
+ except pysma.exceptions.SmaConnectionException:
+ errors["base"] = "cannot_connect"
+ except pysma.exceptions.SmaAuthenticationException:
+ errors["base"] = "invalid_auth"
+ except pysma.exceptions.SmaReadException:
+ errors["base"] = "cannot_retrieve_device_info"
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+
+ return errors, device_info
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""First step in config flow."""
- errors = {}
+ errors: dict[str, str] = {}
if user_input is not None:
- self._data[CONF_HOST] = user_input[CONF_HOST]
- self._data[CONF_SSL] = user_input[CONF_SSL]
- self._data[CONF_VERIFY_SSL] = user_input[CONF_VERIFY_SSL]
- self._data[CONF_GROUP] = user_input[CONF_GROUP]
- self._data[CONF_PASSWORD] = user_input[CONF_PASSWORD]
-
- try:
- device_info = await validate_input(self.hass, user_input)
- except pysma.exceptions.SmaConnectionException:
- errors["base"] = "cannot_connect"
- except pysma.exceptions.SmaAuthenticationException:
- errors["base"] = "invalid_auth"
- except pysma.exceptions.SmaReadException:
- errors["base"] = "cannot_retrieve_device_info"
- except Exception:
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
+ errors, device_info = await self._handle_user_input(user_input=user_input)
if not errors:
- await self.async_set_unique_id(str(device_info["serial"]))
+ await self.async_set_unique_id(
+ str(device_info["serial"]), raise_on_progress=False
+ )
self._abort_if_unique_id_configured(updates=self._data)
+
return self.async_create_entry(
title=self._data[CONF_HOST], data=self._data
)
@@ -100,3 +136,50 @@ class SmaConfigFlow(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
+
+ async def async_step_dhcp(
+ self, discovery_info: DhcpServiceInfo
+ ) -> ConfigFlowResult:
+ """Handle DHCP discovery."""
+ self._discovery_data[CONF_HOST] = discovery_info.ip
+ self._discovery_data[CONF_MAC] = format_mac(discovery_info.macaddress)
+ self._discovery_data[CONF_NAME] = discovery_info.hostname
+ self._data[CONF_HOST] = discovery_info.ip
+ self._data[CONF_MAC] = format_mac(self._discovery_data[CONF_MAC])
+
+ await self.async_set_unique_id(discovery_info.hostname.replace("SMA", ""))
+ self._abort_if_unique_id_configured()
+
+ return await self.async_step_discovery_confirm()
+
+ async def async_step_discovery_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm discovery."""
+ errors: dict[str, str] = {}
+ if user_input is not None:
+ errors, device_info = await self._handle_user_input(
+ user_input=user_input, discovery=True
+ )
+
+ if not errors:
+ return self.async_create_entry(
+ title=self._data[CONF_HOST], data=self._data
+ )
+
+ return self.async_show_form(
+ step_id="discovery_confirm",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(CONF_SSL, default=self._data[CONF_SSL]): cv.boolean,
+ vol.Optional(
+ CONF_VERIFY_SSL, default=self._data[CONF_VERIFY_SSL]
+ ): cv.boolean,
+ vol.Optional(CONF_GROUP, default=self._data[CONF_GROUP]): vol.In(
+ GROUPS
+ ),
+ vol.Required(CONF_PASSWORD): cv.string,
+ }
+ ),
+ errors=errors,
+ )
diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json
index 8024aad82d6..bb3f5318280 100644
--- a/homeassistant/components/sma/manifest.json
+++ b/homeassistant/components/sma/manifest.json
@@ -3,6 +3,13 @@
"name": "SMA Solar",
"codeowners": ["@kellerza", "@rklomp", "@erwindouna"],
"config_flow": true,
+ "dhcp": [
+ {
+ "hostname": "sma*",
+ "macaddress": "0015BB*"
+ },
+ { "registered_devices": true }
+ ],
"documentation": "https://www.home-assistant.io/integrations/sma",
"iot_class": "local_polling",
"loggers": ["pysma"],
diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py
index 863f15a9a17..ffef026aaed 100644
--- a/homeassistant/components/sma/sensor.py
+++ b/homeassistant/components/sma/sensor.py
@@ -27,7 +27,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
@@ -838,7 +838,7 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SMA sensors."""
sma_data = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/smappee/binary_sensor.py b/homeassistant/components/smappee/binary_sensor.py
index 86bc225dba1..06dcaa62853 100644
--- a/homeassistant/components/smappee/binary_sensor.py
+++ b/homeassistant/components/smappee/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SmappeeConfigEntry
from .const import DOMAIN
@@ -37,7 +37,7 @@ ICON_MAPPING = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SmappeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smappee binary sensor."""
smappee_base = config_entry.runtime_data
diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py
index 2f9d6443568..759dfb34013 100644
--- a/homeassistant/components/smappee/sensor.py
+++ b/homeassistant/components/smappee/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import UnitOfElectricPotential, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SmappeeConfigEntry
from .const import DOMAIN
@@ -189,7 +189,7 @@ VOLTAGE_SENSORS: tuple[SmappeeVoltageSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SmappeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smappee sensor."""
smappee_base = config_entry.runtime_data
diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json
index 2966b5cd753..3037fbc98f6 100644
--- a/homeassistant/components/smappee/strings.json
+++ b/homeassistant/components/smappee/strings.json
@@ -15,7 +15,7 @@
}
},
"zeroconf_confirm": {
- "description": "Do you want to add the Smappee device with serialnumber `{serialnumber}` to Home Assistant?",
+ "description": "Do you want to add the Smappee device with serial number `{serialnumber}` to Home Assistant?",
"title": "Discovered Smappee device"
},
"pick_implementation": {
diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py
index bccf816c823..cf2ddea5938 100644
--- a/homeassistant/components/smappee/switch.py
+++ b/homeassistant/components/smappee/switch.py
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SmappeeConfigEntry
from .const import DOMAIN
@@ -16,7 +16,7 @@ SWITCH_PREFIX = "Switch"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SmappeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smappee Comfort Plugs."""
smappee_base = config_entry.runtime_data
diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py
index 1cd7df68e91..ce87b85c322 100644
--- a/homeassistant/components/smart_meter_texas/__init__.py
+++ b/homeassistant/components/smart_meter_texas/__init__.py
@@ -3,7 +3,7 @@
import logging
import ssl
-from smart_meter_texas import Account, Client, ClientSSLContext
+from smart_meter_texas import Account, Client
from smart_meter_texas.exceptions import (
SmartMeterTexasAPIError,
SmartMeterTexasAuthError,
@@ -16,6 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.util.ssl import get_default_context
from .const import (
DATA_COORDINATOR,
@@ -38,8 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
account = Account(username, password)
- client_ssl_context = ClientSSLContext()
- ssl_context = await client_ssl_context.get_ssl_context()
+ ssl_context = get_default_context()
smart_meter_texas_data = SmartMeterTexasData(hass, entry, account, ssl_context)
try:
diff --git a/homeassistant/components/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py
index b60855b62c8..18a3716e1b9 100644
--- a/homeassistant/components/smart_meter_texas/config_flow.py
+++ b/homeassistant/components/smart_meter_texas/config_flow.py
@@ -4,7 +4,7 @@ import logging
from typing import Any
from aiohttp import ClientError
-from smart_meter_texas import Account, Client, ClientSSLContext
+from smart_meter_texas import Account, Client
from smart_meter_texas.exceptions import (
SmartMeterTexasAPIError,
SmartMeterTexasAuthError,
@@ -16,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import aiohttp_client
+from homeassistant.util.ssl import get_default_context
from .const import DOMAIN
@@ -31,8 +32,7 @@ async def validate_input(hass: HomeAssistant, data):
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
- client_ssl_context = ClientSSLContext()
- ssl_context = await client_ssl_context.get_ssl_context()
+ ssl_context = get_default_context()
client_session = aiohttp_client.async_get_clientsession(hass)
account = Account(data["username"], data["password"])
client = Client(client_session, account, ssl_context)
diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py
index 80fc79671b5..c6e18bf43c1 100644
--- a/homeassistant/components/smart_meter_texas/sensor.py
+++ b/homeassistant/components/smart_meter_texas/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, UnitOfEnergy
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
@@ -30,7 +30,7 @@ from .const import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smart Meter Texas sensors."""
coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py
index 2914851ccbf..c8ca1a819e0 100644
--- a/homeassistant/components/smartthings/__init__.py
+++ b/homeassistant/components/smartthings/__init__.py
@@ -2,416 +2,514 @@
from __future__ import annotations
-import asyncio
-from collections.abc import Iterable
+from collections.abc import Callable
+import contextlib
+from dataclasses import dataclass
from http import HTTPStatus
-import importlib
import logging
+from typing import TYPE_CHECKING, Any, cast
-from aiohttp.client_exceptions import ClientConnectionError, ClientResponseError
-from pysmartapp.event import EVENT_TYPE_DEVICE
-from pysmartthings import APIInvalidGrant, Attribute, Capability, SmartThings
-
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
-from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
-from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import (
- ConfigEntryAuthFailed,
- ConfigEntryError,
- ConfigEntryNotReady,
+from aiohttp import ClientResponseError
+from pysmartthings import (
+ Attribute,
+ Capability,
+ ComponentStatus,
+ Device,
+ DeviceEvent,
+ Lifecycle,
+ Scene,
+ SmartThings,
+ SmartThingsAuthenticationFailedError,
+ SmartThingsConnectionError,
+ SmartThingsSinkError,
+ Status,
)
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.dispatcher import async_dispatcher_send
-from homeassistant.helpers.event import async_track_time_interval
-from homeassistant.helpers.typing import ConfigType
-from homeassistant.loader import async_get_loaded_integration
-from homeassistant.setup import SetupPhases, async_pause_setup
-from .config_flow import SmartThingsFlowHandler # noqa: F401
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ ATTR_CONNECTIONS,
+ ATTR_HW_VERSION,
+ ATTR_MANUFACTURER,
+ ATTR_MODEL,
+ ATTR_SW_VERSION,
+ ATTR_VIA_DEVICE,
+ CONF_ACCESS_TOKEN,
+ CONF_TOKEN,
+ EVENT_HOMEASSISTANT_STOP,
+ Platform,
+)
+from homeassistant.core import Event, HomeAssistant
+from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr, entity_registry as er
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.config_entry_oauth2_flow import (
+ OAuth2Session,
+ async_get_config_entry_implementation,
+)
+from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
+
from .const import (
- CONF_APP_ID,
+ BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES,
CONF_INSTALLED_APP_ID,
CONF_LOCATION_ID,
- CONF_REFRESH_TOKEN,
- DATA_BROKERS,
- DATA_MANAGER,
+ CONF_SUBSCRIPTION_ID,
DOMAIN,
EVENT_BUTTON,
- PLATFORMS,
- SIGNAL_SMARTTHINGS_UPDATE,
- TOKEN_REFRESH_INTERVAL,
-)
-from .smartapp import (
- format_unique_id,
- setup_smartapp,
- setup_smartapp_endpoint,
- smartapp_sync_subscriptions,
- unload_smartapp_endpoint,
- validate_installed_app,
- validate_webhook_requirements,
+ MAIN,
+ OLD_DATA,
+ SENSOR_ATTRIBUTES_TO_CAPABILITIES,
)
_LOGGER = logging.getLogger(__name__)
-CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+@dataclass
+class SmartThingsData:
+ """Define an object to hold SmartThings data."""
+
+ devices: dict[str, FullDevice]
+ scenes: dict[str, Scene]
+ rooms: dict[str, str]
+ client: SmartThings
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Initialize the SmartThings platform."""
- await setup_smartapp_endpoint(hass, False)
- return True
+@dataclass
+class FullDevice:
+ """Define an object to hold device data."""
+
+ device: Device
+ status: dict[str, ComponentStatus]
-async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
- """Handle migration of a previous version config entry.
+type SmartThingsConfigEntry = ConfigEntry[SmartThingsData]
- A config entry created under a previous version must go through the
- integration setup again so we can properly retrieve the needed data
- elements. Force this by removing the entry and triggering a new flow.
- """
- # Remove the entry which will invoke the callback to delete the app.
- hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
- # only create new flow if there isn't a pending one for SmartThings.
- if not hass.config_entries.flow.async_progress_by_handler(DOMAIN):
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}
- )
- )
-
- # Return False because it could not be migrated.
- return False
+PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.BUTTON,
+ Platform.CLIMATE,
+ Platform.COVER,
+ Platform.EVENT,
+ Platform.FAN,
+ Platform.LIGHT,
+ Platform.LOCK,
+ Platform.MEDIA_PLAYER,
+ Platform.NUMBER,
+ Platform.SCENE,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.SWITCH,
+ Platform.UPDATE,
+ Platform.VALVE,
+]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: SmartThingsConfigEntry) -> bool:
"""Initialize config entry which represents an installed SmartApp."""
- # For backwards compat
- if entry.unique_id is None:
+ # The oauth smartthings entry will have a token, older ones are version 3
+ # after migration but still require reauthentication
+ if CONF_TOKEN not in entry.data:
+ raise ConfigEntryAuthFailed("Config entry missing token")
+ implementation = await async_get_config_entry_implementation(hass, entry)
+ session = OAuth2Session(hass, entry, implementation)
+
+ try:
+ await session.async_ensure_token_valid()
+ except ClientResponseError as err:
+ if err.status == HTTPStatus.BAD_REQUEST:
+ raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from err
+ raise ConfigEntryNotReady from err
+
+ client = SmartThings(session=async_get_clientsession(hass))
+
+ async def _refresh_token() -> str:
+ await session.async_ensure_token_valid()
+ token = session.token[CONF_ACCESS_TOKEN]
+ if TYPE_CHECKING:
+ assert isinstance(token, str)
+ return token
+
+ client.refresh_token_function = _refresh_token
+
+ def _handle_max_connections() -> None:
+ _LOGGER.debug(
+ "We hit the limit of max connections or we could not remove the old one, so retrying"
+ )
+ hass.config_entries.async_schedule_reload(entry.entry_id)
+
+ client.max_connections_reached_callback = _handle_max_connections
+
+ def _handle_new_subscription_identifier(identifier: str | None) -> None:
+ """Handle a new subscription identifier."""
hass.config_entries.async_update_entry(
entry,
- unique_id=format_unique_id(
- entry.data[CONF_APP_ID], entry.data[CONF_LOCATION_ID]
- ),
+ data={
+ **entry.data,
+ CONF_SUBSCRIPTION_ID: identifier,
+ },
)
+ if identifier is not None:
+ _LOGGER.debug("Updating subscription ID to %s", identifier)
+ else:
+ _LOGGER.debug("Removing subscription ID")
- if not validate_webhook_requirements(hass):
- _LOGGER.warning(
- "The 'base_url' of the 'http' integration must be configured and start with"
- " 'https://'"
- )
- return False
+ client.new_subscription_id_callback = _handle_new_subscription_identifier
- api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN])
-
- # Ensure platform modules are loaded since the DeviceBroker will
- # import them below and we want them to be cached ahead of time
- # so the integration does not do blocking I/O in the event loop
- # to import the modules.
- await async_get_loaded_integration(hass, DOMAIN).async_get_platforms(PLATFORMS)
+ if (old_identifier := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None:
+ _LOGGER.debug("Trying to delete old subscription %s", old_identifier)
+ try:
+ await client.delete_subscription(old_identifier)
+ except SmartThingsConnectionError as err:
+ raise ConfigEntryNotReady("Could not delete old subscription") from err
+ _LOGGER.debug("Trying to create a new subscription")
try:
- # See if the app is already setup. This occurs when there are
- # installs in multiple SmartThings locations (valid use-case)
- manager = hass.data[DOMAIN][DATA_MANAGER]
- smart_app = manager.smartapps.get(entry.data[CONF_APP_ID])
- if not smart_app:
- # Validate and setup the app.
- app = await api.app(entry.data[CONF_APP_ID])
- smart_app = setup_smartapp(hass, app)
-
- # Validate and retrieve the installed app.
- installed_app = await validate_installed_app(
- api, entry.data[CONF_INSTALLED_APP_ID]
+ subscription = await client.create_subscription(
+ entry.data[CONF_LOCATION_ID],
+ entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID],
)
+ except SmartThingsSinkError as err:
+ _LOGGER.exception("Couldn't create a new subscription")
+ raise ConfigEntryNotReady from err
+ subscription_id = subscription.subscription_id
+ _handle_new_subscription_identifier(subscription_id)
- # Get scenes
- scenes = await async_get_entry_scenes(entry, api)
+ entry.async_create_background_task(
+ hass,
+ client.subscribe(
+ entry.data[CONF_LOCATION_ID],
+ entry.data[CONF_TOKEN][CONF_INSTALLED_APP_ID],
+ subscription,
+ ),
+ "smartthings_socket",
+ )
- # Get SmartApp token to sync subscriptions
- token = await api.generate_tokens(
- entry.data[CONF_CLIENT_ID],
- entry.data[CONF_CLIENT_SECRET],
- entry.data[CONF_REFRESH_TOKEN],
+ device_status: dict[str, FullDevice] = {}
+ try:
+ rooms = {
+ room.room_id: room.name
+ for room in await client.get_rooms(location_id=entry.data[CONF_LOCATION_ID])
+ }
+ devices = await client.get_devices()
+ for device in devices:
+ status = process_status(await client.get_device_status(device.device_id))
+ device_status[device.device_id] = FullDevice(device=device, status=status)
+ except SmartThingsAuthenticationFailedError as err:
+ raise ConfigEntryAuthFailed from err
+
+ device_registry = dr.async_get(hass)
+ create_devices(device_registry, device_status, entry, rooms)
+
+ scenes = {
+ scene.scene_id: scene
+ for scene in await client.get_scenes(location_id=entry.data[CONF_LOCATION_ID])
+ }
+
+ def handle_deleted_device(device_id: str) -> None:
+ """Handle a deleted device."""
+ dev_entry = device_registry.async_get_device(
+ identifiers={(DOMAIN, device_id)},
)
- hass.config_entries.async_update_entry(
- entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token}
- )
-
- # Get devices and their current status
- devices = await api.devices(location_ids=[installed_app.location_id])
-
- async def retrieve_device_status(device):
- try:
- await device.status.refresh()
- except ClientResponseError:
- _LOGGER.debug(
- (
- "Unable to update status for device: %s (%s), the device will"
- " be excluded"
- ),
- device.label,
- device.device_id,
- exc_info=True,
- )
- devices.remove(device)
-
- await asyncio.gather(*(retrieve_device_status(d) for d in devices.copy()))
-
- # Sync device subscriptions
- await smartapp_sync_subscriptions(
- hass,
- token.access_token,
- installed_app.location_id,
- installed_app.installed_app_id,
- devices,
- )
-
- # Setup device broker
- with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS):
- # DeviceBroker has a side effect of importing platform
- # modules when its created. In the future this should be
- # refactored to not do this.
- broker = await hass.async_add_import_executor_job(
- DeviceBroker, hass, entry, token, smart_app, devices, scenes
+ if dev_entry is not None:
+ device_registry.async_update_device(
+ dev_entry.id, remove_config_entry_id=entry.entry_id
)
- broker.connect()
- hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker
- except APIInvalidGrant as ex:
- raise ConfigEntryAuthFailed from ex
- except ClientResponseError as ex:
- if ex.status in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
- raise ConfigEntryError(
- "The access token is no longer valid. Please remove the integration and set up again."
- ) from ex
- _LOGGER.debug(ex, exc_info=True)
- raise ConfigEntryNotReady from ex
- except (ClientConnectionError, RuntimeWarning) as ex:
- _LOGGER.debug(ex, exc_info=True)
- raise ConfigEntryNotReady from ex
+ entry.async_on_unload(
+ client.add_device_lifecycle_event_listener(
+ Lifecycle.DELETE, handle_deleted_device
+ )
+ )
+
+ entry.runtime_data = SmartThingsData(
+ devices={
+ device_id: device
+ for device_id, device in device_status.items()
+ if MAIN in device.status
+ },
+ client=client,
+ scenes=scenes,
+ rooms=rooms,
+ )
+
+ # Events are deprecated and will be removed in 2025.10
+ def handle_button_press(event: DeviceEvent) -> None:
+ """Handle a button press."""
+ if (
+ event.capability is Capability.BUTTON
+ and event.attribute is Attribute.BUTTON
+ ):
+ hass.bus.async_fire(
+ EVENT_BUTTON,
+ {
+ "component_id": event.component_id,
+ "device_id": event.device_id,
+ "location_id": event.location_id,
+ "value": event.value,
+ "name": entry.runtime_data.devices[event.device_id].device.label,
+ "data": event.data,
+ },
+ )
+
+ entry.async_on_unload(
+ client.add_unspecified_device_event_listener(handle_button_press)
+ )
+
+ async def _handle_shutdown(_: Event) -> None:
+ """Handle shutdown."""
+ await client.delete_subscription(subscription_id)
+
+ entry.async_on_unload(
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _handle_shutdown)
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
+ for device_entry in device_entries:
+ device_id = next(
+ identifier[1]
+ for identifier in device_entry.identifiers
+ if identifier[0] == DOMAIN
+ )
+ if device_id in device_status:
+ continue
+ device_registry.async_update_device(
+ device_entry.id, remove_config_entry_id=entry.entry_id
+ )
+
return True
-async def async_get_entry_scenes(entry: ConfigEntry, api):
- """Get the scenes within an integration."""
- try:
- return await api.scenes(location_id=entry.data[CONF_LOCATION_ID])
- except ClientResponseError as ex:
- if ex.status == HTTPStatus.FORBIDDEN:
- _LOGGER.exception(
- (
- "Unable to load scenes for configuration entry '%s' because the"
- " access token does not have the required access"
- ),
- entry.title,
- )
- else:
- raise
- return []
-
-
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: SmartThingsConfigEntry
+) -> bool:
"""Unload a config entry."""
- broker = hass.data[DOMAIN][DATA_BROKERS].pop(entry.entry_id, None)
- if broker:
- broker.disconnect()
-
+ client = entry.runtime_data.client
+ if (subscription_id := entry.data.get(CONF_SUBSCRIPTION_ID)) is not None:
+ with contextlib.suppress(SmartThingsConnectionError):
+ await client.delete_subscription(subscription_id)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Perform clean-up when entry is being removed."""
- api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN])
+async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Handle config entry migration."""
- # Remove the installed_app, which if already removed raises a HTTPStatus.FORBIDDEN error.
- installed_app_id = entry.data[CONF_INSTALLED_APP_ID]
- try:
- await api.delete_installed_app(installed_app_id)
- except ClientResponseError as ex:
- if ex.status == HTTPStatus.FORBIDDEN:
- _LOGGER.debug(
- "Installed app %s has already been removed",
- installed_app_id,
- exc_info=True,
- )
- else:
- raise
- _LOGGER.debug("Removed installed app %s", installed_app_id)
-
- # Remove the app if not referenced by other entries, which if already
- # removed raises a HTTPStatus.FORBIDDEN error.
- all_entries = hass.config_entries.async_entries(DOMAIN)
- app_id = entry.data[CONF_APP_ID]
- app_count = sum(1 for entry in all_entries if entry.data[CONF_APP_ID] == app_id)
- if app_count > 1:
- _LOGGER.debug(
- (
- "App %s was not removed because it is in use by other configuration"
- " entries"
- ),
- app_id,
+ if entry.version < 3:
+ # We keep the old data around, so we can use that to clean up the webhook in the future
+ hass.config_entries.async_update_entry(
+ entry, version=3, data={OLD_DATA: dict(entry.data)}
)
- return
- # Remove the app
- try:
- await api.delete_app(app_id)
- except ClientResponseError as ex:
- if ex.status == HTTPStatus.FORBIDDEN:
- _LOGGER.debug("App %s has already been removed", app_id, exc_info=True)
- else:
- raise
- _LOGGER.debug("Removed app %s", app_id)
- if len(all_entries) == 1:
- await unload_smartapp_endpoint(hass)
+ if entry.minor_version < 2:
-
-class DeviceBroker:
- """Manages an individual SmartThings config entry."""
-
- def __init__(
- self,
- hass: HomeAssistant,
- entry: ConfigEntry,
- token,
- smart_app,
- devices: Iterable,
- scenes: Iterable,
- ) -> None:
- """Create a new instance of the DeviceBroker."""
- self._hass = hass
- self._entry = entry
- self._installed_app_id = entry.data[CONF_INSTALLED_APP_ID]
- self._smart_app = smart_app
- self._token = token
- self._event_disconnect = None
- self._regenerate_token_remove = None
- self._assignments = self._assign_capabilities(devices)
- self.devices = {device.device_id: device for device in devices}
- self.scenes = {scene.scene_id: scene for scene in scenes}
-
- def _assign_capabilities(self, devices: Iterable):
- """Assign platforms to capabilities."""
- assignments = {}
- for device in devices:
- capabilities = device.capabilities.copy()
- slots = {}
- for platform in PLATFORMS:
- platform_module = importlib.import_module(
- f".{platform}", self.__module__
+ def migrate_entities(entity_entry: RegistryEntry) -> dict[str, Any] | None:
+ if entity_entry.domain == "binary_sensor":
+ device_id, attribute = entity_entry.unique_id.split(".")
+ if (
+ capability := BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES.get(
+ attribute
+ )
+ ) is None:
+ return None
+ new_unique_id = (
+ f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}"
)
- if not hasattr(platform_module, "get_capabilities"):
- continue
- assigned = platform_module.get_capabilities(capabilities)
- if not assigned:
- continue
- # Draw-down capabilities and set slot assignment
- for capability in assigned:
- if capability not in capabilities:
- continue
- capabilities.remove(capability)
- slots[capability] = platform
- assignments[device.device_id] = slots
- return assignments
+ return {
+ "new_unique_id": new_unique_id,
+ }
+ if entity_entry.domain in {"cover", "climate", "fan", "light", "lock"}:
+ return {"new_unique_id": f"{entity_entry.unique_id}_{MAIN}"}
+ if entity_entry.domain == "sensor":
+ delimiter = "." if " " not in entity_entry.unique_id else " "
+ if delimiter not in entity_entry.unique_id:
+ return None
+ device_id, attribute = entity_entry.unique_id.split(
+ delimiter, maxsplit=1
+ )
+ if (
+ capability := SENSOR_ATTRIBUTES_TO_CAPABILITIES.get(attribute)
+ ) is None:
+ if attribute in {
+ "energy_meter",
+ "power_meter",
+ "deltaEnergy_meter",
+ "powerEnergy_meter",
+ "energySaved_meter",
+ }:
+ return {
+ "new_unique_id": f"{device_id}_{MAIN}_{Capability.POWER_CONSUMPTION_REPORT}_{Attribute.POWER_CONSUMPTION}_{attribute}",
+ }
+ if attribute in {
+ "X Coordinate",
+ "Y Coordinate",
+ "Z Coordinate",
+ }:
+ new_attribute = {
+ "X Coordinate": "x_coordinate",
+ "Y Coordinate": "y_coordinate",
+ "Z Coordinate": "z_coordinate",
+ }[attribute]
+ return {
+ "new_unique_id": f"{device_id}_{MAIN}_{Capability.THREE_AXIS}_{Attribute.THREE_AXIS}_{new_attribute}",
+ }
+ if attribute in {
+ Attribute.MACHINE_STATE,
+ Attribute.COMPLETION_TIME,
+ }:
+ capability = determine_machine_type(
+ hass, entry.entry_id, device_id
+ )
+ if capability is None:
+ return None
+ return {
+ "new_unique_id": f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}",
+ }
+ return None
+ return {
+ "new_unique_id": f"{device_id}_{MAIN}_{capability}_{attribute}_{attribute}",
+ }
- def connect(self):
- """Connect handlers/listeners for device/lifecycle events."""
+ if entity_entry.domain == "switch":
+ return {
+ "new_unique_id": f"{entity_entry.unique_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}",
+ }
- # Setup interval to regenerate the refresh token on a periodic basis.
- # Tokens expire in 30 days and once expired, cannot be recovered.
- async def regenerate_refresh_token(now):
- """Generate a new refresh token and update the config entry."""
- await self._token.refresh(
- self._entry.data[CONF_CLIENT_ID],
- self._entry.data[CONF_CLIENT_SECRET],
- )
- self._hass.config_entries.async_update_entry(
- self._entry,
- data={
- **self._entry.data,
- CONF_REFRESH_TOKEN: self._token.refresh_token,
- },
- )
- _LOGGER.debug(
- "Regenerated refresh token for installed app: %s",
- self._installed_app_id,
- )
+ return None
- self._regenerate_token_remove = async_track_time_interval(
- self._hass, regenerate_refresh_token, TOKEN_REFRESH_INTERVAL
+ await async_migrate_entries(hass, entry.entry_id, migrate_entities)
+ hass.config_entries.async_update_entry(
+ entry,
+ minor_version=2,
)
- # Connect handler to incoming device events
- self._event_disconnect = self._smart_app.connect_event(self._event_handler)
+ return True
- def disconnect(self):
- """Disconnects handlers/listeners for device/lifecycle events."""
- if self._regenerate_token_remove:
- self._regenerate_token_remove()
- if self._event_disconnect:
- self._event_disconnect()
- def get_assigned(self, device_id: str, platform: str):
- """Get the capabilities assigned to the platform."""
- slots = self._assignments.get(device_id, {})
- return [key for key, value in slots.items() if value == platform]
+def determine_machine_type(
+ hass: HomeAssistant,
+ entry_id: str,
+ device_id: str,
+) -> Capability | None:
+ """Determine the machine type for a device."""
+ entity_registry = er.async_get(hass)
+ entries = er.async_entries_for_config_entry(entity_registry, entry_id)
+ device_entries = [entry for entry in entries if device_id in entry.unique_id]
+ for entry in device_entries:
+ if Attribute.DISHWASHER_JOB_STATE in entry.unique_id:
+ return Capability.DISHWASHER_OPERATING_STATE
+ if Attribute.WASHER_JOB_STATE in entry.unique_id:
+ return Capability.WASHER_OPERATING_STATE
+ if Attribute.DRYER_JOB_STATE in entry.unique_id:
+ return Capability.DRYER_OPERATING_STATE
+ if Attribute.OVEN_JOB_STATE in entry.unique_id:
+ return Capability.OVEN_OPERATING_STATE
+ return None
- def any_assigned(self, device_id: str, platform: str):
- """Return True if the platform has any assigned capabilities."""
- slots = self._assignments.get(device_id, {})
- return any(value for value in slots.values() if value == platform)
- async def _event_handler(self, req, resp, app):
- """Broker for incoming events."""
- # Do not process events received from a different installed app
- # under the same parent SmartApp (valid use-scenario)
- if req.installed_app_id != self._installed_app_id:
- return
-
- updated_devices = set()
- for evt in req.events:
- if evt.event_type != EVENT_TYPE_DEVICE:
- continue
- if not (device := self.devices.get(evt.device_id)):
- continue
- device.status.apply_attribute_update(
- evt.component_id,
- evt.capability,
- evt.attribute,
- evt.value,
- data=evt.data,
+def create_devices(
+ device_registry: dr.DeviceRegistry,
+ devices: dict[str, FullDevice],
+ entry: SmartThingsConfigEntry,
+ rooms: dict[str, str],
+) -> None:
+ """Create devices in the device registry."""
+ for device in sorted(
+ devices.values(), key=lambda d: d.device.parent_device_id or ""
+ ):
+ kwargs: dict[str, Any] = {}
+ if device.device.hub is not None:
+ kwargs = {
+ ATTR_SW_VERSION: device.device.hub.firmware_version,
+ ATTR_MODEL: device.device.hub.hardware_type,
+ }
+ if device.device.hub.mac_address:
+ kwargs[ATTR_CONNECTIONS] = {
+ (dr.CONNECTION_NETWORK_MAC, device.device.hub.mac_address)
+ }
+ if device.device.parent_device_id and device.device.parent_device_id in devices:
+ kwargs[ATTR_VIA_DEVICE] = (DOMAIN, device.device.parent_device_id)
+ if (ocf := device.device.ocf) is not None:
+ kwargs.update(
+ {
+ ATTR_MANUFACTURER: ocf.manufacturer_name,
+ ATTR_MODEL: (
+ (ocf.model_number.split("|")[0]) if ocf.model_number else None
+ ),
+ ATTR_HW_VERSION: ocf.hardware_version,
+ ATTR_SW_VERSION: ocf.firmware_version,
+ }
)
-
- # Fire events for buttons
- if (
- evt.capability == Capability.button
- and evt.attribute == Attribute.button
- ):
- data = {
- "component_id": evt.component_id,
- "device_id": evt.device_id,
- "location_id": evt.location_id,
- "value": evt.value,
- "name": device.label,
- "data": evt.data,
+ if (viper := device.device.viper) is not None:
+ kwargs.update(
+ {
+ ATTR_MANUFACTURER: viper.manufacturer_name,
+ ATTR_MODEL: viper.model_name,
+ ATTR_HW_VERSION: viper.hardware_version,
+ ATTR_SW_VERSION: viper.software_version,
}
- self._hass.bus.async_fire(EVENT_BUTTON, data)
- _LOGGER.debug("Fired button event: %s", data)
- else:
- data = {
- "location_id": evt.location_id,
- "device_id": evt.device_id,
- "component_id": evt.component_id,
- "capability": evt.capability,
- "attribute": evt.attribute,
- "value": evt.value,
- "data": evt.data,
- }
- _LOGGER.debug("Push update received: %s", data)
+ )
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, device.device.device_id)},
+ configuration_url="https://account.smartthings.com",
+ name=device.device.label,
+ suggested_area=(
+ rooms.get(device.device.room_id) if device.device.room_id else None
+ ),
+ **kwargs,
+ )
- updated_devices.add(device.device_id)
- async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE, updated_devices)
+KEEP_CAPABILITY_QUIRK: dict[
+ Capability | str, Callable[[dict[Attribute | str, Status]], bool]
+] = {
+ Capability.DRYER_OPERATING_STATE: (
+ lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None
+ ),
+ Capability.WASHER_OPERATING_STATE: (
+ lambda status: status[Attribute.SUPPORTED_MACHINE_STATES].value is not None
+ ),
+ Capability.DEMAND_RESPONSE_LOAD_CONTROL: lambda _: True,
+}
+
+
+def process_status(status: dict[str, ComponentStatus]) -> dict[str, ComponentStatus]:
+ """Remove disabled capabilities from status."""
+ if (main_component := status.get(MAIN)) is None:
+ return status
+ if (
+ disabled_components_capability := main_component.get(
+ Capability.CUSTOM_DISABLED_COMPONENTS
+ )
+ ) is not None:
+ disabled_components = cast(
+ list[str],
+ disabled_components_capability[Attribute.DISABLED_COMPONENTS].value,
+ )
+ if disabled_components is not None:
+ for component in disabled_components:
+ if component in status:
+ del status[component]
+ for component_status in status.values():
+ process_component_status(component_status)
+ return status
+
+
+def process_component_status(status: ComponentStatus) -> None:
+ """Remove disabled capabilities from component status."""
+ if (
+ disabled_capabilities_capability := status.get(
+ Capability.CUSTOM_DISABLED_CAPABILITIES
+ )
+ ) is not None:
+ disabled_capabilities = cast(
+ list[Capability | str],
+ disabled_capabilities_capability[Attribute.DISABLED_CAPABILITIES].value,
+ )
+ if disabled_capabilities is not None:
+ for capability in disabled_capabilities:
+ if capability in status and (
+ capability not in KEEP_CAPABILITY_QUIRK
+ or not KEEP_CAPABILITY_QUIRK[capability](status[capability])
+ ):
+ del status[capability]
diff --git a/homeassistant/components/smartthings/application_credentials.py b/homeassistant/components/smartthings/application_credentials.py
new file mode 100644
index 00000000000..1e637c6bd12
--- /dev/null
+++ b/homeassistant/components/smartthings/application_credentials.py
@@ -0,0 +1,64 @@
+"""Application credentials platform for SmartThings."""
+
+from json import JSONDecodeError
+import logging
+from typing import cast
+
+from aiohttp import BasicAuth, ClientError
+
+from homeassistant.components.application_credentials import (
+ AuthImplementation,
+ AuthorizationServer,
+ ClientCredential,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_auth_implementation(
+ hass: HomeAssistant, auth_domain: str, credential: ClientCredential
+) -> AbstractOAuth2Implementation:
+ """Return auth implementation."""
+ return SmartThingsOAuth2Implementation(
+ hass,
+ DOMAIN,
+ credential,
+ authorization_server=AuthorizationServer(
+ authorize_url="https://api.smartthings.com/oauth/authorize",
+ token_url="https://auth-global.api.smartthings.com/oauth/token",
+ ),
+ )
+
+
+class SmartThingsOAuth2Implementation(AuthImplementation):
+ """Oauth2 implementation that only uses the external url."""
+
+ async def _token_request(self, data: dict) -> dict:
+ """Make a token request."""
+ session = async_get_clientsession(self.hass)
+
+ resp = await session.post(
+ self.token_url,
+ data=data,
+ auth=BasicAuth(self.client_id, self.client_secret),
+ )
+ if resp.status >= 400:
+ try:
+ error_response = await resp.json()
+ except (ClientError, JSONDecodeError):
+ error_response = {}
+ error_code = error_response.get("error", "unknown")
+ error_description = error_response.get("error_description", "unknown error")
+ _LOGGER.error(
+ "Token request for %s failed (%s): %s",
+ self.domain,
+ error_code,
+ error_description,
+ )
+ resp.raise_for_status()
+ return cast(dict, await resp.json())
diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py
index 611473b011d..74d561f08ac 100644
--- a/homeassistant/components/smartthings/binary_sensor.py
+++ b/homeassistant/components/smartthings/binary_sensor.py
@@ -2,84 +2,287 @@
from __future__ import annotations
-from collections.abc import Sequence
+from collections.abc import Callable
+from dataclasses import dataclass
-from pysmartthings import Attribute, Capability
+from pysmartthings import Attribute, Capability, Category, SmartThings, Status
from homeassistant.components.binary_sensor import (
+ DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
BinarySensorEntity,
+ BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DATA_BROKERS, DOMAIN
+from . import FullDevice, SmartThingsConfigEntry
+from .const import INVALID_SWITCH_CATEGORIES, MAIN
from .entity import SmartThingsEntity
+from .util import deprecate_entity
-CAPABILITY_TO_ATTRIB = {
- Capability.acceleration_sensor: Attribute.acceleration,
- Capability.contact_sensor: Attribute.contact,
- Capability.filter_status: Attribute.filter_status,
- Capability.motion_sensor: Attribute.motion,
- Capability.presence_sensor: Attribute.presence,
- Capability.sound_sensor: Attribute.sound,
- Capability.tamper_alert: Attribute.tamper,
- Capability.valve: Attribute.valve,
- Capability.water_sensor: Attribute.water,
-}
-ATTRIB_TO_CLASS = {
- Attribute.acceleration: BinarySensorDeviceClass.MOVING,
- Attribute.contact: BinarySensorDeviceClass.OPENING,
- Attribute.filter_status: BinarySensorDeviceClass.PROBLEM,
- Attribute.motion: BinarySensorDeviceClass.MOTION,
- Attribute.presence: BinarySensorDeviceClass.PRESENCE,
- Attribute.sound: BinarySensorDeviceClass.SOUND,
- Attribute.tamper: BinarySensorDeviceClass.PROBLEM,
- Attribute.valve: BinarySensorDeviceClass.OPENING,
- Attribute.water: BinarySensorDeviceClass.MOISTURE,
-}
-ATTRIB_TO_ENTTIY_CATEGORY = {
- Attribute.tamper: EntityCategory.DIAGNOSTIC,
+
+@dataclass(frozen=True, kw_only=True)
+class SmartThingsBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Describe a SmartThings binary sensor entity."""
+
+ is_on_key: str
+ category_device_class: dict[Category | str, BinarySensorDeviceClass] | None = None
+ category: set[Category] | None = None
+ exists_fn: Callable[[str], bool] | None = None
+ component_translation_key: dict[str, str] | None = None
+ deprecated_fn: Callable[
+ [dict[str, dict[Capability | str, dict[Attribute | str, Status]]]], str | None
+ ] = lambda _: None
+
+
+CAPABILITY_TO_SENSORS: dict[
+ Capability, dict[Attribute, SmartThingsBinarySensorEntityDescription]
+] = {
+ Capability.ACCELERATION_SENSOR: {
+ Attribute.ACCELERATION: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.ACCELERATION,
+ translation_key="acceleration",
+ device_class=BinarySensorDeviceClass.MOVING,
+ is_on_key="active",
+ )
+ },
+ Capability.CONTACT_SENSOR: {
+ Attribute.CONTACT: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.CONTACT,
+ device_class=BinarySensorDeviceClass.DOOR,
+ is_on_key="open",
+ category_device_class={
+ Category.GARAGE_DOOR: BinarySensorDeviceClass.GARAGE_DOOR,
+ Category.DOOR: BinarySensorDeviceClass.DOOR,
+ Category.WINDOW: BinarySensorDeviceClass.WINDOW,
+ },
+ exists_fn=lambda key: key in {"freezer", "cooler", "cvroom"},
+ component_translation_key={
+ "freezer": "freezer_door",
+ "cooler": "cooler_door",
+ "cvroom": "cool_select_plus_door",
+ },
+ deprecated_fn=(
+ lambda status: "fridge_door"
+ if "freezer" in status and "cooler" in status
+ else None
+ ),
+ )
+ },
+ Capability.CUSTOM_DRYER_WRINKLE_PREVENT: {
+ Attribute.OPERATING_STATE: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.OPERATING_STATE,
+ translation_key="dryer_wrinkle_prevent_active",
+ is_on_key="running",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ },
+ Capability.FILTER_STATUS: {
+ Attribute.FILTER_STATUS: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.FILTER_STATUS,
+ translation_key="filter_status",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ is_on_key="replace",
+ )
+ },
+ Capability.SAMSUNG_CE_KIDS_LOCK: {
+ Attribute.LOCK_STATE: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.LOCK_STATE,
+ translation_key="child_lock",
+ is_on_key="locked",
+ )
+ },
+ Capability.MOTION_SENSOR: {
+ Attribute.MOTION: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.MOTION,
+ device_class=BinarySensorDeviceClass.MOTION,
+ is_on_key="active",
+ )
+ },
+ Capability.PRESENCE_SENSOR: {
+ Attribute.PRESENCE: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.PRESENCE,
+ device_class=BinarySensorDeviceClass.PRESENCE,
+ is_on_key="present",
+ )
+ },
+ Capability.REMOTE_CONTROL_STATUS: {
+ Attribute.REMOTE_CONTROL_ENABLED: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.REMOTE_CONTROL_ENABLED,
+ translation_key="remote_control",
+ is_on_key="true",
+ )
+ },
+ Capability.SOUND_SENSOR: {
+ Attribute.SOUND: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.SOUND,
+ device_class=BinarySensorDeviceClass.SOUND,
+ is_on_key="detected",
+ )
+ },
+ Capability.SWITCH: {
+ Attribute.SWITCH: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.SWITCH,
+ device_class=BinarySensorDeviceClass.POWER,
+ is_on_key="on",
+ category=INVALID_SWITCH_CATEGORIES,
+ )
+ },
+ Capability.TAMPER_ALERT: {
+ Attribute.TAMPER: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.TAMPER,
+ device_class=BinarySensorDeviceClass.TAMPER,
+ is_on_key="detected",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ },
+ Capability.VALVE: {
+ Attribute.VALVE: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.VALVE,
+ translation_key="valve",
+ device_class=BinarySensorDeviceClass.OPENING,
+ is_on_key="open",
+ deprecated_fn=lambda _: "valve",
+ )
+ },
+ Capability.WATER_SENSOR: {
+ Attribute.WATER: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.WATER,
+ device_class=BinarySensorDeviceClass.MOISTURE,
+ is_on_key="wet",
+ )
+ },
+ Capability.SAMSUNG_CE_DOOR_STATE: {
+ Attribute.DOOR_STATE: SmartThingsBinarySensorEntityDescription(
+ key=Attribute.DOOR_STATE,
+ translation_key="door",
+ device_class=BinarySensorDeviceClass.OPENING,
+ is_on_key="open",
+ )
+ },
}
+def get_main_component_category(
+ device: FullDevice,
+) -> Category | str:
+ """Get the main component of a device."""
+ main = device.device.components[MAIN]
+ return main.user_category or main.manufacturer_category
+
+
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add binary sensors for a config entry."""
- broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
- sensors = []
- for device in broker.devices.values():
- for capability in broker.get_assigned(device.device_id, "binary_sensor"):
- attrib = CAPABILITY_TO_ATTRIB[capability]
- sensors.append(SmartThingsBinarySensor(device, attrib))
- async_add_entities(sensors)
+ entry_data = entry.runtime_data
+ entities = []
+ entity_registry = er.async_get(hass)
-def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
- """Return all capabilities supported if minimum required are present."""
- return [
- capability for capability in CAPABILITY_TO_ATTRIB if capability in capabilities
- ]
+ for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks
+ for capability, attribute_map in CAPABILITY_TO_SENSORS.items():
+ for attribute, description in attribute_map.items():
+ for component in device.status:
+ if (
+ capability in device.status[component]
+ and (
+ component == MAIN
+ or (
+ description.exists_fn is not None
+ and description.exists_fn(component)
+ )
+ )
+ and (
+ not description.category
+ or get_main_component_category(device)
+ in description.category
+ )
+ ):
+ if (
+ component == MAIN
+ and (issue := description.deprecated_fn(device.status))
+ is not None
+ ):
+ if deprecate_entity(
+ hass,
+ entity_registry,
+ BINARY_SENSOR_DOMAIN,
+ f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}",
+ f"deprecated_binary_{issue}",
+ ):
+ entities.append(
+ SmartThingsBinarySensor(
+ entry_data.client,
+ device,
+ description,
+ capability,
+ attribute,
+ component,
+ )
+ )
+ continue
+ entities.append(
+ SmartThingsBinarySensor(
+ entry_data.client,
+ device,
+ description,
+ capability,
+ attribute,
+ component,
+ )
+ )
+
+ async_add_entities(entities)
class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorEntity):
"""Define a SmartThings Binary Sensor."""
- def __init__(self, device, attribute):
+ entity_description: SmartThingsBinarySensorEntityDescription
+
+ def __init__(
+ self,
+ client: SmartThings,
+ device: FullDevice,
+ entity_description: SmartThingsBinarySensorEntityDescription,
+ capability: Capability,
+ attribute: Attribute,
+ component: str,
+ ) -> None:
"""Init the class."""
- super().__init__(device)
+ super().__init__(client, device, {capability}, component=component)
self._attribute = attribute
- self._attr_name = f"{device.label} {attribute}"
- self._attr_unique_id = f"{device.device_id}.{attribute}"
- self._attr_device_class = ATTRIB_TO_CLASS[attribute]
- self._attr_entity_category = ATTRIB_TO_ENTTIY_CATEGORY.get(attribute)
+ self.capability = capability
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{attribute}_{attribute}"
+ if (
+ entity_description.category_device_class
+ and (category := get_main_component_category(device))
+ in entity_description.category_device_class
+ ):
+ self._attr_device_class = entity_description.category_device_class[category]
+ self._attr_name = None
+ if (
+ entity_description.component_translation_key is not None
+ and (
+ translation_key := entity_description.component_translation_key.get(
+ component
+ )
+ )
+ is not None
+ ):
+ self._attr_translation_key = translation_key
@property
- def is_on(self):
+ def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
- return self._device.status.is_on(self._attribute)
+ return (
+ self.get_attribute_value(self.capability, self._attribute)
+ == self.entity_description.is_on_key
+ )
diff --git a/homeassistant/components/smartthings/button.py b/homeassistant/components/smartthings/button.py
new file mode 100644
index 00000000000..00fbaa0e2c4
--- /dev/null
+++ b/homeassistant/components/smartthings/button.py
@@ -0,0 +1,78 @@
+"""Support for button entities through the SmartThings cloud API."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from pysmartthings import Capability, Command, SmartThings
+
+from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import FullDevice, SmartThingsConfigEntry
+from .const import MAIN
+from .entity import SmartThingsEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class SmartThingsButtonDescription(ButtonEntityDescription):
+ """Class describing SmartThings button entities."""
+
+ key: Capability
+ command: Command
+
+
+CAPABILITIES_TO_BUTTONS: dict[Capability | str, SmartThingsButtonDescription] = {
+ Capability.OVEN_OPERATING_STATE: SmartThingsButtonDescription(
+ key=Capability.OVEN_OPERATING_STATE,
+ translation_key="stop",
+ command=Command.STOP,
+ ),
+ Capability.CUSTOM_WATER_FILTER: SmartThingsButtonDescription(
+ key=Capability.CUSTOM_WATER_FILTER,
+ translation_key="reset_water_filter",
+ command=Command.RESET_WATER_FILTER,
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add button entities for a config entry."""
+ entry_data = entry.runtime_data
+ async_add_entities(
+ SmartThingsButtonEntity(
+ entry_data.client, device, CAPABILITIES_TO_BUTTONS[capability]
+ )
+ for device in entry_data.devices.values()
+ for capability in device.status[MAIN]
+ if capability in CAPABILITIES_TO_BUTTONS
+ )
+
+
+class SmartThingsButtonEntity(SmartThingsEntity, ButtonEntity):
+ """Define a SmartThings button."""
+
+ entity_description: SmartThingsButtonDescription
+
+ def __init__(
+ self,
+ client: SmartThings,
+ device: FullDevice,
+ entity_description: SmartThingsButtonDescription,
+ ) -> None:
+ """Initialize the instance."""
+ super().__init__(client, device, set())
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{entity_description.key}_{entity_description.command}"
+
+ async def async_press(self) -> None:
+ """Press the button."""
+ await self.execute_device_command(
+ self.entity_description.key,
+ self.entity_description.command,
+ )
diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py
index d9535272295..f2f9479584c 100644
--- a/homeassistant/components/smartthings/climate.py
+++ b/homeassistant/components/smartthings/climate.py
@@ -3,17 +3,15 @@
from __future__ import annotations
import asyncio
-from collections.abc import Iterable, Sequence
import logging
from typing import Any
-from pysmartthings import Attribute, Capability
+from pysmartthings import Attribute, Capability, Command, SmartThings
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
- DOMAIN as CLIMATE_DOMAIN,
SWING_BOTH,
SWING_HORIZONTAL,
SWING_OFF,
@@ -23,12 +21,12 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DATA_BROKERS, DOMAIN
+from . import FullDevice, SmartThingsConfigEntry
+from .const import MAIN
from .entity import SmartThingsEntity
ATTR_OPERATION_STATE = "operation_state"
@@ -97,124 +95,106 @@ UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT}
_LOGGER = logging.getLogger(__name__)
+AC_CAPABILITIES = [
+ Capability.AIR_CONDITIONER_MODE,
+ Capability.AIR_CONDITIONER_FAN_MODE,
+ Capability.SWITCH,
+ Capability.TEMPERATURE_MEASUREMENT,
+ Capability.THERMOSTAT_COOLING_SETPOINT,
+]
+
+THERMOSTAT_CAPABILITIES = [
+ Capability.TEMPERATURE_MEASUREMENT,
+ Capability.THERMOSTAT_HEATING_SETPOINT,
+ Capability.THERMOSTAT_MODE,
+]
+
+
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add climate entities for a config entry."""
- ac_capabilities = [
- Capability.air_conditioner_mode,
- Capability.air_conditioner_fan_mode,
- Capability.switch,
- Capability.temperature_measurement,
- Capability.thermostat_cooling_setpoint,
+ entry_data = entry.runtime_data
+ entities: list[ClimateEntity] = [
+ SmartThingsAirConditioner(entry_data.client, device)
+ for device in entry_data.devices.values()
+ if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES)
]
-
- broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
- entities: list[ClimateEntity] = []
- for device in broker.devices.values():
- if not broker.any_assigned(device.device_id, CLIMATE_DOMAIN):
- continue
- if all(capability in device.capabilities for capability in ac_capabilities):
- entities.append(SmartThingsAirConditioner(device))
- else:
- entities.append(SmartThingsThermostat(device))
- async_add_entities(entities, True)
-
-
-def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
- """Return all capabilities supported if minimum required are present."""
- supported = [
- Capability.air_conditioner_mode,
- Capability.demand_response_load_control,
- Capability.air_conditioner_fan_mode,
- Capability.switch,
- Capability.thermostat,
- Capability.thermostat_cooling_setpoint,
- Capability.thermostat_fan_mode,
- Capability.thermostat_heating_setpoint,
- Capability.thermostat_mode,
- Capability.thermostat_operating_state,
- ]
- # Can have this legacy/deprecated capability
- if Capability.thermostat in capabilities:
- return supported
- # Or must have all of these thermostat capabilities
- thermostat_capabilities = [
- Capability.temperature_measurement,
- Capability.thermostat_heating_setpoint,
- Capability.thermostat_mode,
- ]
- if all(capability in capabilities for capability in thermostat_capabilities):
- return supported
- # Or must have all of these A/C capabilities
- ac_capabilities = [
- Capability.air_conditioner_mode,
- Capability.air_conditioner_fan_mode,
- Capability.switch,
- Capability.temperature_measurement,
- Capability.thermostat_cooling_setpoint,
- ]
- if all(capability in capabilities for capability in ac_capabilities):
- return supported
- return None
+ entities.extend(
+ SmartThingsThermostat(entry_data.client, device)
+ for device in entry_data.devices.values()
+ if all(
+ capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES
+ )
+ )
+ async_add_entities(entities)
class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
"""Define a SmartThings climate entities."""
- def __init__(self, device):
- """Init the class."""
- super().__init__(device)
- self._attr_supported_features = self._determine_features()
- self._hvac_mode = None
- self._hvac_modes = None
+ _attr_name = None
- def _determine_features(self):
+ def __init__(self, client: SmartThings, device: FullDevice) -> None:
+ """Init the class."""
+ super().__init__(
+ client,
+ device,
+ {
+ Capability.THERMOSTAT_FAN_MODE,
+ Capability.THERMOSTAT_MODE,
+ Capability.TEMPERATURE_MEASUREMENT,
+ Capability.THERMOSTAT_HEATING_SETPOINT,
+ Capability.THERMOSTAT_OPERATING_STATE,
+ Capability.THERMOSTAT_COOLING_SETPOINT,
+ Capability.RELATIVE_HUMIDITY_MEASUREMENT,
+ },
+ )
+ self._attr_supported_features = self._determine_features()
+
+ def _determine_features(self) -> ClimateEntityFeature:
flags = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
- if self._device.get_capability(
- Capability.thermostat_fan_mode, Capability.thermostat
- ):
+ if self.supports_capability(Capability.THERMOSTAT_FAN_MODE):
flags |= ClimateEntityFeature.FAN_MODE
return flags
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
- await self._device.set_thermostat_fan_mode(fan_mode, set_status=True)
-
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_schedule_update_ha_state(True)
+ await self.execute_device_command(
+ Capability.THERMOSTAT_FAN_MODE,
+ Command.SET_THERMOSTAT_FAN_MODE,
+ argument=fan_mode,
+ )
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target operation mode."""
- mode = STATE_TO_MODE[hvac_mode]
- await self._device.set_thermostat_mode(mode, set_status=True)
-
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_schedule_update_ha_state(True)
+ await self.execute_device_command(
+ Capability.THERMOSTAT_MODE,
+ Command.SET_THERMOSTAT_MODE,
+ argument=STATE_TO_MODE[hvac_mode],
+ )
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new operation mode and target temperatures."""
+ hvac_mode = self.hvac_mode
# Operation state
if operation_state := kwargs.get(ATTR_HVAC_MODE):
- mode = STATE_TO_MODE[operation_state]
- await self._device.set_thermostat_mode(mode, set_status=True)
- await self.async_update()
+ await self.async_set_hvac_mode(operation_state)
+ hvac_mode = operation_state
# Heat/cool setpoint
heating_setpoint = None
cooling_setpoint = None
- if self.hvac_mode == HVACMode.HEAT:
+ if hvac_mode == HVACMode.HEAT:
heating_setpoint = kwargs.get(ATTR_TEMPERATURE)
- elif self.hvac_mode == HVACMode.COOL:
+ elif hvac_mode == HVACMode.COOL:
cooling_setpoint = kwargs.get(ATTR_TEMPERATURE)
else:
heating_setpoint = kwargs.get(ATTR_TARGET_TEMP_LOW)
@@ -222,137 +202,158 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateEntity):
tasks = []
if heating_setpoint is not None:
tasks.append(
- self._device.set_heating_setpoint(
- round(heating_setpoint, 3), set_status=True
+ self.execute_device_command(
+ Capability.THERMOSTAT_HEATING_SETPOINT,
+ Command.SET_HEATING_SETPOINT,
+ argument=round(heating_setpoint, 3),
)
)
if cooling_setpoint is not None:
tasks.append(
- self._device.set_cooling_setpoint(
- round(cooling_setpoint, 3), set_status=True
+ self.execute_device_command(
+ Capability.THERMOSTAT_COOLING_SETPOINT,
+ Command.SET_COOLING_SETPOINT,
+ argument=round(cooling_setpoint, 3),
)
)
await asyncio.gather(*tasks)
- # State is set optimistically in the commands above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_schedule_update_ha_state(True)
-
- async def async_update(self) -> None:
- """Update the attributes of the climate device."""
- thermostat_mode = self._device.status.thermostat_mode
- self._hvac_mode = MODE_TO_STATE.get(thermostat_mode)
- if self._hvac_mode is None:
- _LOGGER.debug(
- "Device %s (%s) returned an invalid hvac mode: %s",
- self._device.label,
- self._device.device_id,
- thermostat_mode,
- )
-
- modes = set()
- supported_modes = self._device.status.supported_thermostat_modes
- if isinstance(supported_modes, Iterable):
- for mode in supported_modes:
- if (state := MODE_TO_STATE.get(mode)) is not None:
- modes.add(state)
- else:
- _LOGGER.debug(
- (
- "Device %s (%s) returned an invalid supported thermostat"
- " mode: %s"
- ),
- self._device.label,
- self._device.device_id,
- mode,
- )
- else:
- _LOGGER.debug(
- "Device %s (%s) returned invalid supported thermostat modes: %s",
- self._device.label,
- self._device.device_id,
- supported_modes,
- )
- self._hvac_modes = list(modes)
-
@property
- def current_humidity(self):
+ def current_humidity(self) -> float | None:
"""Return the current humidity."""
- return self._device.status.humidity
+ if self.supports_capability(Capability.RELATIVE_HUMIDITY_MEASUREMENT):
+ return self.get_attribute_value(
+ Capability.RELATIVE_HUMIDITY_MEASUREMENT, Attribute.HUMIDITY
+ )
+ return None
@property
- def current_temperature(self):
+ def current_temperature(self) -> float | None:
"""Return the current temperature."""
- return self._device.status.temperature
+ return self.get_attribute_value(
+ Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE
+ )
@property
- def fan_mode(self):
+ def fan_mode(self) -> str | None:
"""Return the fan setting."""
- return self._device.status.thermostat_fan_mode
+ return self.get_attribute_value(
+ Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE
+ )
@property
- def fan_modes(self):
+ def fan_modes(self) -> list[str]:
"""Return the list of available fan modes."""
- return self._device.status.supported_thermostat_fan_modes
+ return self.get_attribute_value(
+ Capability.THERMOSTAT_FAN_MODE, Attribute.SUPPORTED_THERMOSTAT_FAN_MODES
+ )
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current running hvac operation if supported."""
+ if not self.supports_capability(Capability.THERMOSTAT_OPERATING_STATE):
+ return None
return OPERATING_STATE_TO_ACTION.get(
- self._device.status.thermostat_operating_state
+ self.get_attribute_value(
+ Capability.THERMOSTAT_OPERATING_STATE,
+ Attribute.THERMOSTAT_OPERATING_STATE,
+ )
)
@property
- def hvac_mode(self) -> HVACMode:
+ def hvac_mode(self) -> HVACMode | None:
"""Return current operation ie. heat, cool, idle."""
- return self._hvac_mode
+ return MODE_TO_STATE.get(
+ self.get_attribute_value(
+ Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE
+ )
+ )
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available operation modes."""
- return self._hvac_modes
+ if (
+ supported_thermostat_modes := self.get_attribute_value(
+ Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES
+ )
+ ) is None:
+ return []
+ return [
+ state
+ for mode in supported_thermostat_modes
+ if (state := MODE_TO_STATE.get(mode)) is not None
+ ]
@property
- def target_temperature(self):
+ def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
if self.hvac_mode == HVACMode.COOL:
- return self._device.status.cooling_setpoint
+ return self.get_attribute_value(
+ Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT
+ )
if self.hvac_mode == HVACMode.HEAT:
- return self._device.status.heating_setpoint
+ return self.get_attribute_value(
+ Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT
+ )
return None
@property
- def target_temperature_high(self):
+ def target_temperature_high(self) -> float | None:
"""Return the highbound target temperature we try to reach."""
if self.hvac_mode == HVACMode.HEAT_COOL:
- return self._device.status.cooling_setpoint
+ return self.get_attribute_value(
+ Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT
+ )
return None
@property
def target_temperature_low(self):
"""Return the lowbound target temperature we try to reach."""
if self.hvac_mode == HVACMode.HEAT_COOL:
- return self._device.status.heating_setpoint
+ return self.get_attribute_value(
+ Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT
+ )
return None
@property
- def temperature_unit(self):
+ def temperature_unit(self) -> str:
"""Return the unit of measurement."""
- return UNIT_MAP.get(self._device.status.attributes[Attribute.temperature].unit)
+ # Offline third party thermostats may not have a unit
+ # Since climate always requires a unit, default to Celsius
+ if (
+ unit := self._internal_state[Capability.TEMPERATURE_MEASUREMENT][
+ Attribute.TEMPERATURE
+ ].unit
+ ) is None:
+ return UnitOfTemperature.CELSIUS
+ return UNIT_MAP[unit]
class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
"""Define a SmartThings Air Conditioner."""
- _hvac_modes: list[HVACMode]
+ _attr_name = None
- def __init__(self, device) -> None:
+ def __init__(self, client: SmartThings, device: FullDevice) -> None:
"""Init the class."""
- super().__init__(device)
- self._hvac_modes = []
- self._attr_preset_mode = None
+ super().__init__(
+ client,
+ device,
+ {
+ Capability.AIR_CONDITIONER_MODE,
+ Capability.SWITCH,
+ Capability.FAN_OSCILLATION_MODE,
+ Capability.AIR_CONDITIONER_FAN_MODE,
+ Capability.THERMOSTAT_COOLING_SETPOINT,
+ Capability.TEMPERATURE_MEASUREMENT,
+ Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
+ Capability.DEMAND_RESPONSE_LOAD_CONTROL,
+ },
+ )
+ self._attr_hvac_modes = self._determine_hvac_modes()
self._attr_preset_modes = self._determine_preset_modes()
- self._attr_swing_modes = self._determine_swing_modes()
+ if self.supports_capability(Capability.FAN_OSCILLATION_MODE):
+ self._attr_swing_modes = self._determine_swing_modes()
self._attr_supported_features = self._determine_supported_features()
def _determine_supported_features(self) -> ClimateEntityFeature:
@@ -362,7 +363,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
- if self._device.get_capability(Capability.fan_oscillation_mode):
+ if self.supports_capability(Capability.FAN_OSCILLATION_MODE):
features |= ClimateEntityFeature.SWING_MODE
if (self._attr_preset_modes is not None) and len(self._attr_preset_modes) > 0:
features |= ClimateEntityFeature.PRESET_MODE
@@ -370,14 +371,11 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
- await self._device.set_fan_mode(fan_mode, set_status=True)
-
- # setting the fan must reset the preset mode (it deactivates the windFree function)
- self._attr_preset_mode = None
-
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_write_ha_state()
+ await self.execute_device_command(
+ Capability.AIR_CONDITIONER_FAN_MODE,
+ Command.SET_FAN_MODE,
+ argument=fan_mode,
+ )
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target operation mode."""
@@ -386,23 +384,27 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
return
tasks = []
# Turn on the device if it's off before setting mode.
- if not self._device.status.switch:
- tasks.append(self._device.switch_on(set_status=True))
+ if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off":
+ tasks.append(self.async_turn_on())
mode = STATE_TO_AC_MODE[hvac_mode]
# If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" mode the AirConditioner new mode has to be "wind"
# The conversion make the mode change working
# The conversion is made only for device that wrongly has capability "wind" instead "fan_only"
if hvac_mode == HVACMode.FAN_ONLY:
- supported_modes = self._device.status.supported_ac_modes
- if WIND in supported_modes:
+ if WIND in self.get_attribute_value(
+ Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES
+ ):
mode = WIND
- tasks.append(self._device.set_air_conditioner_mode(mode, set_status=True))
+ tasks.append(
+ self.execute_device_command(
+ Capability.AIR_CONDITIONER_MODE,
+ Command.SET_AIR_CONDITIONER_MODE,
+ argument=mode,
+ )
+ )
await asyncio.gather(*tasks)
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -410,155 +412,181 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity):
# operation mode
if operation_mode := kwargs.get(ATTR_HVAC_MODE):
if operation_mode == HVACMode.OFF:
- tasks.append(self._device.switch_off(set_status=True))
+ tasks.append(self.async_turn_off())
else:
- if not self._device.status.switch:
- tasks.append(self._device.switch_on(set_status=True))
+ if (
+ self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
+ == "off"
+ ):
+ tasks.append(self.async_turn_on())
tasks.append(self.async_set_hvac_mode(operation_mode))
# temperature
tasks.append(
- self._device.set_cooling_setpoint(kwargs[ATTR_TEMPERATURE], set_status=True)
+ self.execute_device_command(
+ Capability.THERMOSTAT_COOLING_SETPOINT,
+ Command.SET_COOLING_SETPOINT,
+ argument=kwargs[ATTR_TEMPERATURE],
+ )
)
await asyncio.gather(*tasks)
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_write_ha_state()
async def async_turn_on(self) -> None:
"""Turn device on."""
- await self._device.switch_on(set_status=True)
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_write_ha_state()
+ await self.execute_device_command(
+ Capability.SWITCH,
+ Command.ON,
+ )
async def async_turn_off(self) -> None:
"""Turn device off."""
- await self._device.switch_off(set_status=True)
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_write_ha_state()
-
- async def async_update(self) -> None:
- """Update the calculated fields of the AC."""
- modes = {HVACMode.OFF}
- for mode in self._device.status.supported_ac_modes:
- if (state := AC_MODE_TO_STATE.get(mode)) is not None:
- modes.add(state)
- else:
- _LOGGER.debug(
- "Device %s (%s) returned an invalid supported AC mode: %s",
- self._device.label,
- self._device.device_id,
- mode,
- )
- self._hvac_modes = list(modes)
+ await self.execute_device_command(
+ Capability.SWITCH,
+ Command.OFF,
+ )
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
- return self._device.status.temperature
+ return self.get_attribute_value(
+ Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE
+ )
@property
- def extra_state_attributes(self) -> dict[str, Any]:
+ def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return device specific state attributes.
Include attributes from the Demand Response Load Control (drlc)
and Power Consumption capabilities.
"""
- attributes = [
- "drlc_status_duration",
- "drlc_status_level",
- "drlc_status_start",
- "drlc_status_override",
- ]
- state_attributes = {}
- for attribute in attributes:
- value = getattr(self._device.status, attribute)
- if value is not None:
- state_attributes[attribute] = value
- return state_attributes
+ if not self.supports_capability(Capability.DEMAND_RESPONSE_LOAD_CONTROL):
+ return None
+
+ drlc_status = self.get_attribute_value(
+ Capability.DEMAND_RESPONSE_LOAD_CONTROL,
+ Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS,
+ )
+ res = {}
+ for key in ("duration", "start", "override", "drlcLevel"):
+ if key in drlc_status:
+ dict_key = {"drlcLevel": "drlc_status_level"}.get(
+ key, f"drlc_status_{key}"
+ )
+ res[dict_key] = drlc_status[key]
+ return res
@property
def fan_mode(self) -> str:
"""Return the fan setting."""
- return self._device.status.fan_mode
+ return self.get_attribute_value(
+ Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE
+ )
@property
def fan_modes(self) -> list[str]:
"""Return the list of available fan modes."""
- return self._device.status.supported_ac_fan_modes
+ return self.get_attribute_value(
+ Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES
+ )
@property
def hvac_mode(self) -> HVACMode | None:
"""Return current operation ie. heat, cool, idle."""
- if not self._device.status.switch:
+ if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off":
return HVACMode.OFF
- return AC_MODE_TO_STATE.get(self._device.status.air_conditioner_mode)
-
- @property
- def hvac_modes(self) -> list[HVACMode]:
- """Return the list of available operation modes."""
- return self._hvac_modes
+ return AC_MODE_TO_STATE.get(
+ self.get_attribute_value(
+ Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE
+ )
+ )
@property
def target_temperature(self) -> float:
"""Return the temperature we try to reach."""
- return self._device.status.cooling_setpoint
+ return self.get_attribute_value(
+ Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT
+ )
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
- return UNIT_MAP[self._device.status.attributes[Attribute.temperature].unit]
+ unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][
+ Attribute.TEMPERATURE
+ ].unit
+ assert unit
+ return UNIT_MAP[unit]
def _determine_swing_modes(self) -> list[str] | None:
"""Return the list of available swing modes."""
- supported_swings = None
- supported_modes = self._device.status.attributes[
- Attribute.supported_fan_oscillation_modes
- ][0]
- if supported_modes is not None:
- supported_swings = [
- FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes
- ]
- return supported_swings
+ if (
+ supported_modes := self.get_attribute_value(
+ Capability.FAN_OSCILLATION_MODE,
+ Attribute.SUPPORTED_FAN_OSCILLATION_MODES,
+ )
+ ) is None:
+ return None
+ return [FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes]
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set swing mode."""
- fan_oscillation_mode = SWING_TO_FAN_OSCILLATION[swing_mode]
- await self._device.set_fan_oscillation_mode(fan_oscillation_mode)
-
- # setting the fan must reset the preset mode (it deactivates the windFree function)
- self._attr_preset_mode = None
-
- self.async_schedule_update_ha_state(True)
+ await self.execute_device_command(
+ Capability.FAN_OSCILLATION_MODE,
+ Command.SET_FAN_OSCILLATION_MODE,
+ argument=SWING_TO_FAN_OSCILLATION[swing_mode],
+ )
@property
def swing_mode(self) -> str:
"""Return the swing setting."""
return FAN_OSCILLATION_TO_SWING.get(
- self._device.status.fan_oscillation_mode, SWING_OFF
+ self.get_attribute_value(
+ Capability.FAN_OSCILLATION_MODE, Attribute.FAN_OSCILLATION_MODE
+ ),
+ SWING_OFF,
)
+ @property
+ def preset_mode(self) -> str | None:
+ """Return the preset mode."""
+ if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE):
+ mode = self.get_attribute_value(
+ Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
+ Attribute.AC_OPTIONAL_MODE,
+ )
+ if mode == WINDFREE:
+ return WINDFREE
+ return None
+
def _determine_preset_modes(self) -> list[str] | None:
"""Return a list of available preset modes."""
- supported_modes: list | None = self._device.status.attributes[
- "supportedAcOptionalMode"
- ].value
- if supported_modes and WINDFREE in supported_modes:
- return [WINDFREE]
+ if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE):
+ supported_modes = self.get_attribute_value(
+ Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
+ Attribute.SUPPORTED_AC_OPTIONAL_MODE,
+ )
+ if supported_modes and WINDFREE in supported_modes:
+ return [WINDFREE]
return None
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set special modes (currently only windFree is supported)."""
- result = await self._device.command(
- "main",
- "custom.airConditionerOptionalMode",
- "setAcOptionalMode",
- [preset_mode],
+ await self.execute_device_command(
+ Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE,
+ Command.SET_AC_OPTIONAL_MODE,
+ argument=preset_mode,
)
- if result:
- self._device.status.update_attribute_value("acOptionalMode", preset_mode)
- self._attr_preset_mode = preset_mode
-
- self.async_write_ha_state()
+ def _determine_hvac_modes(self) -> list[HVACMode]:
+ """Determine the supported HVAC modes."""
+ modes = [HVACMode.OFF]
+ if (
+ ac_modes := self.get_attribute_value(
+ Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES
+ )
+ ) is not None:
+ modes.extend(
+ state
+ for mode in ac_modes
+ if (state := AC_MODE_TO_STATE.get(mode)) is not None
+ if state not in modes
+ )
+ return modes
diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py
index 7b49854740a..03c8e4bfa66 100644
--- a/homeassistant/components/smartthings/config_flow.py
+++ b/homeassistant/components/smartthings/config_flow.py
@@ -1,298 +1,97 @@
"""Config flow to configure SmartThings."""
from collections.abc import Mapping
-from http import HTTPStatus
import logging
from typing import Any
-from aiohttp import ClientResponseError
-from pysmartthings import APIResponseError, AppOAuth, SmartThings
-from pysmartthings.installedapp import format_install_url
-import voluptuous as vol
+from pysmartthings import SmartThings
-from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET
+from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
-from .const import (
- APP_OAUTH_CLIENT_NAME,
- APP_OAUTH_SCOPES,
- CONF_APP_ID,
- CONF_INSTALLED_APP_ID,
- CONF_LOCATION_ID,
- CONF_REFRESH_TOKEN,
- DOMAIN,
- VAL_UID_MATCHER,
-)
-from .smartapp import (
- create_app,
- find_app,
- format_unique_id,
- get_webhook_url,
- setup_smartapp,
- setup_smartapp_endpoint,
- update_app,
- validate_webhook_requirements,
-)
+from .const import CONF_LOCATION_ID, DOMAIN, OLD_DATA, REQUESTED_SCOPES, SCOPES
_LOGGER = logging.getLogger(__name__)
-class SmartThingsFlowHandler(ConfigFlow, domain=DOMAIN):
+class SmartThingsConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle configuration of SmartThings integrations."""
- VERSION = 2
+ VERSION = 3
+ MINOR_VERSION = 2
+ DOMAIN = DOMAIN
- api: SmartThings
- app_id: str
- location_id: str
+ @property
+ def logger(self) -> logging.Logger:
+ """Return logger."""
+ return logging.getLogger(__name__)
- def __init__(self) -> None:
- """Create a new instance of the flow handler."""
- self.access_token: str | None = None
- self.oauth_client_secret = None
- self.oauth_client_id = None
- self.installed_app_id = None
- self.refresh_token = None
- self.endpoints_initialized = False
-
- async def async_step_import(self, import_data: None) -> ConfigFlowResult:
- """Occurs when a previously entry setup fails and is re-initiated."""
- return await self.async_step_user(import_data)
+ @property
+ def extra_authorize_data(self) -> dict[str, Any]:
+ """Extra data that needs to be appended to the authorize url."""
+ return {"scope": " ".join(REQUESTED_SCOPES)}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Validate and confirm webhook setup."""
- if not self.endpoints_initialized:
- self.endpoints_initialized = True
- await setup_smartapp_endpoint(
- self.hass, len(self._async_current_entries()) == 0
- )
- webhook_url = get_webhook_url(self.hass)
-
- # Abort if the webhook is invalid
- if not validate_webhook_requirements(self.hass):
+ """Check we have the cloud integration set up."""
+ if "cloud" not in self.hass.config.components:
return self.async_abort(
- reason="invalid_webhook_url",
- description_placeholders={
- "webhook_url": webhook_url,
- "component_url": (
- "https://www.home-assistant.io/integrations/smartthings/"
- ),
+ reason="cloud_not_enabled",
+ description_placeholders={"default_config": "default_config"},
+ )
+ return await super().async_step_user(user_input)
+
+ async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
+ """Create an entry for SmartThings."""
+ if not set(data[CONF_TOKEN]["scope"].split()) >= set(SCOPES):
+ return self.async_abort(reason="missing_scopes")
+ client = SmartThings(session=async_get_clientsession(self.hass))
+ client.authenticate(data[CONF_TOKEN][CONF_ACCESS_TOKEN])
+ locations = await client.get_locations()
+ location = locations[0]
+ # We pick to use the location id as unique id rather than the installed app id
+ # as the installed app id could change with the right settings in the SmartApp
+ # or the app used to sign in changed for any reason.
+ await self.async_set_unique_id(location.location_id)
+ if self.source != SOURCE_REAUTH:
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(
+ title=location.name,
+ data={**data, CONF_LOCATION_ID: location.location_id},
+ )
+
+ if (entry := self._get_reauth_entry()) and CONF_TOKEN not in entry.data:
+ if entry.data[OLD_DATA][CONF_LOCATION_ID] != location.location_id:
+ return self.async_abort(reason="reauth_location_mismatch")
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(),
+ data_updates={
+ **data,
+ CONF_LOCATION_ID: location.location_id,
},
+ unique_id=location.location_id,
)
-
- # Show the confirmation
- if user_input is None:
- return self.async_show_form(
- step_id="user",
- description_placeholders={"webhook_url": webhook_url},
- )
-
- # Show the next screen
- return await self.async_step_pat()
-
- async def async_step_pat(
- self, user_input: dict[str, str] | None = None
- ) -> ConfigFlowResult:
- """Get the Personal Access Token and validate it."""
- errors: dict[str, str] = {}
- if user_input is None or CONF_ACCESS_TOKEN not in user_input:
- return self._show_step_pat(errors)
-
- self.access_token = user_input[CONF_ACCESS_TOKEN]
-
- # Ensure token is a UUID
- if not VAL_UID_MATCHER.match(self.access_token):
- errors[CONF_ACCESS_TOKEN] = "token_invalid_format"
- return self._show_step_pat(errors)
-
- # Setup end-point
- self.api = SmartThings(async_get_clientsession(self.hass), self.access_token)
- try:
- app = await find_app(self.hass, self.api)
- if app:
- await app.refresh() # load all attributes
- await update_app(self.hass, app)
- # Find an existing entry to copy the oauth client
- existing = next(
- (
- entry
- for entry in self._async_current_entries()
- if entry.data[CONF_APP_ID] == app.app_id
- ),
- None,
- )
- if existing:
- self.oauth_client_id = existing.data[CONF_CLIENT_ID]
- self.oauth_client_secret = existing.data[CONF_CLIENT_SECRET]
- else:
- # Get oauth client id/secret by regenerating it
- app_oauth = AppOAuth(app.app_id)
- app_oauth.client_name = APP_OAUTH_CLIENT_NAME
- app_oauth.scope.extend(APP_OAUTH_SCOPES)
- client = await self.api.generate_app_oauth(app_oauth)
- self.oauth_client_secret = client.client_secret
- self.oauth_client_id = client.client_id
- else:
- app, client = await create_app(self.hass, self.api)
- self.oauth_client_secret = client.client_secret
- self.oauth_client_id = client.client_id
- setup_smartapp(self.hass, app)
- self.app_id = app.app_id
-
- except APIResponseError as ex:
- if ex.is_target_error():
- errors["base"] = "webhook_error"
- else:
- errors["base"] = "app_setup_error"
- _LOGGER.exception(
- "API error setting up the SmartApp: %s", ex.raw_error_response
- )
- return self._show_step_pat(errors)
- except ClientResponseError as ex:
- if ex.status == HTTPStatus.UNAUTHORIZED:
- errors[CONF_ACCESS_TOKEN] = "token_unauthorized"
- _LOGGER.debug(
- "Unauthorized error received setting up SmartApp", exc_info=True
- )
- elif ex.status == HTTPStatus.FORBIDDEN:
- errors[CONF_ACCESS_TOKEN] = "token_forbidden"
- _LOGGER.debug(
- "Forbidden error received setting up SmartApp", exc_info=True
- )
- else:
- errors["base"] = "app_setup_error"
- _LOGGER.exception("Unexpected error setting up the SmartApp")
- return self._show_step_pat(errors)
- except Exception:
- errors["base"] = "app_setup_error"
- _LOGGER.exception("Unexpected error setting up the SmartApp")
- return self._show_step_pat(errors)
-
- return await self.async_step_select_location()
-
- async def async_step_select_location(
- self, user_input: dict[str, str] | None = None
- ) -> ConfigFlowResult:
- """Ask user to select the location to setup."""
- if user_input is None or CONF_LOCATION_ID not in user_input:
- # Get available locations
- existing_locations = [
- entry.data[CONF_LOCATION_ID] for entry in self._async_current_entries()
- ]
- locations = await self.api.locations()
- locations_options = {
- location.location_id: location.name
- for location in locations
- if location.location_id not in existing_locations
- }
- if not locations_options:
- return self.async_abort(reason="no_available_locations")
-
- return self.async_show_form(
- step_id="select_location",
- data_schema=vol.Schema(
- {vol.Required(CONF_LOCATION_ID): vol.In(locations_options)}
- ),
- )
-
- self.location_id = user_input[CONF_LOCATION_ID]
- await self.async_set_unique_id(format_unique_id(self.app_id, self.location_id))
- return await self.async_step_authorize()
-
- async def async_step_authorize(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Wait for the user to authorize the app installation."""
- user_input = {} if user_input is None else user_input
- self.installed_app_id = user_input.get(CONF_INSTALLED_APP_ID)
- self.refresh_token = user_input.get(CONF_REFRESH_TOKEN)
- if self.installed_app_id is None:
- # Launch the external setup URL
- url = format_install_url(self.app_id, self.location_id)
- return self.async_external_step(step_id="authorize", url=url)
-
- next_step_id = "install"
- if self.source == SOURCE_REAUTH:
- next_step_id = "update"
- return self.async_external_step_done(next_step_id=next_step_id)
-
- def _show_step_pat(self, errors):
- if self.access_token is None:
- # Get the token from an existing entry to make it easier to setup multiple locations.
- self.access_token = next(
- (
- entry.data.get(CONF_ACCESS_TOKEN)
- for entry in self._async_current_entries()
- ),
- None,
- )
-
- return self.async_show_form(
- step_id="pat",
- data_schema=vol.Schema(
- {vol.Required(CONF_ACCESS_TOKEN, default=self.access_token): str}
- ),
- errors=errors,
- description_placeholders={
- "token_url": "https://account.smartthings.com/tokens",
- "component_url": (
- "https://www.home-assistant.io/integrations/smartthings/"
- ),
- },
+ self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch")
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(), data_updates=data
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
- """Handle re-authentication of an existing config entry."""
+ """Perform reauth upon migration of old entries."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle re-authentication of an existing config entry."""
+ """Confirm reauth dialog."""
if user_input is None:
- return self.async_show_form(step_id="reauth_confirm")
- self.app_id = self._get_reauth_entry().data[CONF_APP_ID]
- self.location_id = self._get_reauth_entry().data[CONF_LOCATION_ID]
- self._set_confirm_only()
- return await self.async_step_authorize()
-
- async def async_step_update(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle re-authentication of an existing config entry."""
- return await self.async_step_update_confirm()
-
- async def async_step_update_confirm(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle re-authentication of an existing config entry."""
- if user_input is None:
- self._set_confirm_only()
- return self.async_show_form(step_id="update_confirm")
- entry = self._get_reauth_entry()
- return self.async_update_reload_and_abort(
- entry, data_updates={CONF_REFRESH_TOKEN: self.refresh_token}
- )
-
- async def async_step_install(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Create a config entry at completion of a flow and authorization of the app."""
- data = {
- CONF_ACCESS_TOKEN: self.access_token,
- CONF_REFRESH_TOKEN: self.refresh_token,
- CONF_CLIENT_ID: self.oauth_client_id,
- CONF_CLIENT_SECRET: self.oauth_client_secret,
- CONF_LOCATION_ID: self.location_id,
- CONF_APP_ID: self.app_id,
- CONF_INSTALLED_APP_ID: self.installed_app_id,
- }
-
- location = await self.api.location(data[CONF_LOCATION_ID])
-
- return self.async_create_entry(title=location.name, data=data)
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ )
+ return await self.async_step_user()
diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py
index e50837697e7..8f27b785688 100644
--- a/homeassistant/components/smartthings/const.py
+++ b/homeassistant/components/smartthings/const.py
@@ -1,15 +1,29 @@
"""Constants used by the SmartThings component and platforms."""
-from datetime import timedelta
-import re
-
-from homeassistant.const import Platform
+from pysmartthings import Attribute, Capability, Category
DOMAIN = "smartthings"
-APP_OAUTH_CLIENT_NAME = "Home Assistant"
-APP_OAUTH_SCOPES = ["r:devices:*"]
-APP_NAME_PREFIX = "homeassistant."
+SCOPES = [
+ "r:devices:*",
+ "w:devices:*",
+ "x:devices:*",
+ "r:hubs:*",
+ "r:locations:*",
+ "w:locations:*",
+ "x:locations:*",
+ "r:scenes:*",
+ "x:scenes:*",
+ "r:rules:*",
+ "w:rules:*",
+ "sse",
+]
+
+REQUESTED_SCOPES = [
+ *SCOPES,
+ "r:installedapps",
+ "w:installedapps",
+]
CONF_APP_ID = "app_id"
CONF_CLOUDHOOK_URL = "cloudhook_url"
@@ -18,41 +32,89 @@ CONF_INSTANCE_ID = "instance_id"
CONF_LOCATION_ID = "location_id"
CONF_REFRESH_TOKEN = "refresh_token"
-DATA_MANAGER = "manager"
-DATA_BROKERS = "brokers"
+MAIN = "main"
+OLD_DATA = "old_data"
+
+CONF_SUBSCRIPTION_ID = "subscription_id"
EVENT_BUTTON = "smartthings.button"
-SIGNAL_SMARTTHINGS_UPDATE = "smartthings_update"
-SIGNAL_SMARTAPP_PREFIX = "smartthings_smartap_"
+BINARY_SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = {
+ Attribute.ACCELERATION: Capability.ACCELERATION_SENSOR,
+ Attribute.CONTACT: Capability.CONTACT_SENSOR,
+ Attribute.FILTER_STATUS: Capability.FILTER_STATUS,
+ Attribute.MOTION: Capability.MOTION_SENSOR,
+ Attribute.PRESENCE: Capability.PRESENCE_SENSOR,
+ Attribute.SOUND: Capability.SOUND_SENSOR,
+ Attribute.TAMPER: Capability.TAMPER_ALERT,
+ Attribute.VALVE: Capability.VALVE,
+ Attribute.WATER: Capability.WATER_SENSOR,
+}
-SETTINGS_INSTANCE_ID = "hassInstanceId"
+SENSOR_ATTRIBUTES_TO_CAPABILITIES: dict[str, str] = {
+ Attribute.LIGHTING_MODE: Capability.ACTIVITY_LIGHTING_MODE,
+ Attribute.AIR_CONDITIONER_MODE: Capability.AIR_CONDITIONER_MODE,
+ Attribute.AIR_QUALITY: Capability.AIR_QUALITY_SENSOR,
+ Attribute.ALARM: Capability.ALARM,
+ Attribute.BATTERY: Capability.BATTERY,
+ Attribute.BMI_MEASUREMENT: Capability.BODY_MASS_INDEX_MEASUREMENT,
+ Attribute.BODY_WEIGHT_MEASUREMENT: Capability.BODY_WEIGHT_MEASUREMENT,
+ Attribute.CARBON_DIOXIDE: Capability.CARBON_DIOXIDE_MEASUREMENT,
+ Attribute.CARBON_MONOXIDE: Capability.CARBON_MONOXIDE_MEASUREMENT,
+ Attribute.CARBON_MONOXIDE_LEVEL: Capability.CARBON_MONOXIDE_MEASUREMENT,
+ Attribute.DISHWASHER_JOB_STATE: Capability.DISHWASHER_OPERATING_STATE,
+ Attribute.DRYER_MODE: Capability.DRYER_MODE,
+ Attribute.DRYER_JOB_STATE: Capability.DRYER_OPERATING_STATE,
+ Attribute.DUST_LEVEL: Capability.DUST_SENSOR,
+ Attribute.FINE_DUST_LEVEL: Capability.DUST_SENSOR,
+ Attribute.ENERGY: Capability.ENERGY_METER,
+ Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT,
+ Attribute.FORMALDEHYDE_LEVEL: Capability.FORMALDEHYDE_MEASUREMENT,
+ Attribute.GAS_METER: Capability.GAS_METER,
+ Attribute.GAS_METER_CALORIFIC: Capability.GAS_METER,
+ Attribute.GAS_METER_TIME: Capability.GAS_METER,
+ Attribute.GAS_METER_VOLUME: Capability.GAS_METER,
+ Attribute.ILLUMINANCE: Capability.ILLUMINANCE_MEASUREMENT,
+ Attribute.INFRARED_LEVEL: Capability.INFRARED_LEVEL,
+ Attribute.INPUT_SOURCE: Capability.MEDIA_INPUT_SOURCE,
+ Attribute.PLAYBACK_REPEAT_MODE: Capability.MEDIA_PLAYBACK_REPEAT,
+ Attribute.PLAYBACK_SHUFFLE: Capability.MEDIA_PLAYBACK_SHUFFLE,
+ Attribute.PLAYBACK_STATUS: Capability.MEDIA_PLAYBACK,
+ Attribute.ODOR_LEVEL: Capability.ODOR_SENSOR,
+ Attribute.OVEN_MODE: Capability.OVEN_MODE,
+ Attribute.OVEN_JOB_STATE: Capability.OVEN_OPERATING_STATE,
+ Attribute.OVEN_SETPOINT: Capability.OVEN_SETPOINT,
+ Attribute.POWER: Capability.POWER_METER,
+ Attribute.POWER_SOURCE: Capability.POWER_SOURCE,
+ Attribute.REFRIGERATION_SETPOINT: Capability.REFRIGERATION_SETPOINT,
+ Attribute.HUMIDITY: Capability.RELATIVE_HUMIDITY_MEASUREMENT,
+ Attribute.ROBOT_CLEANER_CLEANING_MODE: Capability.ROBOT_CLEANER_CLEANING_MODE,
+ Attribute.ROBOT_CLEANER_MOVEMENT: Capability.ROBOT_CLEANER_MOVEMENT,
+ Attribute.ROBOT_CLEANER_TURBO_MODE: Capability.ROBOT_CLEANER_TURBO_MODE,
+ Attribute.LQI: Capability.SIGNAL_STRENGTH,
+ Attribute.RSSI: Capability.SIGNAL_STRENGTH,
+ Attribute.SMOKE: Capability.SMOKE_DETECTOR,
+ Attribute.TEMPERATURE: Capability.TEMPERATURE_MEASUREMENT,
+ Attribute.COOLING_SETPOINT: Capability.THERMOSTAT_COOLING_SETPOINT,
+ Attribute.THERMOSTAT_FAN_MODE: Capability.THERMOSTAT_FAN_MODE,
+ Attribute.HEATING_SETPOINT: Capability.THERMOSTAT_HEATING_SETPOINT,
+ Attribute.THERMOSTAT_MODE: Capability.THERMOSTAT_MODE,
+ Attribute.THERMOSTAT_OPERATING_STATE: Capability.THERMOSTAT_OPERATING_STATE,
+ Attribute.THERMOSTAT_SETPOINT: Capability.THERMOSTAT_SETPOINT,
+ Attribute.TV_CHANNEL: Capability.TV_CHANNEL,
+ Attribute.TV_CHANNEL_NAME: Capability.TV_CHANNEL,
+ Attribute.TVOC_LEVEL: Capability.TVOC_MEASUREMENT,
+ Attribute.ULTRAVIOLET_INDEX: Capability.ULTRAVIOLET_INDEX,
+ Attribute.VERY_FINE_DUST_LEVEL: Capability.VERY_FINE_DUST_SENSOR,
+ Attribute.VOLTAGE: Capability.VOLTAGE_MEASUREMENT,
+ Attribute.WASHER_MODE: Capability.WASHER_MODE,
+ Attribute.WASHER_JOB_STATE: Capability.WASHER_OPERATING_STATE,
+}
-SUBSCRIPTION_WARNING_LIMIT = 40
-
-STORAGE_KEY = DOMAIN
-STORAGE_VERSION = 1
-
-# Ordered 'specific to least-specific platform' in order for capabilities
-# to be drawn-down and represented by the most appropriate platform.
-PLATFORMS = [
- Platform.BINARY_SENSOR,
- Platform.CLIMATE,
- Platform.COVER,
- Platform.FAN,
- Platform.LIGHT,
- Platform.LOCK,
- Platform.SCENE,
- Platform.SENSOR,
- Platform.SWITCH,
-]
-
-IGNORED_CAPABILITIES = [
- "execute",
- "healthCheck",
- "ocf",
-]
-
-TOKEN_REFRESH_INTERVAL = timedelta(days=14)
-
-VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$"
-VAL_UID_MATCHER = re.compile(VAL_UID)
+INVALID_SWITCH_CATEGORIES = {
+ Category.CLOTHING_CARE_MACHINE,
+ Category.COOKTOP,
+ Category.DRYER,
+ Category.WASHER,
+ Category.MICROWAVE,
+ Category.DISHWASHER,
+}
diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py
index 55e86bd582e..0b68409443d 100644
--- a/homeassistant/components/smartthings/cover.py
+++ b/homeassistant/components/smartthings/cover.py
@@ -2,25 +2,23 @@
from __future__ import annotations
-from collections.abc import Sequence
from typing import Any
-from pysmartthings import Attribute, Capability
+from pysmartthings import Attribute, Capability, Command, SmartThings
from homeassistant.components.cover import (
ATTR_POSITION,
- DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
CoverEntity,
CoverEntityFeature,
CoverState,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_BATTERY_LEVEL
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DATA_BROKERS, DOMAIN
+from . import FullDevice, SmartThingsConfigEntry
+from .const import MAIN
from .entity import SmartThingsEntity
VALUE_TO_STATE = {
@@ -32,114 +30,108 @@ VALUE_TO_STATE = {
"unknown": None,
}
+CAPABILITIES = (Capability.WINDOW_SHADE, Capability.DOOR_CONTROL)
+
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add covers for a config entry."""
- broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
+ entry_data = entry.runtime_data
async_add_entities(
- [
- SmartThingsCover(device)
- for device in broker.devices.values()
- if broker.any_assigned(device.device_id, COVER_DOMAIN)
- ],
- True,
+ SmartThingsCover(entry_data.client, device, Capability(capability))
+ for device in entry_data.devices.values()
+ for capability in device.status[MAIN]
+ if capability in CAPABILITIES
)
-def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
- """Return all capabilities supported if minimum required are present."""
- min_required = [
- Capability.door_control,
- Capability.garage_door_control,
- Capability.window_shade,
- ]
- # Must have one of the min_required
- if any(capability in capabilities for capability in min_required):
- # Return all capabilities supported/consumed
- return [
- *min_required,
- Capability.battery,
- Capability.switch_level,
- Capability.window_shade_level,
- ]
-
- return None
-
-
class SmartThingsCover(SmartThingsEntity, CoverEntity):
"""Define a SmartThings cover."""
- def __init__(self, device):
+ _attr_name = None
+ _state: CoverState | None = None
+
+ def __init__(
+ self,
+ client: SmartThings,
+ device: FullDevice,
+ capability: Capability,
+ ) -> None:
"""Initialize the cover class."""
- super().__init__(device)
- self._current_cover_position = None
- self._state = None
+ super().__init__(
+ client,
+ device,
+ {
+ capability,
+ Capability.BATTERY,
+ Capability.WINDOW_SHADE_LEVEL,
+ Capability.SWITCH_LEVEL,
+ },
+ )
+ self.capability = capability
self._attr_supported_features = (
CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
)
- if (
- Capability.switch_level in device.capabilities
- or Capability.window_shade_level in device.capabilities
- ):
+ if self.supports_capability(Capability.WINDOW_SHADE_LEVEL):
+ self.level_capability = Capability.WINDOW_SHADE_LEVEL
+ self.level_command = Command.SET_SHADE_LEVEL
+ else:
+ self.level_capability = Capability.SWITCH_LEVEL
+ self.level_command = Command.SET_LEVEL
+ if self.supports_capability(
+ Capability.SWITCH_LEVEL
+ ) or self.supports_capability(Capability.WINDOW_SHADE_LEVEL):
self._attr_supported_features |= CoverEntityFeature.SET_POSITION
- if Capability.door_control in device.capabilities:
+ if self.supports_capability(Capability.DOOR_CONTROL):
self._attr_device_class = CoverDeviceClass.DOOR
- elif Capability.window_shade in device.capabilities:
+ elif self.supports_capability(Capability.WINDOW_SHADE):
self._attr_device_class = CoverDeviceClass.SHADE
- elif Capability.garage_door_control in device.capabilities:
- self._attr_device_class = CoverDeviceClass.GARAGE
async def async_close_cover(self, **kwargs: Any) -> None:
"""Close cover."""
- # Same command for all 3 supported capabilities
- await self._device.close(set_status=True)
- # State is set optimistically in the commands above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_schedule_update_ha_state(True)
+ await self.execute_device_command(self.capability, Command.CLOSE)
async def async_open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
- # Same for all capability types
- await self._device.open(set_status=True)
- # State is set optimistically in the commands above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_schedule_update_ha_state(True)
+ await self.execute_device_command(self.capability, Command.OPEN)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Move the cover to a specific position."""
- if not self.supported_features & CoverEntityFeature.SET_POSITION:
- return
- # Do not set_status=True as device will report progress.
- if Capability.window_shade_level in self._device.capabilities:
- await self._device.set_window_shade_level(
- kwargs[ATTR_POSITION], set_status=False
- )
- else:
- await self._device.set_level(kwargs[ATTR_POSITION], set_status=False)
+ await self.execute_device_command(
+ self.level_capability,
+ self.level_command,
+ argument=kwargs[ATTR_POSITION],
+ )
- async def async_update(self) -> None:
+ def _update_attr(self) -> None:
"""Update the attrs of the cover."""
- if Capability.door_control in self._device.capabilities:
- self._state = VALUE_TO_STATE.get(self._device.status.door)
- elif Capability.window_shade in self._device.capabilities:
- self._state = VALUE_TO_STATE.get(self._device.status.window_shade)
- elif Capability.garage_door_control in self._device.capabilities:
- self._state = VALUE_TO_STATE.get(self._device.status.door)
+ attribute = {
+ Capability.WINDOW_SHADE: Attribute.WINDOW_SHADE,
+ Capability.DOOR_CONTROL: Attribute.DOOR,
+ }[self.capability]
+ self._state = VALUE_TO_STATE.get(
+ self.get_attribute_value(self.capability, attribute)
+ )
- if Capability.window_shade_level in self._device.capabilities:
- self._attr_current_cover_position = self._device.status.shade_level
- elif Capability.switch_level in self._device.capabilities:
- self._attr_current_cover_position = self._device.status.level
+ if self.supports_capability(Capability.SWITCH_LEVEL):
+ self._attr_current_cover_position = self.get_attribute_value(
+ Capability.SWITCH_LEVEL, Attribute.LEVEL
+ )
+ elif self.supports_capability(Capability.WINDOW_SHADE_LEVEL):
+ self._attr_current_cover_position = self.get_attribute_value(
+ Capability.WINDOW_SHADE_LEVEL, Attribute.SHADE_LEVEL
+ )
+ # Deprecated, remove in 2025.10
self._attr_extra_state_attributes = {}
- battery = self._device.status.attributes[Attribute.battery].value
- if battery is not None:
- self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = battery
+ if self.supports_capability(Capability.BATTERY):
+ self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = (
+ self.get_attribute_value(Capability.BATTERY, Attribute.BATTERY)
+ )
@property
def is_opening(self) -> bool:
diff --git a/homeassistant/components/smartthings/diagnostics.py b/homeassistant/components/smartthings/diagnostics.py
new file mode 100644
index 00000000000..04517112802
--- /dev/null
+++ b/homeassistant/components/smartthings/diagnostics.py
@@ -0,0 +1,56 @@
+"""Diagnostics support for SmartThings."""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import asdict
+from typing import Any
+
+from pysmartthings import DeviceEvent
+
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import DeviceEntry
+
+from . import SmartThingsConfigEntry
+from .const import DOMAIN
+
+EVENT_WAIT_TIME = 5
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant,
+ entry: SmartThingsConfigEntry,
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+ client = entry.runtime_data.client
+ return {"devices": await client.get_raw_devices()}
+
+
+async def async_get_device_diagnostics(
+ hass: HomeAssistant, entry: SmartThingsConfigEntry, device: DeviceEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a device entry."""
+ client = entry.runtime_data.client
+ device_id = next(
+ identifier for identifier in device.identifiers if identifier[0] == DOMAIN
+ )[1]
+
+ device_status = await client.get_raw_device_status(device_id)
+ device_info = await client.get_raw_device(device_id)
+
+ events: list[DeviceEvent] = []
+
+ def register_event(event: DeviceEvent) -> None:
+ events.append(event)
+
+ listener = client.add_device_event_listener(device_id, register_event)
+
+ await asyncio.sleep(EVENT_WAIT_TIME)
+
+ listener()
+
+ return {
+ "events": [asdict(event) for event in events],
+ "status": device_status,
+ "info": device_info,
+ }
diff --git a/homeassistant/components/smartthings/entity.py b/homeassistant/components/smartthings/entity.py
index cc63213d122..5544297a4c6 100644
--- a/homeassistant/components/smartthings/entity.py
+++ b/homeassistant/components/smartthings/entity.py
@@ -2,49 +2,98 @@
from __future__ import annotations
-from pysmartthings.device import DeviceEntity
+from typing import Any
+
+from pysmartthings import (
+ Attribute,
+ Capability,
+ Command,
+ ComponentStatus,
+ DeviceEvent,
+ SmartThings,
+)
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
-from .const import DOMAIN, SIGNAL_SMARTTHINGS_UPDATE
+from . import FullDevice
+from .const import DOMAIN, MAIN
class SmartThingsEntity(Entity):
"""Defines a SmartThings entity."""
_attr_should_poll = False
+ _attr_has_entity_name = True
- def __init__(self, device: DeviceEntity) -> None:
+ def __init__(
+ self,
+ client: SmartThings,
+ device: FullDevice,
+ capabilities: set[Capability],
+ *,
+ component: str = MAIN,
+ ) -> None:
"""Initialize the instance."""
- self._device = device
- self._dispatcher_remove = None
- self._attr_name = device.label
- self._attr_unique_id = device.device_id
+ self.client = client
+ self.capabilities = capabilities
+ self.component = component
+ self._internal_state: ComponentStatus = {
+ capability: device.status[component][capability]
+ for capability in capabilities
+ if capability in device.status[component]
+ }
+ self.device = device
+ self._attr_unique_id = f"{device.device.device_id}_{component}"
self._attr_device_info = DeviceInfo(
- configuration_url="https://account.smartthings.com",
- identifiers={(DOMAIN, device.device_id)},
- manufacturer=device.status.ocf_manufacturer_name,
- model=device.status.ocf_model_number,
- name=device.label,
- hw_version=device.status.ocf_hardware_version,
- sw_version=device.status.ocf_firmware_version,
+ identifiers={(DOMAIN, device.device.device_id)},
)
- async def async_added_to_hass(self):
- """Device added to hass."""
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to updates."""
+ await super().async_added_to_hass()
+ for capability in self._internal_state:
+ self.async_on_remove(
+ self.client.add_device_capability_event_listener(
+ self.device.device.device_id,
+ self.component,
+ capability,
+ self._update_handler,
+ )
+ )
+ self._update_attr()
- async def async_update_state(devices):
- """Update device state."""
- if self._device.device_id in devices:
- await self.async_update_ha_state(True)
+ def _update_handler(self, event: DeviceEvent) -> None:
+ self._internal_state[event.capability][event.attribute].value = event.value
+ self._internal_state[event.capability][event.attribute].data = event.data
+ self._handle_update()
- self._dispatcher_remove = async_dispatcher_connect(
- self.hass, SIGNAL_SMARTTHINGS_UPDATE, async_update_state
+ def supports_capability(self, capability: Capability) -> bool:
+ """Test if device supports a capability."""
+ return capability in self.device.status[self.component]
+
+ def get_attribute_value(self, capability: Capability, attribute: Attribute) -> Any:
+ """Get the value of a device attribute."""
+ return self._internal_state[capability][attribute].value
+
+ def _update_attr(self) -> None:
+ """Update the attributes."""
+
+ def _handle_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._update_attr()
+ self.async_write_ha_state()
+
+ async def execute_device_command(
+ self,
+ capability: Capability,
+ command: Command,
+ argument: int | str | list[Any] | dict[str, Any] | None = None,
+ ) -> None:
+ """Execute a command on the device."""
+ kwargs = {}
+ if argument is not None:
+ kwargs["argument"] = argument
+ await self.client.execute_device_command(
+ self.device.device.device_id, capability, command, self.component, **kwargs
)
-
- async def async_will_remove_from_hass(self) -> None:
- """Disconnect the device when removed."""
- if self._dispatcher_remove:
- self._dispatcher_remove()
diff --git a/homeassistant/components/smartthings/event.py b/homeassistant/components/smartthings/event.py
new file mode 100644
index 00000000000..0439e6391f4
--- /dev/null
+++ b/homeassistant/components/smartthings/event.py
@@ -0,0 +1,63 @@
+"""Support for events through the SmartThings cloud API."""
+
+from __future__ import annotations
+
+from typing import cast
+
+from pysmartthings import Attribute, Capability, Component, DeviceEvent, SmartThings
+
+from homeassistant.components.event import EventDeviceClass, EventEntity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import FullDevice, SmartThingsConfigEntry
+from .entity import SmartThingsEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add events for a config entry."""
+ entry_data = entry.runtime_data
+ async_add_entities(
+ SmartThingsButtonEvent(
+ entry_data.client, device, device.device.components[component]
+ )
+ for device in entry_data.devices.values()
+ for component, capabilities in device.status.items()
+ if Capability.BUTTON in capabilities
+ )
+
+
+class SmartThingsButtonEvent(SmartThingsEntity, EventEntity):
+ """Define a SmartThings event."""
+
+ _attr_device_class = EventDeviceClass.BUTTON
+ _attr_translation_key = "button"
+
+ def __init__(
+ self,
+ client: SmartThings,
+ device: FullDevice,
+ component: Component,
+ ) -> None:
+ """Init the class."""
+ super().__init__(client, device, {Capability.BUTTON}, component=component.id)
+ self._attr_name = component.label
+ self._attr_unique_id = (
+ f"{device.device.device_id}_{component.id}_{Capability.BUTTON}"
+ )
+
+ @property
+ def event_types(self) -> list[str]:
+ """Return the event types."""
+ return self.get_attribute_value(
+ Capability.BUTTON, Attribute.SUPPORTED_BUTTON_VALUES
+ )
+
+ def _update_handler(self, event: DeviceEvent) -> None:
+ if event.attribute is Attribute.BUTTON:
+ self._trigger_event(cast(str, event.value))
+ super()._update_handler(event)
diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py
index 61e30589273..1c4cb4edc4a 100644
--- a/homeassistant/components/smartthings/fan.py
+++ b/homeassistant/components/smartthings/fan.py
@@ -2,23 +2,22 @@
from __future__ import annotations
-from collections.abc import Sequence
import math
from typing import Any
-from pysmartthings import Capability
+from pysmartthings import Attribute, Capability, Command, SmartThings
from homeassistant.components.fan import FanEntity, FanEntityFeature
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from homeassistant.util.scaling import int_states_in_range
-from .const import DATA_BROKERS, DOMAIN
+from . import FullDevice, SmartThingsConfigEntry
+from .const import MAIN
from .entity import SmartThingsEntity
SPEED_RANGE = (1, 3) # off is not included
@@ -26,86 +25,74 @@ SPEED_RANGE = (1, 3) # off is not included
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add fans for a config entry."""
- broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
+ entry_data = entry.runtime_data
async_add_entities(
- SmartThingsFan(device)
- for device in broker.devices.values()
- if broker.any_assigned(device.device_id, "fan")
+ SmartThingsFan(entry_data.client, device)
+ for device in entry_data.devices.values()
+ if Capability.SWITCH in device.status[MAIN]
+ and any(
+ capability in device.status[MAIN]
+ for capability in (
+ Capability.FAN_SPEED,
+ Capability.AIR_CONDITIONER_FAN_MODE,
+ )
+ )
+ and Capability.THERMOSTAT_COOLING_SETPOINT not in device.status[MAIN]
)
-def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
- """Return all capabilities supported if minimum required are present."""
-
- # MUST support switch as we need a way to turn it on and off
- if Capability.switch not in capabilities:
- return None
-
- # These are all optional but at least one must be supported
- optional = [
- Capability.air_conditioner_fan_mode,
- Capability.fan_speed,
- ]
-
- # At least one of the optional capabilities must be supported
- # to classify this entity as a fan.
- # If they are not then return None and don't setup the platform.
- if not any(capability in capabilities for capability in optional):
- return None
-
- supported = [Capability.switch]
-
- supported.extend(
- capability for capability in optional if capability in capabilities
- )
-
- return supported
-
-
class SmartThingsFan(SmartThingsEntity, FanEntity):
"""Define a SmartThings Fan."""
+ _attr_name = None
_attr_speed_count = int_states_in_range(SPEED_RANGE)
- def __init__(self, device):
+ def __init__(self, client: SmartThings, device: FullDevice) -> None:
"""Init the class."""
- super().__init__(device)
+ super().__init__(
+ client,
+ device,
+ {
+ Capability.SWITCH,
+ Capability.FAN_SPEED,
+ Capability.AIR_CONDITIONER_FAN_MODE,
+ },
+ )
self._attr_supported_features = self._determine_features()
def _determine_features(self):
flags = FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
- if self._device.get_capability(Capability.fan_speed):
+ if self.supports_capability(Capability.FAN_SPEED):
flags |= FanEntityFeature.SET_SPEED
- if self._device.get_capability(Capability.air_conditioner_fan_mode):
+ if self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE):
flags |= FanEntityFeature.PRESET_MODE
return flags
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan."""
- await self._async_set_percentage(percentage)
-
- async def _async_set_percentage(self, percentage: int | None) -> None:
- if percentage is None:
- await self._device.switch_on(set_status=True)
- elif percentage == 0:
- await self._device.switch_off(set_status=True)
+ if percentage == 0:
+ await self.execute_device_command(Capability.SWITCH, Command.OFF)
else:
value = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage))
- await self._device.set_fan_speed(value, set_status=True)
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_write_ha_state()
+ await self.execute_device_command(
+ Capability.FAN_SPEED,
+ Command.SET_FAN_SPEED,
+ argument=value,
+ )
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset_mode of the fan."""
- await self._device.set_fan_mode(preset_mode, set_status=True)
- self.async_write_ha_state()
+ await self.execute_device_command(
+ Capability.AIR_CONDITIONER_FAN_MODE,
+ Command.SET_FAN_MODE,
+ argument=preset_mode,
+ )
async def async_turn_on(
self,
@@ -114,32 +101,30 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
**kwargs: Any,
) -> None:
"""Turn the fan on."""
- if FanEntityFeature.SET_SPEED in self._attr_supported_features:
- # If speed is set in features then turn the fan on with the speed.
- await self._async_set_percentage(percentage)
+ if (
+ FanEntityFeature.SET_SPEED in self._attr_supported_features
+ and percentage is not None
+ ):
+ await self.async_set_percentage(percentage)
else:
- # If speed is not valid then turn on the fan with the
- await self._device.switch_on(set_status=True)
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_write_ha_state()
+ await self.execute_device_command(Capability.SWITCH, Command.ON)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
- await self._device.switch_off(set_status=True)
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_write_ha_state()
+ await self.execute_device_command(Capability.SWITCH, Command.OFF)
@property
def is_on(self) -> bool:
"""Return true if fan is on."""
- return self._device.status.switch
+ return self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on"
@property
def percentage(self) -> int | None:
"""Return the current speed percentage."""
- return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed)
+ return ranged_value_to_percentage(
+ SPEED_RANGE,
+ self.get_attribute_value(Capability.FAN_SPEED, Attribute.FAN_SPEED),
+ )
@property
def preset_mode(self) -> str | None:
@@ -147,7 +132,11 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
Requires FanEntityFeature.PRESET_MODE.
"""
- return self._device.status.fan_mode
+ if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE):
+ return None
+ return self.get_attribute_value(
+ Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE
+ )
@property
def preset_modes(self) -> list[str] | None:
@@ -155,4 +144,8 @@ class SmartThingsFan(SmartThingsEntity, FanEntity):
Requires FanEntityFeature.PRESET_MODE.
"""
- return self._device.status.supported_ac_fan_modes
+ if not self.supports_capability(Capability.AIR_CONDITIONER_FAN_MODE):
+ return None
+ return self.get_attribute_value(
+ Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES
+ )
diff --git a/homeassistant/components/smartthings/icons.json b/homeassistant/components/smartthings/icons.json
new file mode 100644
index 00000000000..214a9953a5a
--- /dev/null
+++ b/homeassistant/components/smartthings/icons.json
@@ -0,0 +1,63 @@
+{
+ "entity": {
+ "binary_sensor": {
+ "dryer_wrinkle_prevent_active": {
+ "default": "mdi:tumble-dryer",
+ "state": {
+ "on": "mdi:tumble-dryer-alert"
+ }
+ },
+ "remote_control": {
+ "default": "mdi:remote-off",
+ "state": {
+ "on": "mdi:remote"
+ }
+ },
+ "child_lock": {
+ "default": "mdi:lock-open",
+ "state": {
+ "on": "mdi:lock"
+ }
+ }
+ },
+ "button": {
+ "reset_water_filter": {
+ "default": "mdi:reload"
+ },
+ "stop": {
+ "default": "mdi:stop"
+ }
+ },
+ "number": {
+ "washer_rinse_cycles": {
+ "default": "mdi:waves-arrow-up"
+ }
+ },
+ "select": {
+ "operating_state": {
+ "state": {
+ "run": "mdi:play",
+ "pause": "mdi:pause",
+ "stop": "mdi:stop"
+ }
+ }
+ },
+ "switch": {
+ "bubble_soak": {
+ "default": "mdi:water-off",
+ "state": {
+ "on": "mdi:water"
+ }
+ },
+ "wrinkle_prevent": {
+ "default": "mdi:tumble-dryer",
+ "state": {
+ "off": "mdi:tumble-dryer-off"
+ }
+ },
+ "ice_maker": {
+ "default": "mdi:delete-variant"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py
index eb7c9af246b..1ad315bcd97 100644
--- a/homeassistant/components/smartthings/light.py
+++ b/homeassistant/components/smartthings/light.py
@@ -3,13 +3,13 @@
from __future__ import annotations
import asyncio
-from collections.abc import Sequence
-from typing import Any
+from typing import Any, cast
-from pysmartthings import Capability
+from pysmartthings import Attribute, Capability, Command, DeviceEvent, SmartThings
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
+ ATTR_COLOR_MODE,
ATTR_COLOR_TEMP_KELVIN,
ATTR_HS_COLOR,
ATTR_TRANSITION,
@@ -18,104 +18,95 @@ from homeassistant.components.light import (
LightEntityFeature,
brightness_supported,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
-from .const import DATA_BROKERS, DOMAIN
+from . import FullDevice, SmartThingsConfigEntry
+from .const import MAIN
from .entity import SmartThingsEntity
+CAPABILITIES = (
+ Capability.SWITCH_LEVEL,
+ Capability.COLOR_CONTROL,
+ Capability.COLOR_TEMPERATURE,
+)
+
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add lights for a config entry."""
- broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
+ entry_data = entry.runtime_data
async_add_entities(
- [
- SmartThingsLight(device)
- for device in broker.devices.values()
- if broker.any_assigned(device.device_id, "light")
- ],
- True,
+ SmartThingsLight(entry_data.client, device)
+ for device in entry_data.devices.values()
+ if Capability.SWITCH in device.status[MAIN]
+ and any(capability in device.status[MAIN] for capability in CAPABILITIES)
)
-def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
- """Return all capabilities supported if minimum required are present."""
- supported = [
- Capability.switch,
- Capability.switch_level,
- Capability.color_control,
- Capability.color_temperature,
- ]
- # Must be able to be turned on/off.
- if Capability.switch not in capabilities:
- return None
- # Must have one of these
- light_capabilities = [
- Capability.color_control,
- Capability.color_temperature,
- Capability.switch_level,
- ]
- if any(capability in capabilities for capability in light_capabilities):
- return supported
- return None
-
-
-def convert_scale(value, value_scale, target_scale, round_digits=4):
+def convert_scale(
+ value: float, value_scale: int, target_scale: int, round_digits: int = 4
+) -> float:
"""Convert a value to a different scale."""
return round(value * target_scale / value_scale, round_digits)
-class SmartThingsLight(SmartThingsEntity, LightEntity):
+class SmartThingsLight(SmartThingsEntity, LightEntity, RestoreEntity):
"""Define a SmartThings Light."""
+ _attr_name = None
_attr_supported_color_modes: set[ColorMode]
# SmartThings does not expose this attribute, instead it's
- # implemented within each device-type handler. This value is the
+ # implemented within each device-type handler. This value is the
# lowest kelvin found supported across 20+ handlers.
_attr_min_color_temp_kelvin = 2000 # 500 mireds
# SmartThings does not expose this attribute, instead it's
- # implemented within each device-type handler. This value is the
+ # implemented within each device-type handler. This value is the
# highest kelvin found supported across 20+ handlers.
_attr_max_color_temp_kelvin = 9000 # 111 mireds
- def __init__(self, device):
+ def __init__(self, client: SmartThings, device: FullDevice) -> None:
"""Initialize a SmartThingsLight."""
- super().__init__(device)
- self._attr_supported_color_modes = self._determine_color_modes()
- self._attr_supported_features = self._determine_features()
-
- def _determine_color_modes(self):
- """Get features supported by the device."""
+ super().__init__(
+ client,
+ device,
+ {
+ Capability.COLOR_CONTROL,
+ Capability.COLOR_TEMPERATURE,
+ Capability.SWITCH_LEVEL,
+ Capability.SWITCH,
+ },
+ )
color_modes = set()
- # Color Temperature
- if Capability.color_temperature in self._device.capabilities:
+ if self.supports_capability(Capability.COLOR_TEMPERATURE):
color_modes.add(ColorMode.COLOR_TEMP)
- # Color
- if Capability.color_control in self._device.capabilities:
+ self._attr_color_mode = ColorMode.COLOR_TEMP
+ if self.supports_capability(Capability.COLOR_CONTROL):
color_modes.add(ColorMode.HS)
- # Brightness
- if not color_modes and Capability.switch_level in self._device.capabilities:
+ self._attr_color_mode = ColorMode.HS
+ if not color_modes and self.supports_capability(Capability.SWITCH_LEVEL):
color_modes.add(ColorMode.BRIGHTNESS)
if not color_modes:
color_modes.add(ColorMode.ONOFF)
-
- return color_modes
-
- def _determine_features(self) -> LightEntityFeature:
- """Get features supported by the device."""
+ if len(color_modes) == 1:
+ self._attr_color_mode = list(color_modes)[0]
+ self._attr_supported_color_modes = color_modes
features = LightEntityFeature(0)
- # Transition
- if Capability.switch_level in self._device.capabilities:
+ if self.supports_capability(Capability.SWITCH_LEVEL):
features |= LightEntityFeature.TRANSITION
+ self._attr_supported_features = features
- return features
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ if (last_state := await self.async_get_last_extra_data()) is not None:
+ self._attr_color_mode = last_state.as_dict()[ATTR_COLOR_MODE]
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
@@ -136,11 +127,10 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
kwargs[ATTR_BRIGHTNESS], kwargs.get(ATTR_TRANSITION, 0)
)
else:
- await self._device.switch_on(set_status=True)
-
- # State is set optimistically in the commands above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_schedule_update_ha_state(True)
+ await self.execute_device_command(
+ Capability.SWITCH,
+ Command.ON,
+ )
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
@@ -148,42 +138,74 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
if ATTR_TRANSITION in kwargs:
await self.async_set_level(0, int(kwargs[ATTR_TRANSITION]))
else:
- await self._device.switch_off(set_status=True)
+ await self.execute_device_command(
+ Capability.SWITCH,
+ Command.OFF,
+ )
- # State is set optimistically in the commands above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_schedule_update_ha_state(True)
-
- async def async_update(self) -> None:
+ def _update_attr(self) -> None:
"""Update entity attributes when the device status has changed."""
# Brightness and transition
if brightness_supported(self._attr_supported_color_modes):
- self._attr_brightness = int(
- convert_scale(self._device.status.level, 100, 255, 0)
- )
+ if (
+ brightness := self.get_attribute_value(
+ Capability.SWITCH_LEVEL, Attribute.LEVEL
+ )
+ ) is None:
+ self._attr_brightness = None
+ else:
+ self._attr_brightness = int(
+ convert_scale(
+ brightness,
+ 100,
+ 255,
+ 0,
+ )
+ )
# Color Temperature
if ColorMode.COLOR_TEMP in self._attr_supported_color_modes:
- self._attr_color_temp_kelvin = self._device.status.color_temperature
+ self._attr_color_temp_kelvin = self.get_attribute_value(
+ Capability.COLOR_TEMPERATURE, Attribute.COLOR_TEMPERATURE
+ )
# Color
if ColorMode.HS in self._attr_supported_color_modes:
- self._attr_hs_color = (
- convert_scale(self._device.status.hue, 100, 360),
- self._device.status.saturation,
- )
+ if (
+ hue := self.get_attribute_value(Capability.COLOR_CONTROL, Attribute.HUE)
+ ) is None:
+ self._attr_hs_color = None
+ else:
+ self._attr_hs_color = (
+ convert_scale(
+ hue,
+ 100,
+ 360,
+ ),
+ self.get_attribute_value(
+ Capability.COLOR_CONTROL, Attribute.SATURATION
+ ),
+ )
async def async_set_color(self, hs_color):
"""Set the color of the device."""
hue = convert_scale(float(hs_color[0]), 360, 100)
hue = max(min(hue, 100.0), 0.0)
saturation = max(min(float(hs_color[1]), 100.0), 0.0)
- await self._device.set_color(hue, saturation, set_status=True)
+ await self.execute_device_command(
+ Capability.COLOR_CONTROL,
+ Command.SET_COLOR,
+ argument={"hue": hue, "saturation": saturation},
+ )
async def async_set_color_temp(self, value: int):
"""Set the color temperature of the device."""
kelvin = max(min(value, 30000), 1)
- await self._device.set_color_temperature(kelvin, set_status=True)
+ await self.execute_device_command(
+ Capability.COLOR_TEMPERATURE,
+ Command.SET_COLOR_TEMPERATURE,
+ argument=kelvin,
+ )
- async def async_set_level(self, brightness: int, transition: int):
+ async def async_set_level(self, brightness: int, transition: int) -> None:
"""Set the brightness of the light over transition."""
level = int(convert_scale(brightness, 255, 100, 0))
# Due to rounding, set level to 1 (one) so we don't inadvertently
@@ -191,21 +213,26 @@ class SmartThingsLight(SmartThingsEntity, LightEntity):
level = 1 if level == 0 and brightness > 0 else level
level = max(min(level, 100), 0)
duration = int(transition)
- await self._device.set_level(level, duration, set_status=True)
+ await self.execute_device_command(
+ Capability.SWITCH_LEVEL,
+ Command.SET_LEVEL,
+ argument=[level, duration],
+ )
+
+ def _update_handler(self, event: DeviceEvent) -> None:
+ """Handle device updates."""
+ if event.capability in (Capability.COLOR_CONTROL, Capability.COLOR_TEMPERATURE):
+ self._attr_color_mode = {
+ Capability.COLOR_CONTROL: ColorMode.HS,
+ Capability.COLOR_TEMPERATURE: ColorMode.COLOR_TEMP,
+ }[cast(Capability, event.capability)]
+ super()._update_handler(event)
@property
- def color_mode(self) -> ColorMode:
- """Return the color mode of the light."""
- if len(self._attr_supported_color_modes) == 1:
- # The light supports only a single color mode
- return list(self._attr_supported_color_modes)[0]
-
- # The light supports hs + color temp, determine which one it is
- if self._attr_hs_color and self._attr_hs_color[1]:
- return ColorMode.HS
- return ColorMode.COLOR_TEMP
-
- @property
- def is_on(self) -> bool:
+ def is_on(self) -> bool | None:
"""Return true if light is on."""
- return self._device.status.switch
+ if (
+ state := self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
+ ) is None:
+ return None
+ return state == "on"
diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py
index a0ae9e50443..f56ecd5d565 100644
--- a/homeassistant/components/smartthings/lock.py
+++ b/homeassistant/components/smartthings/lock.py
@@ -2,17 +2,16 @@
from __future__ import annotations
-from collections.abc import Sequence
from typing import Any
-from pysmartthings import Attribute, Capability
+from pysmartthings import Attribute, Capability, Command
from homeassistant.components.lock import LockEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DATA_BROKERS, DOMAIN
+from . import SmartThingsConfigEntry
+from .const import MAIN
from .entity import SmartThingsEntity
ST_STATE_LOCKED = "locked"
@@ -28,48 +27,49 @@ ST_LOCK_ATTR_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add locks for a config entry."""
- broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
+ entry_data = entry.runtime_data
async_add_entities(
- SmartThingsLock(device)
- for device in broker.devices.values()
- if broker.any_assigned(device.device_id, "lock")
+ SmartThingsLock(entry_data.client, device, {Capability.LOCK})
+ for device in entry_data.devices.values()
+ if Capability.LOCK in device.status[MAIN]
)
-def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
- """Return all capabilities supported if minimum required are present."""
- if Capability.lock in capabilities:
- return [Capability.lock]
- return None
-
-
class SmartThingsLock(SmartThingsEntity, LockEntity):
"""Define a SmartThings lock."""
+ _attr_name = None
+
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the device."""
- await self._device.lock(set_status=True)
- self.async_write_ha_state()
+ await self.execute_device_command(
+ Capability.LOCK,
+ Command.LOCK,
+ )
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the device."""
- await self._device.unlock(set_status=True)
- self.async_write_ha_state()
+ await self.execute_device_command(
+ Capability.LOCK,
+ Command.UNLOCK,
+ )
@property
def is_locked(self) -> bool:
"""Return true if lock is locked."""
- return self._device.status.lock == ST_STATE_LOCKED
+ return (
+ self.get_attribute_value(Capability.LOCK, Attribute.LOCK) == ST_STATE_LOCKED
+ )
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return device specific state attributes."""
state_attrs = {}
- status = self._device.status.attributes[Attribute.lock]
+ status = self._internal_state[Capability.LOCK][Attribute.LOCK]
if status.value:
state_attrs["lock_state"] = status.value
if isinstance(status.data, dict):
diff --git a/homeassistant/components/smartthings/manifest.json b/homeassistant/components/smartthings/manifest.json
index be313248eaf..4cd27e49664 100644
--- a/homeassistant/components/smartthings/manifest.json
+++ b/homeassistant/components/smartthings/manifest.json
@@ -1,10 +1,9 @@
{
"domain": "smartthings",
"name": "SmartThings",
- "after_dependencies": ["cloud"],
- "codeowners": [],
+ "codeowners": ["@joostlek"],
"config_flow": true,
- "dependencies": ["webhook"],
+ "dependencies": ["application_credentials"],
"dhcp": [
{
"hostname": "st*",
@@ -29,6 +28,7 @@
],
"documentation": "https://www.home-assistant.io/integrations/smartthings",
"iot_class": "cloud_push",
- "loggers": ["httpsig", "pysmartapp", "pysmartthings"],
- "requirements": ["pysmartapp==0.3.5", "pysmartthings==0.7.8"]
+ "loggers": ["pysmartthings"],
+ "quality_scale": "bronze",
+ "requirements": ["pysmartthings==3.0.4"]
}
diff --git a/homeassistant/components/smartthings/media_player.py b/homeassistant/components/smartthings/media_player.py
new file mode 100644
index 00000000000..335e8255ae4
--- /dev/null
+++ b/homeassistant/components/smartthings/media_player.py
@@ -0,0 +1,359 @@
+"""Support for media players through the SmartThings cloud API."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from pysmartthings import Attribute, Capability, Category, Command, SmartThings
+
+from homeassistant.components.media_player import (
+ MediaPlayerDeviceClass,
+ MediaPlayerEntity,
+ MediaPlayerEntityFeature,
+ MediaPlayerState,
+ RepeatMode,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import FullDevice, SmartThingsConfigEntry
+from .const import MAIN
+from .entity import SmartThingsEntity
+
+MEDIA_PLAYER_CAPABILITIES = (
+ Capability.AUDIO_MUTE,
+ Capability.AUDIO_VOLUME,
+)
+
+CONTROLLABLE_SOURCES = ["bluetooth", "wifi"]
+
+DEVICE_CLASS_MAP: dict[Category | str, MediaPlayerDeviceClass] = {
+ Category.NETWORK_AUDIO: MediaPlayerDeviceClass.SPEAKER,
+ Category.SPEAKER: MediaPlayerDeviceClass.SPEAKER,
+ Category.TELEVISION: MediaPlayerDeviceClass.TV,
+ Category.RECEIVER: MediaPlayerDeviceClass.RECEIVER,
+}
+
+VALUE_TO_STATE = {
+ "buffering": MediaPlayerState.BUFFERING,
+ "paused": MediaPlayerState.PAUSED,
+ "playing": MediaPlayerState.PLAYING,
+ "stopped": MediaPlayerState.IDLE,
+ "fast forwarding": MediaPlayerState.BUFFERING,
+ "rewinding": MediaPlayerState.BUFFERING,
+}
+
+REPEAT_MODE_TO_HA = {
+ "all": RepeatMode.ALL,
+ "one": RepeatMode.ONE,
+ "off": RepeatMode.OFF,
+}
+
+HA_REPEAT_MODE_TO_SMARTTHINGS = {v: k for k, v in REPEAT_MODE_TO_HA.items()}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add media players for a config entry."""
+ entry_data = entry.runtime_data
+
+ async_add_entities(
+ SmartThingsMediaPlayer(entry_data.client, device)
+ for device in entry_data.devices.values()
+ if all(
+ capability in device.status[MAIN]
+ for capability in MEDIA_PLAYER_CAPABILITIES
+ )
+ )
+
+
+class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity):
+ """Define a SmartThings media player."""
+
+ _attr_name = None
+
+ def __init__(self, client: SmartThings, device: FullDevice) -> None:
+ """Initialize the media_player class."""
+ super().__init__(
+ client,
+ device,
+ {
+ Capability.AUDIO_MUTE,
+ Capability.AUDIO_TRACK_DATA,
+ Capability.AUDIO_VOLUME,
+ Capability.MEDIA_INPUT_SOURCE,
+ Capability.MEDIA_PLAYBACK,
+ Capability.MEDIA_PLAYBACK_REPEAT,
+ Capability.MEDIA_PLAYBACK_SHUFFLE,
+ Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE,
+ Capability.SWITCH,
+ },
+ )
+ self._attr_supported_features = self._determine_features()
+ self._attr_device_class = DEVICE_CLASS_MAP.get(
+ device.device.components[MAIN].user_category
+ or device.device.components[MAIN].manufacturer_category,
+ )
+
+ def _determine_features(self) -> MediaPlayerEntityFeature:
+ flags = (
+ MediaPlayerEntityFeature.VOLUME_SET
+ | MediaPlayerEntityFeature.VOLUME_STEP
+ | MediaPlayerEntityFeature.VOLUME_MUTE
+ )
+ if self.supports_capability(Capability.MEDIA_PLAYBACK):
+ playback_commands = self.get_attribute_value(
+ Capability.MEDIA_PLAYBACK, Attribute.SUPPORTED_PLAYBACK_COMMANDS
+ )
+ if "play" in playback_commands:
+ flags |= MediaPlayerEntityFeature.PLAY
+ if "pause" in playback_commands:
+ flags |= MediaPlayerEntityFeature.PAUSE
+ if "stop" in playback_commands:
+ flags |= MediaPlayerEntityFeature.STOP
+ if "rewind" in playback_commands:
+ flags |= MediaPlayerEntityFeature.PREVIOUS_TRACK
+ if "fastForward" in playback_commands:
+ flags |= MediaPlayerEntityFeature.NEXT_TRACK
+ if self.supports_capability(Capability.SWITCH):
+ flags |= (
+ MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF
+ )
+ if self.supports_capability(Capability.MEDIA_INPUT_SOURCE):
+ flags |= MediaPlayerEntityFeature.SELECT_SOURCE
+ if self.supports_capability(Capability.MEDIA_PLAYBACK_SHUFFLE):
+ flags |= MediaPlayerEntityFeature.SHUFFLE_SET
+ if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT):
+ flags |= MediaPlayerEntityFeature.REPEAT_SET
+ return flags
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the media player off."""
+ await self.execute_device_command(
+ Capability.SWITCH,
+ Command.OFF,
+ )
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the media player on."""
+ await self.execute_device_command(
+ Capability.SWITCH,
+ Command.ON,
+ )
+
+ async def async_mute_volume(self, mute: bool) -> None:
+ """Mute volume."""
+ await self.execute_device_command(
+ Capability.AUDIO_MUTE,
+ Command.SET_MUTE,
+ argument="muted" if mute else "unmuted",
+ )
+
+ async def async_set_volume_level(self, volume: float) -> None:
+ """Set volume level."""
+ await self.execute_device_command(
+ Capability.AUDIO_VOLUME,
+ Command.SET_VOLUME,
+ argument=int(volume * 100),
+ )
+
+ async def async_volume_up(self) -> None:
+ """Increase volume."""
+ await self.execute_device_command(
+ Capability.AUDIO_VOLUME,
+ Command.VOLUME_UP,
+ )
+
+ async def async_volume_down(self) -> None:
+ """Decrease volume."""
+ await self.execute_device_command(
+ Capability.AUDIO_VOLUME,
+ Command.VOLUME_DOWN,
+ )
+
+ async def async_media_play(self) -> None:
+ """Play media."""
+ await self.execute_device_command(
+ Capability.MEDIA_PLAYBACK,
+ Command.PLAY,
+ )
+
+ async def async_media_pause(self) -> None:
+ """Pause media."""
+ await self.execute_device_command(
+ Capability.MEDIA_PLAYBACK,
+ Command.PAUSE,
+ )
+
+ async def async_media_stop(self) -> None:
+ """Stop media."""
+ await self.execute_device_command(
+ Capability.MEDIA_PLAYBACK,
+ Command.STOP,
+ )
+
+ async def async_media_previous_track(self) -> None:
+ """Previous track."""
+ await self.execute_device_command(
+ Capability.MEDIA_PLAYBACK,
+ Command.REWIND,
+ )
+
+ async def async_media_next_track(self) -> None:
+ """Next track."""
+ await self.execute_device_command(
+ Capability.MEDIA_PLAYBACK,
+ Command.FAST_FORWARD,
+ )
+
+ async def async_select_source(self, source: str) -> None:
+ """Select source."""
+ await self.execute_device_command(
+ Capability.MEDIA_INPUT_SOURCE,
+ Command.SET_INPUT_SOURCE,
+ argument=source,
+ )
+
+ async def async_set_shuffle(self, shuffle: bool) -> None:
+ """Set shuffle mode."""
+ await self.execute_device_command(
+ Capability.MEDIA_PLAYBACK_SHUFFLE,
+ Command.SET_PLAYBACK_SHUFFLE,
+ argument="enabled" if shuffle else "disabled",
+ )
+
+ async def async_set_repeat(self, repeat: RepeatMode) -> None:
+ """Set repeat mode."""
+ await self.execute_device_command(
+ Capability.MEDIA_PLAYBACK_REPEAT,
+ Command.SET_PLAYBACK_REPEAT_MODE,
+ argument=HA_REPEAT_MODE_TO_SMARTTHINGS[repeat],
+ )
+
+ @property
+ def media_title(self) -> str | None:
+ """Title of current playing media."""
+ if (
+ not self.supports_capability(Capability.AUDIO_TRACK_DATA)
+ or (
+ track_data := self.get_attribute_value(
+ Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA
+ )
+ )
+ is None
+ ):
+ return None
+ return track_data.get("title", None)
+
+ @property
+ def media_artist(self) -> str | None:
+ """Artist of current playing media."""
+ if (
+ not self.supports_capability(Capability.AUDIO_TRACK_DATA)
+ or (
+ track_data := self.get_attribute_value(
+ Capability.AUDIO_TRACK_DATA, Attribute.AUDIO_TRACK_DATA
+ )
+ )
+ is None
+ ):
+ return None
+ return track_data.get("artist")
+
+ @property
+ def state(self) -> MediaPlayerState | None:
+ """State of the media player."""
+ if self.supports_capability(Capability.SWITCH):
+ if not self.supports_capability(Capability.MEDIA_PLAYBACK):
+ if (
+ self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH)
+ == "on"
+ ):
+ return MediaPlayerState.ON
+ return MediaPlayerState.OFF
+ if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "on":
+ if (
+ self.source is not None
+ and self.source in CONTROLLABLE_SOURCES
+ and self.get_attribute_value(
+ Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS
+ )
+ in VALUE_TO_STATE
+ ):
+ return VALUE_TO_STATE[
+ self.get_attribute_value(
+ Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS
+ )
+ ]
+ return MediaPlayerState.ON
+ return MediaPlayerState.OFF
+ return VALUE_TO_STATE[
+ self.get_attribute_value(
+ Capability.MEDIA_PLAYBACK, Attribute.PLAYBACK_STATUS
+ )
+ ]
+
+ @property
+ def is_volume_muted(self) -> bool:
+ """Returns if the volume is muted."""
+ return (
+ self.get_attribute_value(Capability.AUDIO_MUTE, Attribute.MUTE) == "muted"
+ )
+
+ @property
+ def volume_level(self) -> float:
+ """Volume level."""
+ return self.get_attribute_value(Capability.AUDIO_VOLUME, Attribute.VOLUME) / 100
+
+ @property
+ def source(self) -> str | None:
+ """Input source."""
+ if self.supports_capability(Capability.MEDIA_INPUT_SOURCE):
+ return self.get_attribute_value(
+ Capability.MEDIA_INPUT_SOURCE, Attribute.INPUT_SOURCE
+ )
+ if self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE):
+ return self.get_attribute_value(
+ Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE, Attribute.INPUT_SOURCE
+ )
+ return None
+
+ @property
+ def source_list(self) -> list[str] | None:
+ """List of input sources."""
+ if self.supports_capability(Capability.MEDIA_INPUT_SOURCE):
+ return self.get_attribute_value(
+ Capability.MEDIA_INPUT_SOURCE, Attribute.SUPPORTED_INPUT_SOURCES
+ )
+ if self.supports_capability(Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE):
+ return self.get_attribute_value(
+ Capability.SAMSUNG_VD_AUDIO_INPUT_SOURCE,
+ Attribute.SUPPORTED_INPUT_SOURCES,
+ )
+ return None
+
+ @property
+ def shuffle(self) -> bool | None:
+ """Returns if shuffle mode is set."""
+ if self.supports_capability(Capability.MEDIA_PLAYBACK_SHUFFLE):
+ return (
+ self.get_attribute_value(
+ Capability.MEDIA_PLAYBACK_SHUFFLE, Attribute.PLAYBACK_SHUFFLE
+ )
+ == "enabled"
+ )
+ return None
+
+ @property
+ def repeat(self) -> RepeatMode | None:
+ """Returns if repeat mode is set."""
+ if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT):
+ return REPEAT_MODE_TO_HA[
+ self.get_attribute_value(
+ Capability.MEDIA_PLAYBACK_REPEAT, Attribute.PLAYBACK_REPEAT_MODE
+ )
+ ]
+ return None
diff --git a/homeassistant/components/smartthings/number.py b/homeassistant/components/smartthings/number.py
new file mode 100644
index 00000000000..2f2ac7903f2
--- /dev/null
+++ b/homeassistant/components/smartthings/number.py
@@ -0,0 +1,76 @@
+"""Support for number entities through the SmartThings cloud API."""
+
+from __future__ import annotations
+
+from pysmartthings import Attribute, Capability, Command, SmartThings
+
+from homeassistant.components.number import NumberEntity, NumberMode
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import FullDevice, SmartThingsConfigEntry
+from .const import MAIN
+from .entity import SmartThingsEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add number entities for a config entry."""
+ entry_data = entry.runtime_data
+ async_add_entities(
+ SmartThingsWasherRinseCyclesNumberEntity(entry_data.client, device)
+ for device in entry_data.devices.values()
+ if Capability.CUSTOM_WASHER_RINSE_CYCLES in device.status[MAIN]
+ )
+
+
+class SmartThingsWasherRinseCyclesNumberEntity(SmartThingsEntity, NumberEntity):
+ """Define a SmartThings number."""
+
+ _attr_translation_key = "washer_rinse_cycles"
+ _attr_native_step = 1.0
+ _attr_mode = NumberMode.BOX
+
+ def __init__(self, client: SmartThings, device: FullDevice) -> None:
+ """Initialize the instance."""
+ super().__init__(client, device, {Capability.CUSTOM_WASHER_RINSE_CYCLES})
+ self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{Capability.CUSTOM_WASHER_RINSE_CYCLES}_{Attribute.WASHER_RINSE_CYCLES}_{Attribute.WASHER_RINSE_CYCLES}"
+
+ @property
+ def options(self) -> list[int]:
+ """Return the list of options."""
+ values = self.get_attribute_value(
+ Capability.CUSTOM_WASHER_RINSE_CYCLES,
+ Attribute.SUPPORTED_WASHER_RINSE_CYCLES,
+ )
+ return [int(value) for value in values] if values else []
+
+ @property
+ def native_value(self) -> float | None:
+ """Return the current value."""
+ return int(
+ self.get_attribute_value(
+ Capability.CUSTOM_WASHER_RINSE_CYCLES, Attribute.WASHER_RINSE_CYCLES
+ )
+ )
+
+ @property
+ def native_min_value(self) -> float:
+ """Return the minimum value."""
+ return min(self.options)
+
+ @property
+ def native_max_value(self) -> float:
+ """Return the maximum value."""
+ return max(self.options)
+
+ async def async_set_native_value(self, value: float) -> None:
+ """Set the value."""
+ await self.execute_device_command(
+ Capability.CUSTOM_WASHER_RINSE_CYCLES,
+ Command.SET_WASHER_RINSE_CYCLES,
+ str(int(value)),
+ )
diff --git a/homeassistant/components/smartthings/quality_scale.yaml b/homeassistant/components/smartthings/quality_scale.yaml
new file mode 100644
index 00000000000..be8a9039617
--- /dev/null
+++ b/homeassistant/components/smartthings/quality_scale.yaml
@@ -0,0 +1,80 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration works via push.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ 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: No options to configure
+ docs-installation-parameters:
+ status: exempt
+ comment: No parameters needed during installation
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage: todo
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration connects via the cloud.
+ discovery: done
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: todo
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration does not have any entities that are disabled by default.
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ stale-devices: done
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/smartthings/scene.py b/homeassistant/components/smartthings/scene.py
index 9756cef9f04..2b387859f22 100644
--- a/homeassistant/components/smartthings/scene.py
+++ b/homeassistant/components/smartthings/scene.py
@@ -2,39 +2,42 @@
from typing import Any
-from homeassistant.components.scene import Scene
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from pysmartthings import Scene as STScene, SmartThings
-from .const import DATA_BROKERS, DOMAIN
+from homeassistant.components.scene import Scene
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import SmartThingsConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Add switches for a config entry."""
- broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
- async_add_entities(SmartThingsScene(scene) for scene in broker.scenes.values())
+ """Add lights for a config entry."""
+ client = entry.runtime_data.client
+ scenes = entry.runtime_data.scenes
+ async_add_entities(SmartThingsScene(scene, client) for scene in scenes.values())
class SmartThingsScene(Scene):
"""Define a SmartThings scene."""
- def __init__(self, scene):
+ def __init__(self, scene: STScene, client: SmartThings) -> None:
"""Init the scene class."""
+ self.client = client
self._scene = scene
self._attr_name = scene.name
self._attr_unique_id = scene.scene_id
async def async_activate(self, **kwargs: Any) -> None:
"""Activate scene."""
- await self._scene.execute()
+ await self.client.execute_scene(self._scene.scene_id)
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, Any]:
"""Get attributes about the state."""
return {
"icon": self._scene.icon,
diff --git a/homeassistant/components/smartthings/select.py b/homeassistant/components/smartthings/select.py
new file mode 100644
index 00000000000..f0a483b1329
--- /dev/null
+++ b/homeassistant/components/smartthings/select.py
@@ -0,0 +1,127 @@
+"""Support for select entities through the SmartThings cloud API."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from pysmartthings import Attribute, Capability, Command, SmartThings
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ServiceValidationError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import FullDevice, SmartThingsConfigEntry
+from .const import MAIN
+from .entity import SmartThingsEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class SmartThingsSelectDescription(SelectEntityDescription):
+ """Class describing SmartThings select entities."""
+
+ key: Capability
+ requires_remote_control_status: bool
+ options_attribute: Attribute
+ status_attribute: Attribute
+ command: Command
+
+
+CAPABILITIES_TO_SELECT: dict[Capability | str, SmartThingsSelectDescription] = {
+ Capability.DISHWASHER_OPERATING_STATE: SmartThingsSelectDescription(
+ key=Capability.DISHWASHER_OPERATING_STATE,
+ name=None,
+ translation_key="operating_state",
+ requires_remote_control_status=True,
+ options_attribute=Attribute.SUPPORTED_MACHINE_STATES,
+ status_attribute=Attribute.MACHINE_STATE,
+ command=Command.SET_MACHINE_STATE,
+ ),
+ Capability.DRYER_OPERATING_STATE: SmartThingsSelectDescription(
+ key=Capability.DRYER_OPERATING_STATE,
+ name=None,
+ translation_key="operating_state",
+ requires_remote_control_status=True,
+ options_attribute=Attribute.SUPPORTED_MACHINE_STATES,
+ status_attribute=Attribute.MACHINE_STATE,
+ command=Command.SET_MACHINE_STATE,
+ ),
+ Capability.WASHER_OPERATING_STATE: SmartThingsSelectDescription(
+ key=Capability.WASHER_OPERATING_STATE,
+ name=None,
+ translation_key="operating_state",
+ requires_remote_control_status=True,
+ options_attribute=Attribute.SUPPORTED_MACHINE_STATES,
+ status_attribute=Attribute.MACHINE_STATE,
+ command=Command.SET_MACHINE_STATE,
+ ),
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add select entities for a config entry."""
+ entry_data = entry.runtime_data
+ async_add_entities(
+ SmartThingsSelectEntity(
+ entry_data.client, device, CAPABILITIES_TO_SELECT[capability]
+ )
+ for device in entry_data.devices.values()
+ for capability in device.status[MAIN]
+ if capability in CAPABILITIES_TO_SELECT
+ )
+
+
+class SmartThingsSelectEntity(SmartThingsEntity, SelectEntity):
+ """Define a SmartThings select."""
+
+ entity_description: SmartThingsSelectDescription
+
+ def __init__(
+ self,
+ client: SmartThings,
+ device: FullDevice,
+ entity_description: SmartThingsSelectDescription,
+ ) -> None:
+ """Initialize the instance."""
+ capabilities = {entity_description.key}
+ if entity_description.requires_remote_control_status:
+ capabilities.add(Capability.REMOTE_CONTROL_STATUS)
+ super().__init__(client, device, capabilities)
+ self.entity_description = entity_description
+ self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{entity_description.key}_{entity_description.status_attribute}_{entity_description.status_attribute}"
+
+ @property
+ def options(self) -> list[str]:
+ """Return the list of options."""
+ return self.get_attribute_value(
+ self.entity_description.key, self.entity_description.options_attribute
+ )
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the current option."""
+ return self.get_attribute_value(
+ self.entity_description.key, self.entity_description.status_attribute
+ )
+
+ async def async_select_option(self, option: str) -> None:
+ """Select an option."""
+ if (
+ self.entity_description.requires_remote_control_status
+ and self.get_attribute_value(
+ Capability.REMOTE_CONTROL_STATUS, Attribute.REMOTE_CONTROL_ENABLED
+ )
+ == "false"
+ ):
+ raise ServiceValidationError(
+ "Can only be updated when remote control is enabled"
+ )
+ await self.execute_device_command(
+ self.entity_description.key,
+ self.entity_description.command,
+ option,
+ )
diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py
index 8bd0421d2bc..d5a465b8ccc 100644
--- a/homeassistant/components/smartthings/sensor.py
+++ b/homeassistant/components/smartthings/sensor.py
@@ -2,25 +2,27 @@
from __future__ import annotations
-from collections.abc import Sequence
-from typing import NamedTuple
+from collections.abc import Callable, Mapping
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, cast
-from pysmartthings import Attribute, Capability
-from pysmartthings.device import DeviceEntity
+from pysmartthings import Attribute, Capability, ComponentStatus, SmartThings, Status
from homeassistant.components.sensor import (
+ DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorEntity,
+ SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
LIGHT_LUX,
PERCENTAGE,
EntityCategory,
UnitOfArea,
- UnitOfElectricPotential,
UnitOfEnergy,
UnitOfMass,
UnitOfPower,
@@ -28,711 +30,1111 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
-from .const import DATA_BROKERS, DOMAIN
+from . import FullDevice, SmartThingsConfigEntry
+from .const import MAIN
from .entity import SmartThingsEntity
+from .util import deprecate_entity
-
-class Map(NamedTuple):
- """Tuple for mapping Smartthings capabilities to Home Assistant sensors."""
-
- attribute: str
- name: str
- default_unit: str | None
- device_class: SensorDeviceClass | None
- state_class: SensorStateClass | None
- entity_category: EntityCategory | None
-
-
-CAPABILITY_TO_SENSORS: dict[str, list[Map]] = {
- Capability.activity_lighting_mode: [
- Map(
- Attribute.lighting_mode,
- "Activity Lighting Mode",
- None,
- None,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.air_conditioner_mode: [
- Map(
- Attribute.air_conditioner_mode,
- "Air Conditioner Mode",
- None,
- None,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.air_quality_sensor: [
- Map(
- Attribute.air_quality,
- "Air Quality",
- "CAQI",
- None,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None, None, None)],
- Capability.audio_volume: [
- Map(Attribute.volume, "Volume", PERCENTAGE, None, None, None)
- ],
- Capability.battery: [
- Map(
- Attribute.battery,
- "Battery",
- PERCENTAGE,
- SensorDeviceClass.BATTERY,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.body_mass_index_measurement: [
- Map(
- Attribute.bmi_measurement,
- "Body Mass Index",
- f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}",
- None,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.body_weight_measurement: [
- Map(
- Attribute.body_weight_measurement,
- "Body Weight",
- UnitOfMass.KILOGRAMS,
- SensorDeviceClass.WEIGHT,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.carbon_dioxide_measurement: [
- Map(
- Attribute.carbon_dioxide,
- "Carbon Dioxide Measurement",
- CONCENTRATION_PARTS_PER_MILLION,
- SensorDeviceClass.CO2,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.carbon_monoxide_detector: [
- Map(
- Attribute.carbon_monoxide,
- "Carbon Monoxide Detector",
- None,
- None,
- None,
- None,
- )
- ],
- Capability.carbon_monoxide_measurement: [
- Map(
- Attribute.carbon_monoxide_level,
- "Carbon Monoxide Measurement",
- CONCENTRATION_PARTS_PER_MILLION,
- SensorDeviceClass.CO,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.dishwasher_operating_state: [
- Map(
- Attribute.machine_state, "Dishwasher Machine State", None, None, None, None
- ),
- Map(
- Attribute.dishwasher_job_state,
- "Dishwasher Job State",
- None,
- None,
- None,
- None,
- ),
- Map(
- Attribute.completion_time,
- "Dishwasher Completion Time",
- None,
- SensorDeviceClass.TIMESTAMP,
- None,
- None,
- ),
- ],
- Capability.dryer_mode: [
- Map(
- Attribute.dryer_mode,
- "Dryer Mode",
- None,
- None,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.dryer_operating_state: [
- Map(Attribute.machine_state, "Dryer Machine State", None, None, None, None),
- Map(Attribute.dryer_job_state, "Dryer Job State", None, None, None, None),
- Map(
- Attribute.completion_time,
- "Dryer Completion Time",
- None,
- SensorDeviceClass.TIMESTAMP,
- None,
- None,
- ),
- ],
- Capability.dust_sensor: [
- Map(
- Attribute.fine_dust_level,
- "Fine Dust Level",
- None,
- None,
- SensorStateClass.MEASUREMENT,
- None,
- ),
- Map(
- Attribute.dust_level,
- "Dust Level",
- None,
- None,
- SensorStateClass.MEASUREMENT,
- None,
- ),
- ],
- Capability.energy_meter: [
- Map(
- Attribute.energy,
- "Energy Meter",
- UnitOfEnergy.KILO_WATT_HOUR,
- SensorDeviceClass.ENERGY,
- SensorStateClass.TOTAL_INCREASING,
- None,
- )
- ],
- Capability.equivalent_carbon_dioxide_measurement: [
- Map(
- Attribute.equivalent_carbon_dioxide_measurement,
- "Equivalent Carbon Dioxide Measurement",
- CONCENTRATION_PARTS_PER_MILLION,
- SensorDeviceClass.CO2,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.formaldehyde_measurement: [
- Map(
- Attribute.formaldehyde_level,
- "Formaldehyde Measurement",
- CONCENTRATION_PARTS_PER_MILLION,
- None,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.gas_meter: [
- Map(
- Attribute.gas_meter,
- "Gas Meter",
- UnitOfEnergy.KILO_WATT_HOUR,
- SensorDeviceClass.ENERGY,
- SensorStateClass.MEASUREMENT,
- None,
- ),
- Map(
- Attribute.gas_meter_calorific, "Gas Meter Calorific", None, None, None, None
- ),
- Map(
- Attribute.gas_meter_time,
- "Gas Meter Time",
- None,
- SensorDeviceClass.TIMESTAMP,
- None,
- None,
- ),
- Map(
- Attribute.gas_meter_volume,
- "Gas Meter Volume",
- UnitOfVolume.CUBIC_METERS,
- SensorDeviceClass.GAS,
- SensorStateClass.MEASUREMENT,
- None,
- ),
- ],
- Capability.illuminance_measurement: [
- Map(
- Attribute.illuminance,
- "Illuminance",
- LIGHT_LUX,
- SensorDeviceClass.ILLUMINANCE,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.infrared_level: [
- Map(
- Attribute.infrared_level,
- "Infrared Level",
- PERCENTAGE,
- None,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.media_input_source: [
- Map(Attribute.input_source, "Media Input Source", None, None, None, None)
- ],
- Capability.media_playback_repeat: [
- Map(
- Attribute.playback_repeat_mode,
- "Media Playback Repeat",
- None,
- None,
- None,
- None,
- )
- ],
- Capability.media_playback_shuffle: [
- Map(
- Attribute.playback_shuffle, "Media Playback Shuffle", None, None, None, None
- )
- ],
- Capability.media_playback: [
- Map(Attribute.playback_status, "Media Playback Status", None, None, None, None)
- ],
- Capability.odor_sensor: [
- Map(Attribute.odor_level, "Odor Sensor", None, None, None, None)
- ],
- Capability.oven_mode: [
- Map(
- Attribute.oven_mode,
- "Oven Mode",
- None,
- None,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.oven_operating_state: [
- Map(Attribute.machine_state, "Oven Machine State", None, None, None, None),
- Map(Attribute.oven_job_state, "Oven Job State", None, None, None, None),
- Map(Attribute.completion_time, "Oven Completion Time", None, None, None, None),
- ],
- Capability.oven_setpoint: [
- Map(Attribute.oven_setpoint, "Oven Set Point", None, None, None, None)
- ],
- Capability.power_consumption_report: [],
- Capability.power_meter: [
- Map(
- Attribute.power,
- "Power Meter",
- UnitOfPower.WATT,
- SensorDeviceClass.POWER,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.power_source: [
- Map(
- Attribute.power_source,
- "Power Source",
- None,
- None,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.refrigeration_setpoint: [
- Map(
- Attribute.refrigeration_setpoint,
- "Refrigeration Setpoint",
- None,
- SensorDeviceClass.TEMPERATURE,
- None,
- None,
- )
- ],
- Capability.relative_humidity_measurement: [
- Map(
- Attribute.humidity,
- "Relative Humidity Measurement",
- PERCENTAGE,
- SensorDeviceClass.HUMIDITY,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.robot_cleaner_cleaning_mode: [
- Map(
- Attribute.robot_cleaner_cleaning_mode,
- "Robot Cleaner Cleaning Mode",
- None,
- None,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.robot_cleaner_movement: [
- Map(
- Attribute.robot_cleaner_movement,
- "Robot Cleaner Movement",
- None,
- None,
- None,
- None,
- )
- ],
- Capability.robot_cleaner_turbo_mode: [
- Map(
- Attribute.robot_cleaner_turbo_mode,
- "Robot Cleaner Turbo Mode",
- None,
- None,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.signal_strength: [
- Map(
- Attribute.lqi,
- "LQI Signal Strength",
- None,
- None,
- SensorStateClass.MEASUREMENT,
- EntityCategory.DIAGNOSTIC,
- ),
- Map(
- Attribute.rssi,
- "RSSI Signal Strength",
- None,
- SensorDeviceClass.SIGNAL_STRENGTH,
- SensorStateClass.MEASUREMENT,
- EntityCategory.DIAGNOSTIC,
- ),
- ],
- Capability.smoke_detector: [
- Map(Attribute.smoke, "Smoke Detector", None, None, None, None)
- ],
- Capability.temperature_measurement: [
- Map(
- Attribute.temperature,
- "Temperature Measurement",
- None,
- SensorDeviceClass.TEMPERATURE,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.thermostat_cooling_setpoint: [
- Map(
- Attribute.cooling_setpoint,
- "Thermostat Cooling Setpoint",
- None,
- SensorDeviceClass.TEMPERATURE,
- None,
- None,
- )
- ],
- Capability.thermostat_fan_mode: [
- Map(
- Attribute.thermostat_fan_mode,
- "Thermostat Fan Mode",
- None,
- None,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.thermostat_heating_setpoint: [
- Map(
- Attribute.heating_setpoint,
- "Thermostat Heating Setpoint",
- None,
- SensorDeviceClass.TEMPERATURE,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.thermostat_mode: [
- Map(
- Attribute.thermostat_mode,
- "Thermostat Mode",
- None,
- None,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.thermostat_operating_state: [
- Map(
- Attribute.thermostat_operating_state,
- "Thermostat Operating State",
- None,
- None,
- None,
- None,
- )
- ],
- Capability.thermostat_setpoint: [
- Map(
- Attribute.thermostat_setpoint,
- "Thermostat Setpoint",
- None,
- SensorDeviceClass.TEMPERATURE,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.three_axis: [],
- Capability.tv_channel: [
- Map(Attribute.tv_channel, "Tv Channel", None, None, None, None),
- Map(Attribute.tv_channel_name, "Tv Channel Name", None, None, None, None),
- ],
- Capability.tvoc_measurement: [
- Map(
- Attribute.tvoc_level,
- "Tvoc Measurement",
- CONCENTRATION_PARTS_PER_MILLION,
- None,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.ultraviolet_index: [
- Map(
- Attribute.ultraviolet_index,
- "Ultraviolet Index",
- None,
- None,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.voltage_measurement: [
- Map(
- Attribute.voltage,
- "Voltage Measurement",
- UnitOfElectricPotential.VOLT,
- SensorDeviceClass.VOLTAGE,
- SensorStateClass.MEASUREMENT,
- None,
- )
- ],
- Capability.washer_mode: [
- Map(
- Attribute.washer_mode,
- "Washer Mode",
- None,
- None,
- None,
- EntityCategory.DIAGNOSTIC,
- )
- ],
- Capability.washer_operating_state: [
- Map(Attribute.machine_state, "Washer Machine State", None, None, None, None),
- Map(Attribute.washer_job_state, "Washer Job State", None, None, None, None),
- Map(
- Attribute.completion_time,
- "Washer Completion Time",
- None,
- SensorDeviceClass.TIMESTAMP,
- None,
- None,
- ),
- ],
+THERMOSTAT_CAPABILITIES = {
+ Capability.TEMPERATURE_MEASUREMENT,
+ Capability.THERMOSTAT_HEATING_SETPOINT,
+ Capability.THERMOSTAT_MODE,
}
+JOB_STATE_MAP = {
+ "airWash": "air_wash",
+ "airwash": "air_wash",
+ "aIRinse": "ai_rinse",
+ "aISpin": "ai_spin",
+ "aIWash": "ai_wash",
+ "aIDrying": "ai_drying",
+ "internalCare": "internal_care",
+ "continuousDehumidifying": "continuous_dehumidifying",
+ "thawingFrozenInside": "thawing_frozen_inside",
+ "delayWash": "delay_wash",
+ "weightSensing": "weight_sensing",
+ "freezeProtection": "freeze_protection",
+ "preDrain": "pre_drain",
+ "preWash": "pre_wash",
+ "prewash": "pre_wash",
+ "wrinklePrevent": "wrinkle_prevent",
+ "unknown": None,
+}
+
+OVEN_JOB_STATE_MAP = {
+ "scheduledStart": "scheduled_start",
+ "fastPreheat": "fast_preheat",
+ "scheduledEnd": "scheduled_end",
+ "stone_heating": "stone_heating",
+ "timeHoldPreheat": "time_hold_preheat",
+}
+
+MEDIA_PLAYBACK_STATE_MAP = {
+ "fast forwarding": "fast_forwarding",
+}
+
+ROBOT_CLEANER_TURBO_MODE_STATE_MAP = {
+ "extraSilence": "extra_silence",
+}
+
+ROBOT_CLEANER_MOVEMENT_MAP = {
+ "powerOff": "off",
+}
+
+OVEN_MODE = {
+ "Conventional": "conventional",
+ "Bake": "bake",
+ "BottomHeat": "bottom_heat",
+ "ConvectionBake": "convection_bake",
+ "ConvectionRoast": "convection_roast",
+ "Broil": "broil",
+ "ConvectionBroil": "convection_broil",
+ "SteamCook": "steam_cook",
+ "SteamBake": "steam_bake",
+ "SteamRoast": "steam_roast",
+ "SteamBottomHeatplusConvection": "steam_bottom_heat_plus_convection",
+ "Microwave": "microwave",
+ "MWplusGrill": "microwave_plus_grill",
+ "MWplusConvection": "microwave_plus_convection",
+ "MWplusHotBlast": "microwave_plus_hot_blast",
+ "MWplusHotBlast2": "microwave_plus_hot_blast_2",
+ "SlimMiddle": "slim_middle",
+ "SlimStrong": "slim_strong",
+ "SlowCook": "slow_cook",
+ "Proof": "proof",
+ "Dehydrate": "dehydrate",
+ "Others": "others",
+ "StrongSteam": "strong_steam",
+ "Descale": "descale",
+ "Rinse": "rinse",
+}
+
+WASHER_OPTIONS = ["pause", "run", "stop"]
+
+
+def power_attributes(status: dict[str, Any]) -> dict[str, Any]:
+ """Return the power attributes."""
+ state = {}
+ for attribute in ("start", "end"):
+ if (value := status.get(attribute)) is not None:
+ state[f"power_consumption_{attribute}"] = value
+ return state
+
+
+@dataclass(frozen=True, kw_only=True)
+class SmartThingsSensorEntityDescription(SensorEntityDescription):
+ """Describe a SmartThings sensor entity."""
+
+ value_fn: Callable[[Any], str | float | int | datetime | None] = lambda value: value
+ extra_state_attributes_fn: Callable[[Any], dict[str, Any]] | None = None
+ capability_ignore_list: list[set[Capability]] | None = None
+ options_attribute: Attribute | None = None
+ exists_fn: Callable[[Status], bool] | None = None
+ use_temperature_unit: bool = False
+ deprecated: Callable[[ComponentStatus], str | None] | None = None
+
+
+CAPABILITY_TO_SENSORS: dict[
+ Capability, dict[Attribute, list[SmartThingsSensorEntityDescription]]
+] = {
+ # Haven't seen at devices yet
+ Capability.ACTIVITY_LIGHTING_MODE: {
+ Attribute.LIGHTING_MODE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.LIGHTING_MODE,
+ translation_key="lighting_mode",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ ]
+ },
+ Capability.AIR_CONDITIONER_MODE: {
+ Attribute.AIR_CONDITIONER_MODE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.AIR_CONDITIONER_MODE,
+ translation_key="air_conditioner_mode",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ capability_ignore_list=[
+ {
+ Capability.TEMPERATURE_MEASUREMENT,
+ Capability.THERMOSTAT_COOLING_SETPOINT,
+ }
+ ],
+ )
+ ]
+ },
+ Capability.AIR_QUALITY_SENSOR: {
+ Attribute.AIR_QUALITY: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.AIR_QUALITY,
+ translation_key="air_quality",
+ native_unit_of_measurement="CAQI",
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ Capability.ALARM: {
+ Attribute.ALARM: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.ALARM,
+ translation_key="alarm",
+ options=["both", "strobe", "siren", "off"],
+ device_class=SensorDeviceClass.ENUM,
+ )
+ ]
+ },
+ Capability.AUDIO_VOLUME: {
+ Attribute.VOLUME: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.VOLUME,
+ translation_key="audio_volume",
+ native_unit_of_measurement=PERCENTAGE,
+ deprecated=(
+ lambda status: "media_player"
+ if Capability.AUDIO_MUTE in status
+ else None
+ ),
+ )
+ ]
+ },
+ Capability.BATTERY: {
+ Attribute.BATTERY: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.BATTERY,
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.BODY_MASS_INDEX_MEASUREMENT: {
+ Attribute.BMI_MEASUREMENT: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.BMI_MEASUREMENT,
+ translation_key="body_mass_index",
+ native_unit_of_measurement=f"{UnitOfMass.KILOGRAMS}/{UnitOfArea.SQUARE_METERS}",
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.BODY_WEIGHT_MEASUREMENT: {
+ Attribute.BODY_WEIGHT_MEASUREMENT: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.BODY_WEIGHT_MEASUREMENT,
+ translation_key="body_weight",
+ native_unit_of_measurement=UnitOfMass.KILOGRAMS,
+ device_class=SensorDeviceClass.WEIGHT,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ Capability.CARBON_DIOXIDE_MEASUREMENT: {
+ Attribute.CARBON_DIOXIDE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.CARBON_DIOXIDE,
+ native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
+ device_class=SensorDeviceClass.CO2,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.CARBON_MONOXIDE_DETECTOR: {
+ Attribute.CARBON_MONOXIDE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.CARBON_MONOXIDE,
+ translation_key="carbon_monoxide_detector",
+ options=["detected", "clear", "tested"],
+ device_class=SensorDeviceClass.ENUM,
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.CARBON_MONOXIDE_MEASUREMENT: {
+ Attribute.CARBON_MONOXIDE_LEVEL: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.CARBON_MONOXIDE_LEVEL,
+ native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
+ device_class=SensorDeviceClass.CO,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ Capability.DISHWASHER_OPERATING_STATE: {
+ Attribute.MACHINE_STATE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.MACHINE_STATE,
+ translation_key="dishwasher_machine_state",
+ options=WASHER_OPTIONS,
+ device_class=SensorDeviceClass.ENUM,
+ )
+ ],
+ Attribute.DISHWASHER_JOB_STATE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.DISHWASHER_JOB_STATE,
+ translation_key="dishwasher_job_state",
+ options=[
+ "air_wash",
+ "cooling",
+ "drying",
+ "finish",
+ "pre_drain",
+ "pre_wash",
+ "rinse",
+ "spin",
+ "wash",
+ "wrinkle_prevent",
+ ],
+ device_class=SensorDeviceClass.ENUM,
+ value_fn=lambda value: JOB_STATE_MAP.get(value, value),
+ )
+ ],
+ Attribute.COMPLETION_TIME: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.COMPLETION_TIME,
+ translation_key="completion_time",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=dt_util.parse_datetime,
+ )
+ ],
+ },
+ # part of the proposed spec, Haven't seen at devices yet
+ Capability.DRYER_MODE: {
+ Attribute.DRYER_MODE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.DRYER_MODE,
+ translation_key="dryer_mode",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ ]
+ },
+ Capability.DRYER_OPERATING_STATE: {
+ Attribute.MACHINE_STATE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.MACHINE_STATE,
+ translation_key="dryer_machine_state",
+ options=WASHER_OPTIONS,
+ device_class=SensorDeviceClass.ENUM,
+ )
+ ],
+ Attribute.DRYER_JOB_STATE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.DRYER_JOB_STATE,
+ translation_key="dryer_job_state",
+ options=[
+ "cooling",
+ "delay_wash",
+ "drying",
+ "finished",
+ "none",
+ "refreshing",
+ "weight_sensing",
+ "wrinkle_prevent",
+ "dehumidifying",
+ "ai_drying",
+ "sanitizing",
+ "internal_care",
+ "freeze_protection",
+ "continuous_dehumidifying",
+ "thawing_frozen_inside",
+ ],
+ device_class=SensorDeviceClass.ENUM,
+ value_fn=lambda value: JOB_STATE_MAP.get(value, value),
+ )
+ ],
+ Attribute.COMPLETION_TIME: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.COMPLETION_TIME,
+ translation_key="completion_time",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=dt_util.parse_datetime,
+ )
+ ],
+ },
+ Capability.DUST_SENSOR: {
+ Attribute.DUST_LEVEL: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.DUST_LEVEL,
+ device_class=SensorDeviceClass.PM10,
+ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ],
+ Attribute.FINE_DUST_LEVEL: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.FINE_DUST_LEVEL,
+ device_class=SensorDeviceClass.PM25,
+ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ],
+ },
+ Capability.ENERGY_METER: {
+ Attribute.ENERGY: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: {
+ Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.EQUIVALENT_CARBON_DIOXIDE_MEASUREMENT,
+ translation_key="equivalent_carbon_dioxide",
+ native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
+ device_class=SensorDeviceClass.CO2,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.FORMALDEHYDE_MEASUREMENT: {
+ Attribute.FORMALDEHYDE_LEVEL: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.FORMALDEHYDE_LEVEL,
+ translation_key="formaldehyde",
+ native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ Capability.GAS_METER: {
+ Attribute.GAS_METER: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.GAS_METER,
+ translation_key="gas_meter",
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL,
+ )
+ ],
+ Attribute.GAS_METER_CALORIFIC: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.GAS_METER_CALORIFIC,
+ translation_key="gas_meter_calorific",
+ )
+ ],
+ Attribute.GAS_METER_TIME: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.GAS_METER_TIME,
+ translation_key="gas_meter_time",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=dt_util.parse_datetime,
+ )
+ ],
+ Attribute.GAS_METER_VOLUME: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.GAS_METER_VOLUME,
+ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
+ device_class=SensorDeviceClass.GAS,
+ state_class=SensorStateClass.TOTAL,
+ )
+ ],
+ },
+ # Haven't seen at devices yet
+ Capability.ILLUMINANCE_MEASUREMENT: {
+ Attribute.ILLUMINANCE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.ILLUMINANCE,
+ native_unit_of_measurement=LIGHT_LUX,
+ device_class=SensorDeviceClass.ILLUMINANCE,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.INFRARED_LEVEL: {
+ Attribute.INFRARED_LEVEL: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.INFRARED_LEVEL,
+ translation_key="infrared_level",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ Capability.MEDIA_INPUT_SOURCE: {
+ Attribute.INPUT_SOURCE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.INPUT_SOURCE,
+ translation_key="media_input_source",
+ device_class=SensorDeviceClass.ENUM,
+ options_attribute=Attribute.SUPPORTED_INPUT_SOURCES,
+ value_fn=lambda value: value.lower() if value else None,
+ deprecated=lambda _: "media_player",
+ )
+ ]
+ },
+ Capability.MEDIA_PLAYBACK_REPEAT: {
+ Attribute.PLAYBACK_REPEAT_MODE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.PLAYBACK_REPEAT_MODE,
+ translation_key="media_playback_repeat",
+ deprecated=lambda _: "media_player",
+ )
+ ]
+ },
+ Capability.MEDIA_PLAYBACK_SHUFFLE: {
+ Attribute.PLAYBACK_SHUFFLE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.PLAYBACK_SHUFFLE,
+ translation_key="media_playback_shuffle",
+ deprecated=lambda _: "media_player",
+ )
+ ]
+ },
+ Capability.MEDIA_PLAYBACK: {
+ Attribute.PLAYBACK_STATUS: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.PLAYBACK_STATUS,
+ translation_key="media_playback_status",
+ options=[
+ "paused",
+ "playing",
+ "stopped",
+ "fast_forwarding",
+ "rewinding",
+ "buffering",
+ ],
+ device_class=SensorDeviceClass.ENUM,
+ value_fn=lambda value: MEDIA_PLAYBACK_STATE_MAP.get(value, value),
+ deprecated=lambda _: "media_player",
+ )
+ ]
+ },
+ Capability.ODOR_SENSOR: {
+ Attribute.ODOR_LEVEL: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.ODOR_LEVEL,
+ translation_key="odor_sensor",
+ )
+ ]
+ },
+ Capability.OVEN_MODE: {
+ Attribute.OVEN_MODE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.OVEN_MODE,
+ translation_key="oven_mode",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ options=list(OVEN_MODE.values()),
+ device_class=SensorDeviceClass.ENUM,
+ value_fn=lambda value: OVEN_MODE.get(value, value),
+ )
+ ]
+ },
+ Capability.OVEN_OPERATING_STATE: {
+ Attribute.MACHINE_STATE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.MACHINE_STATE,
+ translation_key="oven_machine_state",
+ options=["ready", "running", "paused"],
+ device_class=SensorDeviceClass.ENUM,
+ )
+ ],
+ Attribute.OVEN_JOB_STATE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.OVEN_JOB_STATE,
+ translation_key="oven_job_state",
+ options=[
+ "cleaning",
+ "cooking",
+ "cooling",
+ "draining",
+ "preheat",
+ "ready",
+ "rinsing",
+ "finished",
+ "scheduled_start",
+ "warming",
+ "defrosting",
+ "sensing",
+ "searing",
+ "fast_preheat",
+ "scheduled_end",
+ "stone_heating",
+ "time_hold_preheat",
+ ],
+ device_class=SensorDeviceClass.ENUM,
+ value_fn=lambda value: OVEN_JOB_STATE_MAP.get(value, value),
+ )
+ ],
+ Attribute.COMPLETION_TIME: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.COMPLETION_TIME,
+ translation_key="completion_time",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=dt_util.parse_datetime,
+ )
+ ],
+ },
+ Capability.OVEN_SETPOINT: {
+ Attribute.OVEN_SETPOINT: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.OVEN_SETPOINT,
+ translation_key="oven_setpoint",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ use_temperature_unit=True,
+ # Set the value to None if it is 0 F (-17 C)
+ value_fn=lambda value: None if value in {0, -17} else value,
+ )
+ ]
+ },
+ Capability.POWER_CONSUMPTION_REPORT: {
+ Attribute.POWER_CONSUMPTION: [
+ SmartThingsSensorEntityDescription(
+ key="energy_meter",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_fn=lambda value: value["energy"] / 1000,
+ suggested_display_precision=2,
+ exists_fn=lambda status: (
+ (value := cast(dict | None, status.value)) is not None
+ and "energy" in value
+ ),
+ ),
+ SmartThingsSensorEntityDescription(
+ key="power_meter",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ value_fn=lambda value: value["power"],
+ extra_state_attributes_fn=power_attributes,
+ suggested_display_precision=2,
+ exists_fn=lambda status: (
+ (value := cast(dict | None, status.value)) is not None
+ and "power" in value
+ ),
+ ),
+ SmartThingsSensorEntityDescription(
+ key="deltaEnergy_meter",
+ translation_key="energy_difference",
+ state_class=SensorStateClass.TOTAL,
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_fn=lambda value: value["deltaEnergy"] / 1000,
+ suggested_display_precision=2,
+ exists_fn=lambda status: (
+ (value := cast(dict | None, status.value)) is not None
+ and "deltaEnergy" in value
+ ),
+ ),
+ SmartThingsSensorEntityDescription(
+ key="powerEnergy_meter",
+ translation_key="power_energy",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_fn=lambda value: value["powerEnergy"] / 1000,
+ suggested_display_precision=2,
+ exists_fn=lambda status: (
+ (value := cast(dict | None, status.value)) is not None
+ and "powerEnergy" in value
+ ),
+ ),
+ SmartThingsSensorEntityDescription(
+ key="energySaved_meter",
+ translation_key="energy_saved",
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ value_fn=lambda value: value["energySaved"] / 1000,
+ suggested_display_precision=2,
+ exists_fn=lambda status: (
+ (value := cast(dict | None, status.value)) is not None
+ and "energySaved" in value
+ ),
+ ),
+ ]
+ },
+ Capability.POWER_METER: {
+ Attribute.POWER: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.POWER,
+ native_unit_of_measurement=UnitOfPower.WATT,
+ device_class=SensorDeviceClass.POWER,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.POWER_SOURCE: {
+ Attribute.POWER_SOURCE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.POWER_SOURCE,
+ translation_key="power_source",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ ]
+ },
+ # part of the proposed spec
+ Capability.REFRIGERATION_SETPOINT: {
+ Attribute.REFRIGERATION_SETPOINT: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.REFRIGERATION_SETPOINT,
+ translation_key="refrigeration_setpoint",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ )
+ ]
+ },
+ Capability.RELATIVE_BRIGHTNESS: {
+ Attribute.BRIGHTNESS_INTENSITY: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.BRIGHTNESS_INTENSITY,
+ translation_key="brightness_intensity",
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ Capability.RELATIVE_HUMIDITY_MEASUREMENT: {
+ Attribute.HUMIDITY: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.HUMIDITY,
+ native_unit_of_measurement=PERCENTAGE,
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ Capability.ROBOT_CLEANER_CLEANING_MODE: {
+ Attribute.ROBOT_CLEANER_CLEANING_MODE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.ROBOT_CLEANER_CLEANING_MODE,
+ translation_key="robot_cleaner_cleaning_mode",
+ options=["auto", "part", "repeat", "manual", "stop", "map"],
+ device_class=SensorDeviceClass.ENUM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ ],
+ },
+ Capability.ROBOT_CLEANER_MOVEMENT: {
+ Attribute.ROBOT_CLEANER_MOVEMENT: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.ROBOT_CLEANER_MOVEMENT,
+ translation_key="robot_cleaner_movement",
+ options=[
+ "homing",
+ "idle",
+ "charging",
+ "alarm",
+ "off",
+ "reserve",
+ "point",
+ "after",
+ "cleaning",
+ "pause",
+ ],
+ device_class=SensorDeviceClass.ENUM,
+ value_fn=lambda value: ROBOT_CLEANER_MOVEMENT_MAP.get(value, value),
+ )
+ ]
+ },
+ Capability.ROBOT_CLEANER_TURBO_MODE: {
+ Attribute.ROBOT_CLEANER_TURBO_MODE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.ROBOT_CLEANER_TURBO_MODE,
+ translation_key="robot_cleaner_turbo_mode",
+ options=["on", "off", "silence", "extra_silence"],
+ device_class=SensorDeviceClass.ENUM,
+ value_fn=lambda value: ROBOT_CLEANER_TURBO_MODE_STATE_MAP.get(
+ value, value
+ ),
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.SIGNAL_STRENGTH: {
+ Attribute.LQI: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.LQI,
+ translation_key="link_quality",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ ],
+ Attribute.RSSI: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.RSSI,
+ device_class=SensorDeviceClass.SIGNAL_STRENGTH,
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ ],
+ },
+ # Haven't seen at devices yet
+ Capability.SMOKE_DETECTOR: {
+ Attribute.SMOKE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.SMOKE,
+ translation_key="smoke_detector",
+ options=["detected", "clear", "tested"],
+ device_class=SensorDeviceClass.ENUM,
+ )
+ ]
+ },
+ Capability.TEMPERATURE_MEASUREMENT: {
+ Attribute.TEMPERATURE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.TEMPERATURE,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ Capability.THERMOSTAT_COOLING_SETPOINT: {
+ Attribute.COOLING_SETPOINT: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.COOLING_SETPOINT,
+ translation_key="thermostat_cooling_setpoint",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ capability_ignore_list=[
+ {
+ Capability.AIR_CONDITIONER_FAN_MODE,
+ Capability.TEMPERATURE_MEASUREMENT,
+ Capability.AIR_CONDITIONER_MODE,
+ },
+ THERMOSTAT_CAPABILITIES,
+ ],
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.THERMOSTAT_FAN_MODE: {
+ Attribute.THERMOSTAT_FAN_MODE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.THERMOSTAT_FAN_MODE,
+ translation_key="thermostat_fan_mode",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ capability_ignore_list=[THERMOSTAT_CAPABILITIES],
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.THERMOSTAT_HEATING_SETPOINT: {
+ Attribute.HEATING_SETPOINT: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.HEATING_SETPOINT,
+ translation_key="thermostat_heating_setpoint",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ capability_ignore_list=[THERMOSTAT_CAPABILITIES],
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.THERMOSTAT_MODE: {
+ Attribute.THERMOSTAT_MODE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.THERMOSTAT_MODE,
+ translation_key="thermostat_mode",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ capability_ignore_list=[THERMOSTAT_CAPABILITIES],
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.THERMOSTAT_OPERATING_STATE: {
+ Attribute.THERMOSTAT_OPERATING_STATE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.THERMOSTAT_OPERATING_STATE,
+ translation_key="thermostat_operating_state",
+ capability_ignore_list=[THERMOSTAT_CAPABILITIES],
+ )
+ ]
+ },
+ # deprecated capability
+ Capability.THERMOSTAT_SETPOINT: {
+ Attribute.THERMOSTAT_SETPOINT: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.THERMOSTAT_SETPOINT,
+ translation_key="thermostat_setpoint",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ ]
+ },
+ Capability.THREE_AXIS: {
+ Attribute.THREE_AXIS: [
+ SmartThingsSensorEntityDescription(
+ key="x_coordinate",
+ translation_key="x_coordinate",
+ value_fn=lambda value: value[0],
+ ),
+ SmartThingsSensorEntityDescription(
+ key="y_coordinate",
+ translation_key="y_coordinate",
+ value_fn=lambda value: value[1],
+ ),
+ SmartThingsSensorEntityDescription(
+ key="z_coordinate",
+ translation_key="z_coordinate",
+ value_fn=lambda value: value[2],
+ ),
+ ]
+ },
+ Capability.TV_CHANNEL: {
+ Attribute.TV_CHANNEL: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.TV_CHANNEL,
+ translation_key="tv_channel",
+ )
+ ],
+ Attribute.TV_CHANNEL_NAME: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.TV_CHANNEL_NAME,
+ translation_key="tv_channel_name",
+ )
+ ],
+ },
+ # Haven't seen at devices yet
+ Capability.TVOC_MEASUREMENT: {
+ Attribute.TVOC_LEVEL: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.TVOC_LEVEL,
+ device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
+ native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ # Haven't seen at devices yet
+ Capability.ULTRAVIOLET_INDEX: {
+ Attribute.ULTRAVIOLET_INDEX: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.ULTRAVIOLET_INDEX,
+ translation_key="uv_index",
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ Capability.VERY_FINE_DUST_SENSOR: {
+ Attribute.VERY_FINE_DUST_LEVEL: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.VERY_FINE_DUST_LEVEL,
+ native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ device_class=SensorDeviceClass.PM1,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ Capability.VOLTAGE_MEASUREMENT: {
+ Attribute.VOLTAGE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.VOLTAGE,
+ device_class=SensorDeviceClass.VOLTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ )
+ ]
+ },
+ # part of the proposed spec
+ Capability.WASHER_MODE: {
+ Attribute.WASHER_MODE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.WASHER_MODE,
+ translation_key="washer_mode",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ )
+ ]
+ },
+ Capability.WASHER_OPERATING_STATE: {
+ Attribute.MACHINE_STATE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.MACHINE_STATE,
+ translation_key="washer_machine_state",
+ options=WASHER_OPTIONS,
+ device_class=SensorDeviceClass.ENUM,
+ )
+ ],
+ Attribute.WASHER_JOB_STATE: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.WASHER_JOB_STATE,
+ translation_key="washer_job_state",
+ options=[
+ "air_wash",
+ "ai_rinse",
+ "ai_spin",
+ "ai_wash",
+ "cooling",
+ "delay_wash",
+ "drying",
+ "finish",
+ "none",
+ "pre_wash",
+ "rinse",
+ "spin",
+ "wash",
+ "weight_sensing",
+ "wrinkle_prevent",
+ "freeze_protection",
+ ],
+ device_class=SensorDeviceClass.ENUM,
+ value_fn=lambda value: JOB_STATE_MAP.get(value, value),
+ )
+ ],
+ Attribute.COMPLETION_TIME: [
+ SmartThingsSensorEntityDescription(
+ key=Attribute.COMPLETION_TIME,
+ translation_key="completion_time",
+ device_class=SensorDeviceClass.TIMESTAMP,
+ value_fn=dt_util.parse_datetime,
+ )
+ ],
+ },
+}
+
+
UNITS = {
"C": UnitOfTemperature.CELSIUS,
"F": UnitOfTemperature.FAHRENHEIT,
+ "ccf": UnitOfVolume.CENTUM_CUBIC_FEET,
"lux": LIGHT_LUX,
+ "mG": None,
+ "μg/m^3": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
}
-THREE_AXIS_NAMES = ["X Coordinate", "Y Coordinate", "Z Coordinate"]
-POWER_CONSUMPTION_REPORT_NAMES = [
- "energy",
- "power",
- "deltaEnergy",
- "powerEnergy",
- "energySaved",
-]
-
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add sensors for a config entry."""
- broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
- entities: list[SensorEntity] = []
- for device in broker.devices.values():
- for capability in broker.get_assigned(device.device_id, "sensor"):
- if capability == Capability.three_axis:
- entities.extend(
- [
- SmartThingsThreeAxisSensor(device, index)
- for index in range(len(THREE_AXIS_NAMES))
- ]
- )
- elif capability == Capability.power_consumption_report:
- entities.extend(
- [
- SmartThingsPowerConsumptionSensor(device, report_name)
- for report_name in POWER_CONSUMPTION_REPORT_NAMES
- ]
- )
- else:
- maps = CAPABILITY_TO_SENSORS[capability]
- entities.extend(
- [
- SmartThingsSensor(
- device,
- m.attribute,
- m.name,
- m.default_unit,
- m.device_class,
- m.state_class,
- m.entity_category,
- )
- for m in maps
- ]
- )
+ entry_data = entry.runtime_data
+ entities = []
- if broker.any_assigned(device.device_id, "switch"):
- for capability in (Capability.energy_meter, Capability.power_meter):
- maps = CAPABILITY_TO_SENSORS[capability]
- entities.extend(
- [
- SmartThingsSensor(
- device,
- m.attribute,
- m.name,
- m.default_unit,
- m.device_class,
- m.state_class,
- m.entity_category,
- )
- for m in maps
- ]
- )
+ entity_registry = er.async_get(hass)
+
+ for device in entry_data.devices.values(): # pylint: disable=too-many-nested-blocks
+ for capability, attributes in CAPABILITY_TO_SENSORS.items():
+ if capability in device.status[MAIN]:
+ for attribute, descriptions in attributes.items():
+ for description in descriptions:
+ if (
+ not description.capability_ignore_list
+ or not any(
+ all(
+ capability in device.status[MAIN]
+ for capability in capability_list
+ )
+ for capability_list in description.capability_ignore_list
+ )
+ ) and (
+ not description.exists_fn
+ or description.exists_fn(
+ device.status[MAIN][capability][attribute]
+ )
+ ):
+ if (
+ description.deprecated
+ and (
+ reason := description.deprecated(
+ device.status[MAIN]
+ )
+ )
+ is not None
+ ):
+ if deprecate_entity(
+ hass,
+ entity_registry,
+ SENSOR_DOMAIN,
+ f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{description.key}",
+ f"deprecated_{reason}",
+ ):
+ entities.append(
+ SmartThingsSensor(
+ entry_data.client,
+ device,
+ description,
+ capability,
+ attribute,
+ )
+ )
+ continue
+ entities.append(
+ SmartThingsSensor(
+ entry_data.client,
+ device,
+ description,
+ capability,
+ attribute,
+ )
+ )
async_add_entities(entities)
-def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
- """Return all capabilities supported if minimum required are present."""
- return [
- capability for capability in CAPABILITY_TO_SENSORS if capability in capabilities
- ]
-
-
class SmartThingsSensor(SmartThingsEntity, SensorEntity):
"""Define a SmartThings Sensor."""
+ entity_description: SmartThingsSensorEntityDescription
+
def __init__(
self,
- device: DeviceEntity,
- attribute: str,
- name: str,
- default_unit: str | None,
- device_class: SensorDeviceClass | None,
- state_class: str | None,
- entity_category: EntityCategory | None,
+ client: SmartThings,
+ device: FullDevice,
+ entity_description: SmartThingsSensorEntityDescription,
+ capability: Capability,
+ attribute: Attribute,
) -> None:
"""Init the class."""
- super().__init__(device)
+ capabilities_to_subscribe = {capability}
+ if entity_description.use_temperature_unit:
+ capabilities_to_subscribe.add(Capability.TEMPERATURE_MEASUREMENT)
+ super().__init__(client, device, capabilities_to_subscribe)
+ self._attr_unique_id = f"{device.device.device_id}_{MAIN}_{capability}_{attribute}_{entity_description.key}"
self._attribute = attribute
- self._attr_name = f"{device.label} {name}"
- self._attr_unique_id = f"{device.device_id}.{attribute}"
- self._attr_device_class = device_class
- self._default_unit = default_unit
- self._attr_state_class = state_class
- self._attr_entity_category = entity_category
+ self.capability = capability
+ self.entity_description = entity_description
@property
- def native_value(self):
+ def native_value(self) -> str | float | datetime | int | None:
"""Return the state of the sensor."""
- value = self._device.status.attributes[self._attribute].value
-
- if self.device_class != SensorDeviceClass.TIMESTAMP:
- return value
-
- return dt_util.parse_datetime(value)
+ res = self.get_attribute_value(self.capability, self._attribute)
+ return self.entity_description.value_fn(res)
@property
- def native_unit_of_measurement(self):
+ def native_unit_of_measurement(self) -> str | None:
"""Return the unit this state is expressed in."""
- unit = self._device.status.attributes[self._attribute].unit
- return UNITS.get(unit, unit) if unit else self._default_unit
-
-
-class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity):
- """Define a SmartThings Three Axis Sensor."""
-
- def __init__(self, device, index):
- """Init the class."""
- super().__init__(device)
- self._index = index
- self._attr_name = f"{device.label} {THREE_AXIS_NAMES[index]}"
- self._attr_unique_id = f"{device.device_id} {THREE_AXIS_NAMES[index]}"
-
- @property
- def native_value(self):
- """Return the state of the sensor."""
- three_axis = self._device.status.attributes[Attribute.three_axis].value
- try:
- return three_axis[self._index]
- except (TypeError, IndexError):
- return None
-
-
-class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity):
- """Define a SmartThings Sensor."""
-
- def __init__(
- self,
- device: DeviceEntity,
- report_name: str,
- ) -> None:
- """Init the class."""
- super().__init__(device)
- self.report_name = report_name
- self._attr_name = f"{device.label} {report_name}"
- self._attr_unique_id = f"{device.device_id}.{report_name}_meter"
- if self.report_name == "power":
- self._attr_state_class = SensorStateClass.MEASUREMENT
- self._attr_device_class = SensorDeviceClass.POWER
- self._attr_native_unit_of_measurement = UnitOfPower.WATT
+ if self.entity_description.use_temperature_unit:
+ unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][
+ Attribute.TEMPERATURE
+ ].unit
else:
- self._attr_state_class = SensorStateClass.TOTAL_INCREASING
- self._attr_device_class = SensorDeviceClass.ENERGY
- self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR
+ unit = self._internal_state[self.capability][self._attribute].unit
+ return (
+ UNITS.get(unit, unit)
+ if unit
+ else self.entity_description.native_unit_of_measurement
+ )
@property
- def native_value(self):
- """Return the state of the sensor."""
- value = self._device.status.attributes[Attribute.power_consumption].value
- if value is None or value.get(self.report_name) is None:
- return None
- if self.report_name == "power":
- return value[self.report_name]
- return value[self.report_name] / 1000
-
- @property
- def extra_state_attributes(self):
- """Return specific state attributes."""
- if self.report_name == "power":
- attributes = [
- "power_consumption_start",
- "power_consumption_end",
- ]
- state_attributes = {}
- for attribute in attributes:
- value = getattr(self._device.status, attribute)
- if value is not None:
- state_attributes[attribute] = value
- return state_attributes
+ def extra_state_attributes(self) -> Mapping[str, Any] | None:
+ """Return the state attributes."""
+ if self.entity_description.extra_state_attributes_fn:
+ return self.entity_description.extra_state_attributes_fn(
+ self.get_attribute_value(self.capability, self._attribute)
+ )
return None
+
+ @property
+ def options(self) -> list[str] | None:
+ """Return the options for this sensor."""
+ if self.entity_description.options_attribute:
+ if (
+ options := self.get_attribute_value(
+ self.capability, self.entity_description.options_attribute
+ )
+ ) is None:
+ return []
+ return [option.lower() for option in options]
+ return super().options
diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py
deleted file mode 100644
index 76b6804075f..00000000000
--- a/homeassistant/components/smartthings/smartapp.py
+++ /dev/null
@@ -1,545 +0,0 @@
-"""SmartApp functionality to receive cloud-push notifications."""
-
-from __future__ import annotations
-
-import asyncio
-import functools
-import logging
-import secrets
-from typing import Any
-from urllib.parse import urlparse
-from uuid import uuid4
-
-from aiohttp import web
-from pysmartapp import Dispatcher, SmartAppManager
-from pysmartapp.const import SETTINGS_APP_ID
-from pysmartthings import (
- APP_TYPE_WEBHOOK,
- CAPABILITIES,
- CLASSIFICATION_AUTOMATION,
- App,
- AppEntity,
- AppOAuth,
- AppSettings,
- InstalledAppStatus,
- SmartThings,
- SourceType,
- Subscription,
- SubscriptionEntity,
-)
-
-from homeassistant.components import cloud, webhook
-from homeassistant.config_entries import ConfigFlowResult
-from homeassistant.const import CONF_WEBHOOK_ID
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.dispatcher import (
- async_dispatcher_connect,
- async_dispatcher_send,
-)
-from homeassistant.helpers.network import NoURLAvailableError, get_url
-from homeassistant.helpers.storage import Store
-
-from .const import (
- APP_NAME_PREFIX,
- APP_OAUTH_CLIENT_NAME,
- APP_OAUTH_SCOPES,
- CONF_CLOUDHOOK_URL,
- CONF_INSTALLED_APP_ID,
- CONF_INSTANCE_ID,
- CONF_REFRESH_TOKEN,
- DATA_BROKERS,
- DATA_MANAGER,
- DOMAIN,
- IGNORED_CAPABILITIES,
- SETTINGS_INSTANCE_ID,
- SIGNAL_SMARTAPP_PREFIX,
- STORAGE_KEY,
- STORAGE_VERSION,
- SUBSCRIPTION_WARNING_LIMIT,
-)
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def format_unique_id(app_id: str, location_id: str) -> str:
- """Format the unique id for a config entry."""
- return f"{app_id}_{location_id}"
-
-
-async def find_app(hass: HomeAssistant, api: SmartThings) -> AppEntity | None:
- """Find an existing SmartApp for this installation of hass."""
- apps = await api.apps()
- for app in [app for app in apps if app.app_name.startswith(APP_NAME_PREFIX)]:
- # Load settings to compare instance id
- settings = await app.settings()
- if (
- settings.settings.get(SETTINGS_INSTANCE_ID)
- == hass.data[DOMAIN][CONF_INSTANCE_ID]
- ):
- return app
- return None
-
-
-async def validate_installed_app(api, installed_app_id: str):
- """Ensure the specified installed SmartApp is valid and functioning.
-
- Query the API for the installed SmartApp and validate that it is tied to
- the specified app_id and is in an authorized state.
- """
- installed_app = await api.installed_app(installed_app_id)
- if installed_app.installed_app_status != InstalledAppStatus.AUTHORIZED:
- raise RuntimeWarning(
- f"Installed SmartApp instance '{installed_app.display_name}' "
- f"({installed_app.installed_app_id}) is not AUTHORIZED "
- f"but instead {installed_app.installed_app_status}"
- )
- return installed_app
-
-
-def validate_webhook_requirements(hass: HomeAssistant) -> bool:
- """Ensure Home Assistant is setup properly to receive webhooks."""
- if cloud.async_active_subscription(hass):
- return True
- if hass.data[DOMAIN][CONF_CLOUDHOOK_URL] is not None:
- return True
- return get_webhook_url(hass).lower().startswith("https://")
-
-
-def get_webhook_url(hass: HomeAssistant) -> str:
- """Get the URL of the webhook.
-
- Return the cloudhook if available, otherwise local webhook.
- """
- cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
- if cloud.async_active_subscription(hass) and cloudhook_url is not None:
- return cloudhook_url
- return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
-
-
-def _get_app_template(hass: HomeAssistant):
- try:
- endpoint = f"at {get_url(hass, allow_cloud=False, prefer_external=True)}"
- except NoURLAvailableError:
- endpoint = ""
-
- cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
- if cloudhook_url is not None:
- endpoint = "via Nabu Casa"
- description = f"{hass.config.location_name} {endpoint}"
-
- return {
- "app_name": APP_NAME_PREFIX + str(uuid4()),
- "display_name": "Home Assistant",
- "description": description,
- "webhook_target_url": get_webhook_url(hass),
- "app_type": APP_TYPE_WEBHOOK,
- "single_instance": True,
- "classifications": [CLASSIFICATION_AUTOMATION],
- }
-
-
-async def create_app(hass: HomeAssistant, api):
- """Create a SmartApp for this instance of hass."""
- # Create app from template attributes
- template = _get_app_template(hass)
- app = App()
- for key, value in template.items():
- setattr(app, key, value)
- app, client = await api.create_app(app)
- _LOGGER.debug("Created SmartApp '%s' (%s)", app.app_name, app.app_id)
-
- # Set unique hass id in settings
- settings = AppSettings(app.app_id)
- settings.settings[SETTINGS_APP_ID] = app.app_id
- settings.settings[SETTINGS_INSTANCE_ID] = hass.data[DOMAIN][CONF_INSTANCE_ID]
- await api.update_app_settings(settings)
- _LOGGER.debug(
- "Updated App Settings for SmartApp '%s' (%s)", app.app_name, app.app_id
- )
-
- # Set oauth scopes
- oauth = AppOAuth(app.app_id)
- oauth.client_name = APP_OAUTH_CLIENT_NAME
- oauth.scope.extend(APP_OAUTH_SCOPES)
- await api.update_app_oauth(oauth)
- _LOGGER.debug("Updated App OAuth for SmartApp '%s' (%s)", app.app_name, app.app_id)
- return app, client
-
-
-async def update_app(hass: HomeAssistant, app):
- """Ensure the SmartApp is up-to-date and update if necessary."""
- template = _get_app_template(hass)
- template.pop("app_name") # don't update this
- update_required = False
- for key, value in template.items():
- if getattr(app, key) != value:
- update_required = True
- setattr(app, key, value)
- if update_required:
- await app.save()
- _LOGGER.debug(
- "SmartApp '%s' (%s) updated with latest settings", app.app_name, app.app_id
- )
-
-
-def setup_smartapp(hass, app):
- """Configure an individual SmartApp in hass.
-
- Register the SmartApp with the SmartAppManager so that hass will service
- lifecycle events (install, event, etc...). A unique SmartApp is created
- for each SmartThings account that is configured in hass.
- """
- manager = hass.data[DOMAIN][DATA_MANAGER]
- if smartapp := manager.smartapps.get(app.app_id):
- # already setup
- return smartapp
- smartapp = manager.register(app.app_id, app.webhook_public_key)
- smartapp.name = app.display_name
- smartapp.description = app.description
- smartapp.permissions.extend(APP_OAUTH_SCOPES)
- return smartapp
-
-
-async def setup_smartapp_endpoint(hass: HomeAssistant, fresh_install: bool):
- """Configure the SmartApp webhook in hass.
-
- SmartApps are an extension point within the SmartThings ecosystem and
- is used to receive push updates (i.e. device updates) from the cloud.
- """
- if hass.data.get(DOMAIN):
- # already setup
- if not fresh_install:
- return
-
- # We're doing a fresh install, clean up
- await unload_smartapp_endpoint(hass)
-
- # Get/create config to store a unique id for this hass instance.
- store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
-
- if fresh_install or not (config := await store.async_load()):
- # Create config
- config = {
- CONF_INSTANCE_ID: str(uuid4()),
- CONF_WEBHOOK_ID: secrets.token_hex(),
- CONF_CLOUDHOOK_URL: None,
- }
- await store.async_save(config)
-
- # Register webhook
- webhook.async_register(
- hass, DOMAIN, "SmartApp", config[CONF_WEBHOOK_ID], smartapp_webhook
- )
-
- # Create webhook if eligible
- cloudhook_url = config.get(CONF_CLOUDHOOK_URL)
- if (
- cloudhook_url is None
- and cloud.async_active_subscription(hass)
- and not hass.config_entries.async_entries(DOMAIN)
- ):
- cloudhook_url = await cloud.async_create_cloudhook(
- hass, config[CONF_WEBHOOK_ID]
- )
- config[CONF_CLOUDHOOK_URL] = cloudhook_url
- await store.async_save(config)
- _LOGGER.debug("Created cloudhook '%s'", cloudhook_url)
-
- # SmartAppManager uses a dispatcher to invoke callbacks when push events
- # occur. Use hass' implementation instead of the built-in one.
- dispatcher = Dispatcher(
- signal_prefix=SIGNAL_SMARTAPP_PREFIX,
- connect=functools.partial(async_dispatcher_connect, hass),
- send=functools.partial(async_dispatcher_send, hass),
- )
- # Path is used in digital signature validation
- path = (
- urlparse(cloudhook_url).path
- if cloudhook_url
- else webhook.async_generate_path(config[CONF_WEBHOOK_ID])
- )
- manager = SmartAppManager(path, dispatcher=dispatcher)
- manager.connect_install(functools.partial(smartapp_install, hass))
- manager.connect_update(functools.partial(smartapp_update, hass))
- manager.connect_uninstall(functools.partial(smartapp_uninstall, hass))
-
- hass.data[DOMAIN] = {
- DATA_MANAGER: manager,
- CONF_INSTANCE_ID: config[CONF_INSTANCE_ID],
- DATA_BROKERS: {},
- CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID],
- # Will not be present if not enabled
- CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL),
- }
- _LOGGER.debug(
- "Setup endpoint for %s",
- cloudhook_url
- if cloudhook_url
- else webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID]),
- )
-
-
-async def unload_smartapp_endpoint(hass: HomeAssistant):
- """Tear down the component configuration."""
- if DOMAIN not in hass.data:
- return
- # Remove the cloudhook if it was created
- cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL]
- if cloudhook_url and cloud.async_is_logged_in(hass):
- await cloud.async_delete_cloudhook(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
- # Remove cloudhook from storage
- store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
- await store.async_save(
- {
- CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID],
- CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID],
- CONF_CLOUDHOOK_URL: None,
- }
- )
- _LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url)
- # Remove the webhook
- webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID])
- # Disconnect all brokers
- for broker in hass.data[DOMAIN][DATA_BROKERS].values():
- broker.disconnect()
- # Remove all handlers from manager
- hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all()
- # Remove the component data
- hass.data.pop(DOMAIN)
-
-
-async def smartapp_sync_subscriptions(
- hass: HomeAssistant,
- auth_token: str,
- location_id: str,
- installed_app_id: str,
- devices,
-):
- """Synchronize subscriptions of an installed up."""
- api = SmartThings(async_get_clientsession(hass), auth_token)
- tasks = []
-
- async def create_subscription(target: str):
- sub = Subscription()
- sub.installed_app_id = installed_app_id
- sub.location_id = location_id
- sub.source_type = SourceType.CAPABILITY
- sub.capability = target
- try:
- await api.create_subscription(sub)
- _LOGGER.debug(
- "Created subscription for '%s' under app '%s'", target, installed_app_id
- )
- except Exception as error: # noqa: BLE001
- _LOGGER.error(
- "Failed to create subscription for '%s' under app '%s': %s",
- target,
- installed_app_id,
- error,
- )
-
- async def delete_subscription(sub: SubscriptionEntity):
- try:
- await api.delete_subscription(installed_app_id, sub.subscription_id)
- _LOGGER.debug(
- (
- "Removed subscription for '%s' under app '%s' because it was no"
- " longer needed"
- ),
- sub.capability,
- installed_app_id,
- )
- except Exception as error: # noqa: BLE001
- _LOGGER.error(
- "Failed to remove subscription for '%s' under app '%s': %s",
- sub.capability,
- installed_app_id,
- error,
- )
-
- # Build set of capabilities and prune unsupported ones
- capabilities = set()
- for device in devices:
- capabilities.update(device.capabilities)
- # Remove items not defined in the library
- capabilities.intersection_update(CAPABILITIES)
- # Remove unused capabilities
- capabilities.difference_update(IGNORED_CAPABILITIES)
- capability_count = len(capabilities)
- if capability_count > SUBSCRIPTION_WARNING_LIMIT:
- _LOGGER.warning(
- (
- "Some device attributes may not receive push updates and there may be"
- " subscription creation failures under app '%s' because %s"
- " subscriptions are required but there is a limit of %s per app"
- ),
- installed_app_id,
- capability_count,
- SUBSCRIPTION_WARNING_LIMIT,
- )
- _LOGGER.debug(
- "Synchronizing subscriptions for %s capabilities under app '%s': %s",
- capability_count,
- installed_app_id,
- capabilities,
- )
-
- # Get current subscriptions and find differences
- subscriptions = await api.subscriptions(installed_app_id)
- for subscription in subscriptions:
- if subscription.capability in capabilities:
- capabilities.remove(subscription.capability)
- else:
- # Delete the subscription
- tasks.append(delete_subscription(subscription))
-
- # Remaining capabilities need subscriptions created
- tasks.extend([create_subscription(c) for c in capabilities])
-
- if tasks:
- await asyncio.gather(*tasks)
- else:
- _LOGGER.debug("Subscriptions for app '%s' are up-to-date", installed_app_id)
-
-
-async def _find_and_continue_flow(
- hass: HomeAssistant,
- app_id: str,
- location_id: str,
- installed_app_id: str,
- refresh_token: str,
-):
- """Continue a config flow if one is in progress for the specific installed app."""
- unique_id = format_unique_id(app_id, location_id)
- flow = next(
- (
- flow
- for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
- if flow["context"].get("unique_id") == unique_id
- ),
- None,
- )
- if flow is not None:
- await _continue_flow(hass, app_id, installed_app_id, refresh_token, flow)
-
-
-async def _continue_flow(
- hass: HomeAssistant,
- app_id: str,
- installed_app_id: str,
- refresh_token: str,
- flow: ConfigFlowResult,
-) -> None:
- await hass.config_entries.flow.async_configure(
- flow["flow_id"],
- {
- CONF_INSTALLED_APP_ID: installed_app_id,
- CONF_REFRESH_TOKEN: refresh_token,
- },
- )
- _LOGGER.debug(
- "Continued config flow '%s' for SmartApp '%s' under parent app '%s'",
- flow["flow_id"],
- installed_app_id,
- app_id,
- )
-
-
-async def smartapp_install(hass: HomeAssistant, req, resp, app):
- """Handle a SmartApp installation and continue the config flow."""
- await _find_and_continue_flow(
- hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
- )
- _LOGGER.debug(
- "Installed SmartApp '%s' under parent app '%s'",
- req.installed_app_id,
- app.app_id,
- )
-
-
-async def smartapp_update(hass: HomeAssistant, req, resp, app):
- """Handle a SmartApp update and either update the entry or continue the flow."""
- unique_id = format_unique_id(app.app_id, req.location_id)
- flow = next(
- (
- flow
- for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
- if flow["context"].get("unique_id") == unique_id
- and flow["step_id"] == "authorize"
- ),
- None,
- )
- if flow is not None:
- await _continue_flow(
- hass, app.app_id, req.installed_app_id, req.refresh_token, flow
- )
- _LOGGER.debug(
- "Continued reauth flow '%s' for SmartApp '%s' under parent app '%s'",
- flow["flow_id"],
- req.installed_app_id,
- app.app_id,
- )
- return
- entry = next(
- (
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id
- ),
- None,
- )
- if entry:
- hass.config_entries.async_update_entry(
- entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token}
- )
- _LOGGER.debug(
- "Updated config entry '%s' for SmartApp '%s' under parent app '%s'",
- entry.entry_id,
- req.installed_app_id,
- app.app_id,
- )
-
- await _find_and_continue_flow(
- hass, app.app_id, req.location_id, req.installed_app_id, req.refresh_token
- )
- _LOGGER.debug(
- "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id
- )
-
-
-async def smartapp_uninstall(hass: HomeAssistant, req, resp, app):
- """Handle when a SmartApp is removed from a location by the user.
-
- Find and delete the config entry representing the integration.
- """
- entry = next(
- (
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.data.get(CONF_INSTALLED_APP_ID) == req.installed_app_id
- ),
- None,
- )
- if entry:
- # Add as job not needed because the current coroutine was invoked
- # from the dispatcher and is not being awaited.
- await hass.config_entries.async_remove(entry.entry_id)
-
- _LOGGER.debug(
- "Uninstalled SmartApp '%s' under parent app '%s'",
- req.installed_app_id,
- app.app_id,
- )
-
-
-async def smartapp_webhook(hass: HomeAssistant, webhook_id: str, request):
- """Handle a smartapp lifecycle event callback from SmartThings.
-
- Requests from SmartThings are digitally signed and the SmartAppManager
- validates the signature for authenticity.
- """
- manager = hass.data[DOMAIN][DATA_MANAGER]
- data = await request.json()
- result = await manager.handle_request(data, request.headers)
- return web.json_response(result)
diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json
index 31a552be149..384264b0595 100644
--- a/homeassistant/components/smartthings/strings.json
+++ b/homeassistant/components/smartthings/strings.json
@@ -1,43 +1,526 @@
{
"config": {
"step": {
- "user": {
- "title": "Confirm Callback URL",
- "description": "SmartThings will be configured to send push updates to Home Assistant at:\n> {webhook_url}\n\nIf this is not correct, please update your configuration, restart Home Assistant, and try again."
+ "pick_implementation": {
+ "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
},
- "pat": {
- "title": "Enter Personal Access Token",
- "description": "Please enter a SmartThings [Personal Access Token]({token_url}) that has been created per the [instructions]({component_url}). This will be used to create the Home Assistant integration within your SmartThings account.\n\n**Please note that all Personal Access Tokens created after 30 December 2024 are only valid for 24 hours, after which the integration will stop working. We are working on a fix.**",
- "data": {
- "access_token": "[%key:common::config_flow::data::access_token%]"
- }
- },
- "select_location": {
- "title": "Select Location",
- "description": "Please select the SmartThings Location you wish to add to Home Assistant. We will then open a new window and ask you to login and authorize installation of the Home Assistant integration into the selected location.",
- "data": { "location_id": "[%key:common::config_flow::data::location%]" }
- },
- "authorize": { "title": "Authorize Home Assistant" },
"reauth_confirm": {
- "title": "Reauthorize Home Assistant",
- "description": "You are about to reauthorize Home Assistant with SmartThings. This will require you to log in and authorize the integration again."
- },
- "update_confirm": {
- "title": "Finish reauthentication",
- "description": "You have almost successfully reauthorized Home Assistant with SmartThings. Please press the button down below to finish the process."
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The SmartThings integration needs to re-authenticate your account"
}
},
- "abort": {
- "invalid_webhook_url": "Home Assistant is not configured correctly to receive updates from SmartThings. The webhook URL is invalid:\n> {webhook_url}\n\nPlease update your configuration per the [instructions]({component_url}), restart Home Assistant, and try again.",
- "no_available_locations": "There are no available SmartThings Locations to set up in Home Assistant.",
- "reauth_successful": "Home Assistant has been successfully reauthorized with SmartThings."
- },
"error": {
- "token_invalid_format": "The token must be in the UID/GUID format",
- "token_unauthorized": "The token is invalid or no longer authorized.",
- "token_forbidden": "The token does not have the required OAuth scopes.",
- "app_setup_error": "Unable to set up the SmartApp. Please try again.",
- "webhook_error": "SmartThings could not validate the webhook URL. Please ensure the webhook URL is reachable from the internet and try again."
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
+ },
+ "abort": {
+ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
+ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
+ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
+ "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
+ "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
+ "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reauth_account_mismatch": "Authenticated account does not match the account to be reauthenticated. Please log in with the correct account and pick the right location.",
+ "reauth_location_mismatch": "Authenticated location does not match the location to be reauthenticated. Please log in with the correct account and pick the right location.",
+ "missing_scopes": "Authentication failed. Please make sure you have granted all required permissions.",
+ "cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml."
+ }
+ },
+ "entity": {
+ "binary_sensor": {
+ "acceleration": {
+ "name": "Acceleration"
+ },
+ "door": {
+ "name": "[%key:component::binary_sensor::entity_component::door::name%]"
+ },
+ "dryer_wrinkle_prevent_active": {
+ "name": "Wrinkle prevent active"
+ },
+ "filter_status": {
+ "name": "Filter status"
+ },
+ "freezer_door": {
+ "name": "Freezer door"
+ },
+ "cooler_door": {
+ "name": "Cooler door"
+ },
+ "cool_select_plus_door": {
+ "name": "CoolSelect+ door"
+ },
+ "remote_control": {
+ "name": "Remote control"
+ },
+ "child_lock": {
+ "name": "Child lock"
+ },
+ "valve": {
+ "name": "Valve"
+ }
+ },
+ "button": {
+ "reset_water_filter": {
+ "name": "Reset water filter"
+ },
+ "stop": {
+ "name": "[%key:common::action::stop%]"
+ }
+ },
+ "event": {
+ "button": {
+ "state": {
+ "pushed": "Pushed",
+ "held": "Held",
+ "double": "Double",
+ "pushed_2x": "Pushed 2x",
+ "pushed_3x": "Pushed 3x",
+ "pushed_4x": "Pushed 4x",
+ "pushed_5x": "Pushed 5x",
+ "pushed_6x": "Pushed 6x",
+ "down": "Down",
+ "down_2x": "Down 2x",
+ "down_3x": "Down 3x",
+ "down_4x": "Down 4x",
+ "down_5x": "Down 5x",
+ "down_6x": "Down 6x",
+ "down_hold": "Down hold",
+ "up": "Up",
+ "up_2x": "Up 2x",
+ "up_3x": "Up 3x",
+ "up_4x": "Up 4x",
+ "up_5x": "Up 5x",
+ "up_6x": "Up 6x",
+ "up_hold": "Up hold",
+ "swipe_up": "Swipe up",
+ "swipe_down": "Swipe down",
+ "swipe_left": "Swipe left",
+ "swipe_right": "Swipe right"
+ }
+ }
+ },
+ "number": {
+ "washer_rinse_cycles": {
+ "name": "Rinse cycles",
+ "unit_of_measurement": "cycles"
+ }
+ },
+ "select": {
+ "operating_state": {
+ "state": {
+ "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]",
+ "pause": "[%key:common::state::paused%]",
+ "stop": "[%key:common::state::stopped%]"
+ }
+ }
+ },
+ "sensor": {
+ "lighting_mode": {
+ "name": "Activity lighting mode"
+ },
+ "air_conditioner_mode": {
+ "name": "Air conditioner mode"
+ },
+ "air_quality": {
+ "name": "Air quality"
+ },
+ "alarm": {
+ "name": "Alarm",
+ "state": {
+ "both": "Strobe and siren",
+ "strobe": "Strobe",
+ "siren": "Siren",
+ "off": "[%key:common::state::off%]"
+ }
+ },
+ "audio_volume": {
+ "name": "Volume"
+ },
+ "body_mass_index": {
+ "name": "Body mass index"
+ },
+ "body_weight": {
+ "name": "Body weight"
+ },
+ "carbon_monoxide_detector": {
+ "name": "Carbon monoxide detector",
+ "state": {
+ "detected": "Detected",
+ "clear": "Clear",
+ "tested": "Tested"
+ }
+ },
+ "dishwasher_machine_state": {
+ "name": "Machine state",
+ "state": {
+ "pause": "[%key:common::state::paused%]",
+ "run": "Running",
+ "stop": "[%key:common::state::stopped%]"
+ }
+ },
+ "dishwasher_job_state": {
+ "name": "Job state",
+ "state": {
+ "air_wash": "Air wash",
+ "cooling": "Cooling",
+ "drying": "Drying",
+ "finish": "Finish",
+ "pre_drain": "Pre-drain",
+ "pre_wash": "Pre-wash",
+ "rinse": "Rinse",
+ "spin": "Spin",
+ "wash": "Wash",
+ "wrinkle_prevent": "Wrinkle prevention"
+ }
+ },
+ "completion_time": {
+ "name": "Completion time"
+ },
+ "dryer_mode": {
+ "name": "Dryer mode"
+ },
+ "dryer_machine_state": {
+ "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]",
+ "state": {
+ "pause": "[%key:common::state::paused%]",
+ "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]",
+ "stop": "[%key:common::state::stopped%]"
+ }
+ },
+ "dryer_job_state": {
+ "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]",
+ "state": {
+ "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]",
+ "delay_wash": "[%key:component::smartthings::entity::sensor::washer_job_state::state::delay_wash%]",
+ "drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]",
+ "finished": "[%key:component::smartthings::entity::sensor::oven_job_state::state::finished%]",
+ "none": "[%key:component::smartthings::entity::sensor::washer_job_state::state::none%]",
+ "refreshing": "Refreshing",
+ "weight_sensing": "[%key:component::smartthings::entity::sensor::washer_job_state::state::weight_sensing%]",
+ "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]",
+ "dehumidifying": "Dehumidifying",
+ "ai_drying": "AI drying",
+ "sanitizing": "Sanitizing",
+ "internal_care": "Internal care",
+ "freeze_protection": "Freeze protection",
+ "continuous_dehumidifying": "Continuous dehumidifying",
+ "thawing_frozen_inside": "Thawing frozen inside"
+ }
+ },
+ "equivalent_carbon_dioxide": {
+ "name": "Equivalent carbon dioxide"
+ },
+ "formaldehyde": {
+ "name": "Formaldehyde"
+ },
+ "gas_meter": {
+ "name": "Gas meter"
+ },
+ "gas_meter_calorific": {
+ "name": "Gas meter calorific"
+ },
+ "gas_meter_time": {
+ "name": "Gas meter time"
+ },
+ "infrared_level": {
+ "name": "Infrared level"
+ },
+ "media_input_source": {
+ "name": "Media input source",
+ "state": {
+ "am": "AM",
+ "fm": "FM",
+ "cd": "CD",
+ "hdmi": "HDMI",
+ "hdmi1": "HDMI 1",
+ "hdmi2": "HDMI 2",
+ "hdmi3": "HDMI 3",
+ "hdmi4": "HDMI 4",
+ "hdmi5": "HDMI 5",
+ "hdmi6": "HDMI 6",
+ "digitaltv": "Digital TV",
+ "usb": "USB",
+ "youtube": "YouTube",
+ "aux": "AUX",
+ "bluetooth": "Bluetooth",
+ "digital": "Digital",
+ "melon": "Melon",
+ "wifi": "Wi-Fi",
+ "network": "Network",
+ "optical": "Optical",
+ "coaxial": "Coaxial",
+ "analog1": "Analog 1",
+ "analog2": "Analog 2",
+ "analog3": "Analog 3",
+ "phono": "Phono"
+ }
+ },
+ "media_playback_repeat": {
+ "name": "Media playback repeat"
+ },
+ "media_playback_shuffle": {
+ "name": "Media playback shuffle"
+ },
+ "media_playback_status": {
+ "name": "Media playback status"
+ },
+ "odor_sensor": {
+ "name": "Odor sensor"
+ },
+ "oven_mode": {
+ "name": "Oven mode",
+ "state": {
+ "heating": "Heating",
+ "grill": "Grill",
+ "warming": "Warming",
+ "defrosting": "Defrosting",
+ "conventional": "Conventional",
+ "bake": "Bake",
+ "bottom_heat": "Bottom heat",
+ "convection_bake": "Convection bake",
+ "convection_roast": "Convection roast",
+ "broil": "Broil",
+ "convection_broil": "Convection broil",
+ "steam_cook": "Steam cook",
+ "steam_bake": "Steam bake",
+ "steam_roast": "Steam roast",
+ "steam_bottom_heat_plus_convection": "Steam bottom heat plus convection",
+ "microwave": "Microwave",
+ "microwave_plus_grill": "Microwave plus grill",
+ "microwave_plus_convection": "Microwave plus convection",
+ "microwave_plus_hot_blast": "Microwave plus hot blast",
+ "microwave_plus_hot_blast_2": "Microwave plus hot blast 2",
+ "slim_middle": "Slim middle",
+ "slim_strong": "Slim strong",
+ "slow_cook": "Slow cook",
+ "proof": "Proof",
+ "dehydrate": "Dehydrate",
+ "others": "Others",
+ "strong_steam": "Strong steam",
+ "descale": "Descale",
+ "rinse": "Rinse"
+ }
+ },
+ "oven_machine_state": {
+ "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]",
+ "state": {
+ "ready": "Ready",
+ "running": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]",
+ "paused": "[%key:common::state::paused%]"
+ }
+ },
+ "oven_job_state": {
+ "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]",
+ "state": {
+ "cleaning": "Cleaning",
+ "cooking": "Cooking",
+ "cooling": "Cooling",
+ "draining": "Draining",
+ "preheat": "Preheat",
+ "ready": "Ready",
+ "rinsing": "Rinsing",
+ "finished": "Finished",
+ "scheduled_start": "Scheduled start",
+ "warming": "Warming",
+ "defrosting": "Defrosting",
+ "sensing": "Sensing",
+ "searing": "Searing",
+ "fast_preheat": "Fast preheat",
+ "scheduled_end": "Scheduled end",
+ "stone_heating": "Stone heating",
+ "time_hold_preheat": "Time hold preheat"
+ }
+ },
+ "oven_setpoint": {
+ "name": "Set point"
+ },
+ "energy_difference": {
+ "name": "Energy difference"
+ },
+ "power_energy": {
+ "name": "Power energy"
+ },
+ "energy_saved": {
+ "name": "Energy saved"
+ },
+ "power_source": {
+ "name": "Power source"
+ },
+ "refrigeration_setpoint": {
+ "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]"
+ },
+ "brightness_intensity": {
+ "name": "Brightness intensity"
+ },
+ "robot_cleaner_cleaning_mode": {
+ "name": "Cleaning mode",
+ "state": {
+ "stop": "[%key:common::action::stop%]",
+ "auto": "[%key:common::state::auto%]",
+ "manual": "[%key:common::state::manual%]",
+ "part": "Partial",
+ "repeat": "Repeat",
+ "map": "Map"
+ }
+ },
+ "robot_cleaner_movement": {
+ "name": "Movement",
+ "state": {
+ "homing": "Homing",
+ "idle": "[%key:common::state::idle%]",
+ "charging": "[%key:common::state::charging%]",
+ "alarm": "Alarm",
+ "off": "[%key:common::state::off%]",
+ "reserve": "Reserve",
+ "point": "Point",
+ "after": "After",
+ "cleaning": "Cleaning",
+ "pause": "[%key:common::state::paused%]"
+ }
+ },
+ "robot_cleaner_turbo_mode": {
+ "name": "Turbo mode",
+ "state": {
+ "on": "[%key:common::state::on%]",
+ "off": "[%key:common::state::off%]",
+ "silence": "Silent",
+ "extra_silence": "Extra silent"
+ }
+ },
+ "link_quality": {
+ "name": "Link quality"
+ },
+ "smoke_detector": {
+ "name": "Smoke detector",
+ "state": {
+ "detected": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::detected%]",
+ "clear": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::clear%]",
+ "tested": "[%key:component::smartthings::entity::sensor::carbon_monoxide_detector::state::tested%]"
+ }
+ },
+ "thermostat_cooling_setpoint": {
+ "name": "Cooling set point"
+ },
+ "thermostat_fan_mode": {
+ "name": "Fan mode"
+ },
+ "thermostat_heating_setpoint": {
+ "name": "Heating set point"
+ },
+ "thermostat_mode": {
+ "name": "Mode"
+ },
+ "thermostat_operating_state": {
+ "name": "Operating state"
+ },
+ "thermostat_setpoint": {
+ "name": "[%key:component::smartthings::entity::sensor::oven_setpoint::name%]"
+ },
+ "x_coordinate": {
+ "name": "X coordinate"
+ },
+ "y_coordinate": {
+ "name": "Y coordinate"
+ },
+ "z_coordinate": {
+ "name": "Z coordinate"
+ },
+ "tv_channel": {
+ "name": "TV channel"
+ },
+ "tv_channel_name": {
+ "name": "TV channel name"
+ },
+ "uv_index": {
+ "name": "UV index"
+ },
+ "washer_mode": {
+ "name": "Washer mode"
+ },
+ "washer_machine_state": {
+ "name": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::name%]",
+ "state": {
+ "pause": "[%key:common::state::paused%]",
+ "run": "[%key:component::smartthings::entity::sensor::dishwasher_machine_state::state::run%]",
+ "stop": "[%key:common::state::stopped%]"
+ }
+ },
+ "washer_job_state": {
+ "name": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::name%]",
+ "state": {
+ "air_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::air_wash%]",
+ "ai_rinse": "AI rinse",
+ "ai_spin": "AI spin",
+ "ai_wash": "AI wash",
+ "cooling": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::cooling%]",
+ "delay_wash": "Delay wash",
+ "drying": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::drying%]",
+ "finish": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::finish%]",
+ "none": "None",
+ "pre_wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::pre_wash%]",
+ "rinse": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::rinse%]",
+ "spin": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::spin%]",
+ "wash": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wash%]",
+ "weight_sensing": "Weight sensing",
+ "wrinkle_prevent": "[%key:component::smartthings::entity::sensor::dishwasher_job_state::state::wrinkle_prevent%]",
+ "freeze_protection": "Freeze protection"
+ }
+ }
+ },
+ "switch": {
+ "bubble_soak": {
+ "name": "Bubble Soak"
+ },
+ "wrinkle_prevent": {
+ "name": "Wrinkle prevent"
+ },
+ "ice_maker": {
+ "name": "Ice maker"
+ }
+ }
+ },
+ "issues": {
+ "deprecated_binary_valve": {
+ "title": "Valve binary sensor deprecated",
+ "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. A valve entity with controls is available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue."
+ },
+ "deprecated_binary_valve_scripts": {
+ "title": "[%key:component::smartthings::issues::deprecated_binary_valve::title%]",
+ "description": "The valve binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nA valve entity with controls is available and should be used going forward. Please use the new valve entity in the above automations or scripts and disable the entity to fix this issue."
+ },
+ "deprecated_binary_fridge_door": {
+ "title": "Refrigerator door binary sensor deprecated",
+ "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. Separate entities for cooler and freezer door are available and should be used going forward. Please update your dashboards, templates accordingly and disable the entity to fix this issue."
+ },
+ "deprecated_binary_fridge_door_scripts": {
+ "title": "[%key:component::smartthings::issues::deprecated_binary_fridge_door::title%]",
+ "description": "The refrigerator door binary sensor {entity_name} (`{entity_id}`) is deprecated and will be removed in the future. The entity is used in the following automations or scripts:\n{items}\n\nSeparate entities for cooler and freezer door are available and should be used going forward. Please use them in the above automations or scripts and disable the entity to fix this issue."
+ },
+ "deprecated_switch_appliance": {
+ "title": "Appliance switch deprecated",
+ "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nPlease update your dashboards, templates accordingly and disable the entity to fix this issue."
+ },
+ "deprecated_switch_appliance_scripts": {
+ "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]",
+ "description": "The switch `{entity_id}` is deprecated because the actions did not work, so it has been replaced with a binary sensor instead.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new binary sensor in the above automations or scripts and disable the entity to fix this issue."
+ },
+ "deprecated_switch_media_player": {
+ "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]",
+ "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue."
+ },
+ "deprecated_switch_media_player_scripts": {
+ "title": "[%key:component::smartthings::issues::deprecated_switch_appliance::title%]",
+ "description": "The switch `{entity_id}` is deprecated and a media player entity has been added to replace it.\n\nThe switch was used in the following automations or scripts:\n{items}\n\nPlease use the new media player entity in the above automations or scripts and disable the entity to fix this issue."
+ },
+ "deprecated_media_player": {
+ "title": "Media player sensors deprecated",
+ "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nPlease update your dashboards, templates to use the new media player entity and disable the entity to fix this issue."
+ },
+ "deprecated_media_player_scripts": {
+ "title": "Deprecated sensor detected in some automations or scripts",
+ "description": "The sensor {entity_name} (`{entity_id}`) is deprecated because it has been replaced with a media player entity.\n\nThe sensor was used in the following automations or scripts:\n{items}\n\nPlease update the above automations or scripts to use the new media player entity and disable the entity to fix this issue."
}
}
}
diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py
index 5cfe4576d6a..ff53082ac7c 100644
--- a/homeassistant/components/smartthings/switch.py
+++ b/homeassistant/components/smartthings/switch.py
@@ -2,60 +2,244 @@
from __future__ import annotations
-from collections.abc import Sequence
+from dataclasses import dataclass
from typing import Any
-from pysmartthings import Capability
+from pysmartthings import Attribute, Capability, Command, SmartThings
-from homeassistant.components.switch import SwitchEntity
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.components.switch import (
+ DOMAIN as SWITCH_DOMAIN,
+ SwitchEntity,
+ SwitchEntityDescription,
+)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DATA_BROKERS, DOMAIN
+from . import FullDevice, SmartThingsConfigEntry
+from .const import INVALID_SWITCH_CATEGORIES, MAIN
from .entity import SmartThingsEntity
+from .util import deprecate_entity
+
+CAPABILITIES = (
+ Capability.SWITCH_LEVEL,
+ Capability.COLOR_CONTROL,
+ Capability.COLOR_TEMPERATURE,
+ Capability.FAN_SPEED,
+)
+
+AC_CAPABILITIES = (
+ Capability.AIR_CONDITIONER_MODE,
+ Capability.AIR_CONDITIONER_FAN_MODE,
+ Capability.TEMPERATURE_MEASUREMENT,
+ Capability.THERMOSTAT_COOLING_SETPOINT,
+)
+
+MEDIA_PLAYER_CAPABILITIES = (
+ Capability.AUDIO_MUTE,
+ Capability.AUDIO_VOLUME,
+)
+
+
+@dataclass(frozen=True, kw_only=True)
+class SmartThingsSwitchEntityDescription(SwitchEntityDescription):
+ """Describe a SmartThings switch entity."""
+
+ status_attribute: Attribute
+ component_translation_key: dict[str, str] | None = None
+
+
+@dataclass(frozen=True, kw_only=True)
+class SmartThingsCommandSwitchEntityDescription(SmartThingsSwitchEntityDescription):
+ """Describe a SmartThings switch entity."""
+
+ command: Command
+
+
+SWITCH = SmartThingsSwitchEntityDescription(
+ key=Capability.SWITCH,
+ status_attribute=Attribute.SWITCH,
+ name=None,
+)
+CAPABILITY_TO_COMMAND_SWITCHES: dict[
+ Capability | str, SmartThingsCommandSwitchEntityDescription
+] = {
+ Capability.CUSTOM_DRYER_WRINKLE_PREVENT: SmartThingsCommandSwitchEntityDescription(
+ key=Capability.CUSTOM_DRYER_WRINKLE_PREVENT,
+ translation_key="wrinkle_prevent",
+ status_attribute=Attribute.DRYER_WRINKLE_PREVENT,
+ command=Command.SET_DRYER_WRINKLE_PREVENT,
+ )
+}
+CAPABILITY_TO_SWITCHES: dict[Capability | str, SmartThingsSwitchEntityDescription] = {
+ Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK: SmartThingsSwitchEntityDescription(
+ key=Capability.SAMSUNG_CE_WASHER_BUBBLE_SOAK,
+ translation_key="bubble_soak",
+ status_attribute=Attribute.STATUS,
+ ),
+ Capability.SWITCH: SmartThingsSwitchEntityDescription(
+ key=Capability.SWITCH,
+ status_attribute=Attribute.SWITCH,
+ component_translation_key={
+ "icemaker": "ice_maker",
+ },
+ ),
+}
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add switches for a config entry."""
- broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id]
- async_add_entities(
- SmartThingsSwitch(device)
- for device in broker.devices.values()
- if broker.any_assigned(device.device_id, "switch")
+ entry_data = entry.runtime_data
+ entities: list[SmartThingsEntity] = [
+ SmartThingsCommandSwitch(
+ entry_data.client,
+ device,
+ description,
+ Capability(capability),
+ )
+ for device in entry_data.devices.values()
+ for capability, description in CAPABILITY_TO_COMMAND_SWITCHES.items()
+ if capability in device.status[MAIN]
+ ]
+ entities.extend(
+ SmartThingsSwitch(
+ entry_data.client,
+ device,
+ description,
+ Capability(capability),
+ component,
+ )
+ for device in entry_data.devices.values()
+ for capability, description in CAPABILITY_TO_SWITCHES.items()
+ for component in device.status
+ if capability in device.status[component]
+ and (
+ (description.component_translation_key is None and component == MAIN)
+ or (
+ description.component_translation_key is not None
+ and component in description.component_translation_key
+ )
+ )
)
-
-
-def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None:
- """Return all capabilities supported if minimum required are present."""
- # Must be able to be turned on/off.
- if Capability.switch in capabilities:
- return [Capability.switch, Capability.energy_meter, Capability.power_meter]
- return None
+ entity_registry = er.async_get(hass)
+ for device in entry_data.devices.values():
+ if (
+ Capability.SWITCH in device.status[MAIN]
+ and not any(
+ capability in device.status[MAIN] for capability in CAPABILITIES
+ )
+ and not all(
+ capability in device.status[MAIN] for capability in AC_CAPABILITIES
+ )
+ ):
+ media_player = all(
+ capability in device.status[MAIN]
+ for capability in MEDIA_PLAYER_CAPABILITIES
+ )
+ appliance = (
+ device.device.components[MAIN].manufacturer_category
+ in INVALID_SWITCH_CATEGORIES
+ )
+ if media_player or appliance:
+ issue = "media_player" if media_player else "appliance"
+ if deprecate_entity(
+ hass,
+ entity_registry,
+ SWITCH_DOMAIN,
+ f"{device.device.device_id}_{MAIN}_{Capability.SWITCH}_{Attribute.SWITCH}_{Attribute.SWITCH}",
+ f"deprecated_switch_{issue}",
+ ):
+ entities.append(
+ SmartThingsSwitch(
+ entry_data.client,
+ device,
+ SWITCH,
+ Capability.SWITCH,
+ )
+ )
+ continue
+ entities.append(
+ SmartThingsSwitch(
+ entry_data.client,
+ device,
+ SWITCH,
+ Capability.SWITCH,
+ )
+ )
+ async_add_entities(entities)
class SmartThingsSwitch(SmartThingsEntity, SwitchEntity):
"""Define a SmartThings switch."""
+ entity_description: SmartThingsSwitchEntityDescription
+
+ def __init__(
+ self,
+ client: SmartThings,
+ device: FullDevice,
+ entity_description: SmartThingsSwitchEntityDescription,
+ capability: Capability,
+ component: str = MAIN,
+ ) -> None:
+ """Initialize the switch."""
+ super().__init__(client, device, {capability}, component=component)
+ self.entity_description = entity_description
+ self.switch_capability = capability
+ self._attr_unique_id = f"{device.device.device_id}_{component}_{capability}_{entity_description.status_attribute}_{entity_description.status_attribute}"
+ if (
+ translation_keys := entity_description.component_translation_key
+ ) is not None and (
+ translation_key := translation_keys.get(component)
+ ) is not None:
+ self._attr_translation_key = translation_key
+
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
- await self._device.switch_off(set_status=True)
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_write_ha_state()
+ await self.execute_device_command(
+ self.switch_capability,
+ Command.OFF,
+ )
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
- await self._device.switch_on(set_status=True)
- # State is set optimistically in the command above, therefore update
- # the entity state ahead of receiving the confirming push updates
- self.async_write_ha_state()
+ await self.execute_device_command(
+ self.switch_capability,
+ Command.ON,
+ )
@property
def is_on(self) -> bool:
- """Return true if light is on."""
- return self._device.status.switch
+ """Return true if switch is on."""
+ return (
+ self.get_attribute_value(
+ self.switch_capability, self.entity_description.status_attribute
+ )
+ == "on"
+ )
+
+
+class SmartThingsCommandSwitch(SmartThingsSwitch):
+ """Define a SmartThings command switch."""
+
+ entity_description: SmartThingsCommandSwitchEntityDescription
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the switch off."""
+ await self.execute_device_command(
+ self.switch_capability,
+ self.entity_description.command,
+ "off",
+ )
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the switch on."""
+ await self.execute_device_command(
+ self.switch_capability,
+ self.entity_description.command,
+ "on",
+ )
diff --git a/homeassistant/components/smartthings/update.py b/homeassistant/components/smartthings/update.py
new file mode 100644
index 00000000000..bb226918596
--- /dev/null
+++ b/homeassistant/components/smartthings/update.py
@@ -0,0 +1,87 @@
+"""Support for update entities through the SmartThings cloud API."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from awesomeversion import AwesomeVersion
+from pysmartthings import Attribute, Capability, Command
+
+from homeassistant.components.update import (
+ UpdateDeviceClass,
+ UpdateEntity,
+ UpdateEntityFeature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import SmartThingsConfigEntry
+from .const import MAIN
+from .entity import SmartThingsEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add update entities for a config entry."""
+ entry_data = entry.runtime_data
+ async_add_entities(
+ SmartThingsUpdateEntity(entry_data.client, device, {Capability.FIRMWARE_UPDATE})
+ for device in entry_data.devices.values()
+ if Capability.FIRMWARE_UPDATE in device.status[MAIN]
+ )
+
+
+def is_hex_version(version: str) -> bool:
+ """Check if the version is a hex version."""
+ return len(version) == 8 and all(c in "0123456789abcdefABCDEF" for c in version)
+
+
+class SmartThingsUpdateEntity(SmartThingsEntity, UpdateEntity):
+ """Define a SmartThings update entity."""
+
+ _attr_device_class = UpdateDeviceClass.FIRMWARE
+ _attr_supported_features = (
+ UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
+ )
+
+ @property
+ def installed_version(self) -> str | None:
+ """Return the installed version of the entity."""
+ return self.get_attribute_value(
+ Capability.FIRMWARE_UPDATE, Attribute.CURRENT_VERSION
+ )
+
+ @property
+ def latest_version(self) -> str | None:
+ """Return the available version of the entity."""
+ return self.get_attribute_value(
+ Capability.FIRMWARE_UPDATE, Attribute.AVAILABLE_VERSION
+ )
+
+ @property
+ def in_progress(self) -> bool:
+ """Return if the entity is in progress."""
+ return (
+ self.get_attribute_value(Capability.FIRMWARE_UPDATE, Attribute.STATE)
+ == "updateInProgress"
+ )
+
+ async def async_install(
+ self, version: str | None, backup: bool, **kwargs: Any
+ ) -> None:
+ """Install the firmware update."""
+ await self.execute_device_command(
+ Capability.FIRMWARE_UPDATE,
+ Command.UPDATE_FIRMWARE,
+ )
+
+ def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
+ """Return if the latest version is newer."""
+ if is_hex_version(latest_version):
+ latest_version = f"0x{latest_version}"
+ if is_hex_version(installed_version):
+ installed_version = f"0x{installed_version}"
+ return AwesomeVersion(latest_version) > AwesomeVersion(installed_version)
diff --git a/homeassistant/components/smartthings/util.py b/homeassistant/components/smartthings/util.py
new file mode 100644
index 00000000000..b21652ca629
--- /dev/null
+++ b/homeassistant/components/smartthings/util.py
@@ -0,0 +1,83 @@
+"""Utility functions for SmartThings integration."""
+
+from homeassistant.components.automation import automations_with_entity
+from homeassistant.components.script import scripts_with_entity
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import entity_registry as er
+from homeassistant.helpers.issue_registry import (
+ IssueSeverity,
+ async_create_issue,
+ async_delete_issue,
+)
+
+from .const import DOMAIN
+
+
+def deprecate_entity(
+ hass: HomeAssistant,
+ entity_registry: er.EntityRegistry,
+ platform_domain: str,
+ entity_unique_id: str,
+ issue_string: str,
+) -> bool:
+ """Create an issue for deprecated entities."""
+ if entity_id := entity_registry.async_get_entity_id(
+ platform_domain, DOMAIN, entity_unique_id
+ ):
+ entity_entry = entity_registry.async_get(entity_id)
+ if not entity_entry:
+ return False
+ if entity_entry.disabled:
+ entity_registry.async_remove(entity_id)
+ async_delete_issue(
+ hass,
+ DOMAIN,
+ f"{issue_string}_{entity_id}",
+ )
+ return False
+ translation_key = issue_string
+ placeholders = {
+ "entity_id": entity_id,
+ "entity_name": entity_entry.name or entity_entry.original_name or "Unknown",
+ }
+ if items := get_automations_and_scripts_using_entity(hass, entity_id):
+ translation_key = f"{translation_key}_scripts"
+ placeholders.update(
+ {
+ "items": "\n".join(items),
+ }
+ )
+ async_create_issue(
+ hass,
+ DOMAIN,
+ f"{issue_string}_{entity_id}",
+ breaks_in_ha_version="2025.10.0",
+ is_fixable=False,
+ severity=IssueSeverity.WARNING,
+ translation_key=translation_key,
+ translation_placeholders=placeholders,
+ )
+ return True
+ return False
+
+
+def get_automations_and_scripts_using_entity(
+ hass: HomeAssistant,
+ entity_id: str,
+) -> list[str]:
+ """Get automations and scripts using an entity."""
+ automations = automations_with_entity(hass, entity_id)
+ scripts = scripts_with_entity(hass, entity_id)
+ if not automations and not scripts:
+ return []
+
+ entity_reg = er.async_get(hass)
+ return [
+ f"- [{item.original_name}](/config/{integration}/edit/{item.unique_id})"
+ for integration, entities in (
+ ("automation", automations),
+ ("script", scripts),
+ )
+ for entity_id in entities
+ if (item := entity_reg.async_get(entity_id))
+ ]
diff --git a/homeassistant/components/smartthings/valve.py b/homeassistant/components/smartthings/valve.py
new file mode 100644
index 00000000000..4279d528f8b
--- /dev/null
+++ b/homeassistant/components/smartthings/valve.py
@@ -0,0 +1,71 @@
+"""Support for valves through the SmartThings cloud API."""
+
+from __future__ import annotations
+
+from pysmartthings import Attribute, Capability, Category, Command, SmartThings
+
+from homeassistant.components.valve import (
+ ValveDeviceClass,
+ ValveEntity,
+ ValveEntityFeature,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import FullDevice, SmartThingsConfigEntry
+from .const import MAIN
+from .entity import SmartThingsEntity
+
+DEVICE_CLASS_MAP: dict[Category | str, ValveDeviceClass] = {
+ Category.WATER_VALVE: ValveDeviceClass.WATER,
+ Category.GAS_VALVE: ValveDeviceClass.GAS,
+}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SmartThingsConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Add valves for a config entry."""
+ entry_data = entry.runtime_data
+ async_add_entities(
+ SmartThingsValve(entry_data.client, device)
+ for device in entry_data.devices.values()
+ if Capability.VALVE in device.status[MAIN]
+ )
+
+
+class SmartThingsValve(SmartThingsEntity, ValveEntity):
+ """Define a SmartThings valve."""
+
+ _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
+ _attr_reports_position = False
+ _attr_name = None
+
+ def __init__(self, client: SmartThings, device: FullDevice) -> None:
+ """Init the class."""
+ super().__init__(client, device, {Capability.VALVE})
+ self._attr_device_class = DEVICE_CLASS_MAP.get(
+ device.device.components[MAIN].user_category
+ or device.device.components[MAIN].manufacturer_category
+ )
+
+ async def async_open_valve(self) -> None:
+ """Open the valve."""
+ await self.execute_device_command(
+ Capability.VALVE,
+ Command.OPEN,
+ )
+
+ async def async_close_valve(self) -> None:
+ """Close the valve."""
+ await self.execute_device_command(
+ Capability.VALVE,
+ Command.CLOSE,
+ )
+
+ @property
+ def is_closed(self) -> bool:
+ """Return if the valve is closed."""
+ return self.get_attribute_value(Capability.VALVE, Attribute.VALVE) == "closed"
diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py
index f665f5e61b3..2e8792140b0 100644
--- a/homeassistant/components/smarttub/binary_sensor.py
+++ b/homeassistant/components/smarttub/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .const import ATTR_ERRORS, ATTR_REMINDERS, DOMAIN, SMARTTUB_CONTROLLER
@@ -43,7 +43,9 @@ SNOOZE_REMINDER_SCHEMA: VolDictType = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensor entities for the binary sensors in the tub."""
diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py
index 7f3163834e0..f5759f32fa3 100644
--- a/homeassistant/components/smarttub/climate.py
+++ b/homeassistant/components/smarttub/climate.py
@@ -17,7 +17,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, SMARTTUB_CONTROLLER
@@ -42,7 +42,9 @@ HVAC_ACTIONS = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up climate entity for the thermostat in the tub."""
diff --git a/homeassistant/components/smarttub/light.py b/homeassistant/components/smarttub/light.py
index 532234f4059..dda936aa56a 100644
--- a/homeassistant/components/smarttub/light.py
+++ b/homeassistant/components/smarttub/light.py
@@ -14,7 +14,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_LIGHTS,
@@ -28,7 +28,9 @@ from .helpers import get_spa_name
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for any lights in the tub."""
diff --git a/homeassistant/components/smarttub/manifest.json b/homeassistant/components/smarttub/manifest.json
index d5102f14437..b8d81db0ea5 100644
--- a/homeassistant/components/smarttub/manifest.json
+++ b/homeassistant/components/smarttub/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/smarttub",
"iot_class": "cloud_polling",
"loggers": ["smarttub"],
- "requirements": ["python-smarttub==0.0.38"]
+ "requirements": ["python-smarttub==0.0.39"]
}
diff --git a/homeassistant/components/smarttub/sensor.py b/homeassistant/components/smarttub/sensor.py
index 585e8859432..b2bb1170d09 100644
--- a/homeassistant/components/smarttub/sensor.py
+++ b/homeassistant/components/smarttub/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .const import DOMAIN, SMARTTUB_CONTROLLER
@@ -43,7 +43,9 @@ SET_SECONDARY_FILTRATION_SCHEMA: VolDictType = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor entities for the sensors in the tub."""
diff --git a/homeassistant/components/smarttub/strings.json b/homeassistant/components/smarttub/strings.json
index 974e5fb7d37..8391aaa4d47 100644
--- a/homeassistant/components/smarttub/strings.json
+++ b/homeassistant/components/smarttub/strings.json
@@ -3,7 +3,7 @@
"step": {
"user": {
"title": "Login",
- "description": "Enter your SmartTub email address and password to login",
+ "description": "Enter your SmartTub email address and password to log in",
"data": {
"email": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
@@ -49,17 +49,17 @@
},
"snooze_reminder": {
"name": "Snooze a reminder",
- "description": "Delay a reminder, so that it won't trigger again for a period of time.",
+ "description": "Temporarily suppresses the maintenance reminder on a hot tub.",
"fields": {
"days": {
"name": "Days",
- "description": "The number of days to delay the reminder."
+ "description": "The number of days to snooze the reminder."
}
}
},
"reset_reminder": {
"name": "Reset a reminder",
- "description": "Reset a reminder, and set the next time it will be triggered.",
+ "description": "Resets the maintenance reminder on a hot tub.",
"fields": {
"days": {
"name": "[%key:component::smarttub::services::snooze_reminder::fields::days::name%]",
diff --git a/homeassistant/components/smarttub/switch.py b/homeassistant/components/smarttub/switch.py
index 6e1cf9bef2a..2dedad8e18a 100644
--- a/homeassistant/components/smarttub/switch.py
+++ b/homeassistant/components/smarttub/switch.py
@@ -8,7 +8,7 @@ from smarttub import SpaPump
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import API_TIMEOUT, ATTR_PUMPS, DOMAIN, SMARTTUB_CONTROLLER
from .entity import SmartTubEntity
@@ -16,7 +16,9 @@ from .helpers import get_spa_name
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch entities for the pumps on the tub."""
diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py
index 213cb00d47c..82236a154f0 100644
--- a/homeassistant/components/smarty/binary_sensor.py
+++ b/homeassistant/components/smarty/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SmartyConfigEntry, SmartyCoordinator
from .entity import SmartyEntity
@@ -53,7 +53,7 @@ ENTITIES: tuple[SmartyBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: SmartyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smarty Binary Sensor Platform."""
diff --git a/homeassistant/components/smarty/button.py b/homeassistant/components/smarty/button.py
index b8e31cf6fc8..78638561088 100644
--- a/homeassistant/components/smarty/button.py
+++ b/homeassistant/components/smarty/button.py
@@ -11,7 +11,7 @@ from pysmarty2 import Smarty
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SmartyConfigEntry, SmartyCoordinator
from .entity import SmartyEntity
@@ -38,7 +38,7 @@ ENTITIES: tuple[SmartyButtonDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: SmartyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smarty Button Platform."""
diff --git a/homeassistant/components/smarty/config_flow.py b/homeassistant/components/smarty/config_flow.py
index 9a55356a990..a7f0bdd4123 100644
--- a/homeassistant/components/smarty/config_flow.py
+++ b/homeassistant/components/smarty/config_flow.py
@@ -1,5 +1,6 @@
"""Config flow for Smarty integration."""
+import logging
from typing import Any
from pysmarty2 import Smarty
@@ -10,6 +11,8 @@ from homeassistant.const import CONF_HOST, CONF_NAME
from .const import DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
class SmartyConfigFlow(ConfigFlow, domain=DOMAIN):
"""Smarty config flow."""
@@ -20,7 +23,8 @@ class SmartyConfigFlow(ConfigFlow, domain=DOMAIN):
try:
if smarty.update():
return None
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
return "unknown"
else:
return "cannot_connect"
diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py
index 2804f14ee15..07dec85ae47 100644
--- a/homeassistant/components/smarty/fan.py
+++ b/homeassistant/components/smarty/fan.py
@@ -9,7 +9,7 @@ from typing import Any
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -29,7 +29,7 @@ SPEED_RANGE = (1, 3) # off is not included
async def async_setup_entry(
hass: HomeAssistant,
entry: SmartyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smarty Fan Platform."""
diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json
index ca3133d8add..c295647b8e5 100644
--- a/homeassistant/components/smarty/manifest.json
+++ b/homeassistant/components/smarty/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pymodbus", "pysmarty2"],
- "requirements": ["pysmarty2==0.10.1"]
+ "requirements": ["pysmarty2==0.10.2"]
}
diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py
index 48b169c104e..fe35f741380 100644
--- a/homeassistant/components/smarty/sensor.py
+++ b/homeassistant/components/smarty/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import REVOLUTIONS_PER_MINUTE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import SmartyConfigEntry, SmartyCoordinator
@@ -85,7 +85,7 @@ ENTITIES: tuple[SmartySensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: SmartyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smarty Sensor Platform."""
diff --git a/homeassistant/components/smarty/switch.py b/homeassistant/components/smarty/switch.py
index bf5fe80db44..5781bb11680 100644
--- a/homeassistant/components/smarty/switch.py
+++ b/homeassistant/components/smarty/switch.py
@@ -11,7 +11,7 @@ from pysmarty2 import Smarty
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SmartyConfigEntry, SmartyCoordinator
from .entity import SmartyEntity
@@ -42,7 +42,7 @@ ENTITIES: tuple[SmartySwitchDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: SmartyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Smarty Switch Platform."""
diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py
index 59b32948879..1869b333071 100644
--- a/homeassistant/components/smhi/__init__.py
+++ b/homeassistant/components/smhi/__init__.py
@@ -1,6 +1,5 @@
"""Support for the Swedish weather institute weather service."""
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_LATITUDE,
CONF_LOCATION,
@@ -10,10 +9,12 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
+from .coordinator import SMHIConfigEntry, SMHIDataUpdateCoordinator
+
PLATFORMS = [Platform.WEATHER]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool:
"""Set up SMHI forecast as config entry."""
# Setting unique id where missing
@@ -21,16 +22,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unique_id = f"{entry.data[CONF_LOCATION][CONF_LATITUDE]}-{entry.data[CONF_LOCATION][CONF_LONGITUDE]}"
hass.config_entries.async_update_entry(entry, unique_id=unique_id)
+ coordinator = SMHIDataUpdateCoordinator(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: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_migrate_entry(hass: HomeAssistant, entry: SMHIConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 3:
diff --git a/homeassistant/components/smhi/const.py b/homeassistant/components/smhi/const.py
index 11401119227..6cbf928d5e6 100644
--- a/homeassistant/components/smhi/const.py
+++ b/homeassistant/components/smhi/const.py
@@ -1,5 +1,7 @@
"""Constants in smhi component."""
+from datetime import timedelta
+import logging
from typing import Final
from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
@@ -12,3 +14,8 @@ HOME_LOCATION_NAME = "Home"
DEFAULT_NAME = "Weather"
ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".smhi_{}"
+
+LOGGER = logging.getLogger(__package__)
+
+DEFAULT_SCAN_INTERVAL = timedelta(minutes=31)
+TIMEOUT = 10
diff --git a/homeassistant/components/smhi/coordinator.py b/homeassistant/components/smhi/coordinator.py
new file mode 100644
index 00000000000..511ba8b38d9
--- /dev/null
+++ b/homeassistant/components/smhi/coordinator.py
@@ -0,0 +1,63 @@
+"""DataUpdateCoordinator for the SMHI integration."""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass
+
+from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT
+
+type SMHIConfigEntry = ConfigEntry[SMHIDataUpdateCoordinator]
+
+
+@dataclass
+class SMHIForecastData:
+ """Dataclass for SMHI data."""
+
+ daily: list[SMHIForecast]
+ hourly: list[SMHIForecast]
+
+
+class SMHIDataUpdateCoordinator(DataUpdateCoordinator[SMHIForecastData]):
+ """A SMHI Data Update Coordinator."""
+
+ config_entry: SMHIConfigEntry
+
+ def __init__(self, hass: HomeAssistant, config_entry: SMHIConfigEntry) -> None:
+ """Initialize the SMHI coordinator."""
+ super().__init__(
+ hass,
+ LOGGER,
+ config_entry=config_entry,
+ name=DOMAIN,
+ update_interval=DEFAULT_SCAN_INTERVAL,
+ )
+ self._smhi_api = SMHIPointForecast(
+ config_entry.data[CONF_LOCATION][CONF_LONGITUDE],
+ config_entry.data[CONF_LOCATION][CONF_LATITUDE],
+ session=aiohttp_client.async_get_clientsession(hass),
+ )
+
+ async def _async_update_data(self) -> SMHIForecastData:
+ """Fetch data from SMHI."""
+ try:
+ async with asyncio.timeout(TIMEOUT):
+ _forecast_daily = await self._smhi_api.async_get_daily_forecast()
+ _forecast_hourly = await self._smhi_api.async_get_hourly_forecast()
+ except SmhiForecastException as ex:
+ raise UpdateFailed(
+ "Failed to retrieve the forecast from the SMHI API"
+ ) from ex
+
+ return SMHIForecastData(
+ daily=_forecast_daily,
+ hourly=_forecast_hourly,
+ )
diff --git a/homeassistant/components/smhi/entity.py b/homeassistant/components/smhi/entity.py
new file mode 100644
index 00000000000..89dca3360ca
--- /dev/null
+++ b/homeassistant/components/smhi/entity.py
@@ -0,0 +1,41 @@
+"""Support for the Swedish weather institute weather base entities."""
+
+from __future__ import annotations
+
+from abc import abstractmethod
+
+from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import SMHIDataUpdateCoordinator
+
+
+class SmhiWeatherBaseEntity(CoordinatorEntity[SMHIDataUpdateCoordinator]):
+ """Representation of a base weather entity."""
+
+ _attr_attribution = "Swedish weather institute (SMHI)"
+ _attr_has_entity_name = True
+ _attr_name = None
+
+ def __init__(
+ self,
+ latitude: str,
+ longitude: str,
+ coordinator: SMHIDataUpdateCoordinator,
+ ) -> None:
+ """Initialize the SMHI base weather entity."""
+ super().__init__(coordinator)
+ self._attr_unique_id = f"{latitude}, {longitude}"
+ self._attr_device_info = DeviceInfo(
+ entry_type=DeviceEntryType.SERVICE,
+ identifiers={(DOMAIN, f"{latitude}, {longitude}")},
+ manufacturer="SMHI",
+ model="v2",
+ configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html",
+ )
+ self.update_entity_data()
+
+ @abstractmethod
+ def update_entity_data(self) -> None:
+ """Refresh the entity data."""
diff --git a/homeassistant/components/smhi/manifest.json b/homeassistant/components/smhi/manifest.json
index fc3af634764..0af692b800c 100644
--- a/homeassistant/components/smhi/manifest.json
+++ b/homeassistant/components/smhi/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/smhi",
"iot_class": "cloud_polling",
"loggers": ["pysmhi"],
- "requirements": ["pysmhi==1.0.0"]
+ "requirements": ["pysmhi==1.0.2"]
}
diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py
index 1707afa2fca..5faef04e03d 100644
--- a/homeassistant/components/smhi/weather.py
+++ b/homeassistant/components/smhi/weather.py
@@ -2,14 +2,10 @@
from __future__ import annotations
-import asyncio
from collections.abc import Mapping
-from datetime import datetime, timedelta
-import logging
from typing import Any, Final
-import aiohttp
-from pysmhi import SMHIForecast, SmhiForecastException, SMHIPointForecast
+from pysmhi import SMHIForecast
from homeassistant.components.weather import (
ATTR_CONDITION_CLEAR_NIGHT,
@@ -39,10 +35,9 @@ from homeassistant.components.weather import (
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
Forecast,
- WeatherEntity,
+ SingleCoordinatorWeatherEntity,
WeatherEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_LATITUDE,
CONF_LOCATION,
@@ -53,16 +48,13 @@ from homeassistant.const import (
UnitOfSpeed,
UnitOfTemperature,
)
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers import aiohttp_client, sun
-from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.event import async_call_later
-from homeassistant.util import Throttle
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import sun
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import ATTR_SMHI_THUNDER_PROBABILITY, DOMAIN, ENTITY_ID_SENSOR_FORMAT
-
-_LOGGER = logging.getLogger(__name__)
+from .const import ATTR_SMHI_THUNDER_PROBABILITY, ENTITY_ID_SENSOR_FORMAT
+from .coordinator import SMHIConfigEntry
+from .entity import SmhiWeatherBaseEntity
# Used to map condition from API results
CONDITION_CLASSES: Final[dict[str, list[int]]] = {
@@ -87,112 +79,70 @@ CONDITION_MAP = {
for cond_code in cond_codes
}
-TIMEOUT = 10
-# 5 minutes between retrying connect to API again
-RETRY_TIMEOUT = 5 * 60
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=31)
-
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: SMHIConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from map location."""
location = config_entry.data
- session = aiohttp_client.async_get_clientsession(hass)
+ coordinator = config_entry.runtime_data
entity = SmhiWeather(
location[CONF_LOCATION][CONF_LATITUDE],
location[CONF_LOCATION][CONF_LONGITUDE],
- session=session,
+ coordinator=coordinator,
)
entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(config_entry.title)
- async_add_entities([entity], True)
+ async_add_entities([entity])
-class SmhiWeather(WeatherEntity):
+class SmhiWeather(SmhiWeatherBaseEntity, SingleCoordinatorWeatherEntity):
"""Representation of a weather entity."""
- _attr_attribution = "Swedish weather institute (SMHI)"
_attr_native_temperature_unit = UnitOfTemperature.CELSIUS
_attr_native_visibility_unit = UnitOfLength.KILOMETERS
_attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS
_attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND
_attr_native_pressure_unit = UnitOfPressure.HPA
-
- _attr_has_entity_name = True
- _attr_name = None
_attr_supported_features = (
WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY
)
- def __init__(
- self,
- latitude: str,
- longitude: str,
- session: aiohttp.ClientSession,
- ) -> None:
- """Initialize the SMHI weather entity."""
- self._attr_unique_id = f"{latitude}, {longitude}"
- self._forecast_daily: list[SMHIForecast] | None = None
- self._forecast_hourly: list[SMHIForecast] | None = None
- self._fail_count = 0
- self._smhi_api = SMHIPointForecast(longitude, latitude, session=session)
- self._attr_device_info = DeviceInfo(
- entry_type=DeviceEntryType.SERVICE,
- identifiers={(DOMAIN, f"{latitude}, {longitude}")},
- manufacturer="SMHI",
- model="v2",
- configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html",
- )
+ def update_entity_data(self) -> None:
+ """Refresh the entity data."""
+ if daily_data := self.coordinator.data.daily:
+ self._attr_native_temperature = daily_data[0]["temperature"]
+ self._attr_humidity = daily_data[0]["humidity"]
+ self._attr_native_wind_speed = daily_data[0]["wind_speed"]
+ self._attr_wind_bearing = daily_data[0]["wind_direction"]
+ self._attr_native_visibility = daily_data[0]["visibility"]
+ self._attr_native_pressure = daily_data[0]["pressure"]
+ self._attr_native_wind_gust_speed = daily_data[0]["wind_gust"]
+ self._attr_cloud_coverage = daily_data[0]["total_cloud"]
+ self._attr_condition = CONDITION_MAP.get(daily_data[0]["symbol"])
+ if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up(
+ self.coordinator.hass
+ ):
+ self._attr_condition = ATTR_CONDITION_CLEAR_NIGHT
@property
def extra_state_attributes(self) -> Mapping[str, Any] | None:
"""Return additional attributes."""
- if self._forecast_daily:
+ if daily_data := self.coordinator.data.daily:
return {
- ATTR_SMHI_THUNDER_PROBABILITY: self._forecast_daily[0]["thunder"],
+ ATTR_SMHI_THUNDER_PROBABILITY: daily_data[0]["thunder"],
}
return None
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- async def async_update(self) -> None:
- """Refresh the forecast data from SMHI weather API."""
- try:
- async with asyncio.timeout(TIMEOUT):
- self._forecast_daily = await self._smhi_api.async_get_daily_forecast()
- self._forecast_hourly = await self._smhi_api.async_get_hourly_forecast()
- self._fail_count = 0
- except (TimeoutError, SmhiForecastException):
- _LOGGER.error("Failed to connect to SMHI API, retry in 5 minutes")
- self._fail_count += 1
- if self._fail_count < 3:
- async_call_later(self.hass, RETRY_TIMEOUT, self.retry_update)
- return
-
- if self._forecast_daily:
- self._attr_native_temperature = self._forecast_daily[0]["temperature"]
- self._attr_humidity = self._forecast_daily[0]["humidity"]
- self._attr_native_wind_speed = self._forecast_daily[0]["wind_speed"]
- self._attr_wind_bearing = self._forecast_daily[0]["wind_direction"]
- self._attr_native_visibility = self._forecast_daily[0]["visibility"]
- self._attr_native_pressure = self._forecast_daily[0]["pressure"]
- self._attr_native_wind_gust_speed = self._forecast_daily[0]["wind_gust"]
- self._attr_cloud_coverage = self._forecast_daily[0]["total_cloud"]
- self._attr_condition = CONDITION_MAP.get(self._forecast_daily[0]["symbol"])
- if self._attr_condition == ATTR_CONDITION_SUNNY and not sun.is_up(
- self.hass
- ):
- self._attr_condition = ATTR_CONDITION_CLEAR_NIGHT
- await self.async_update_listeners(("daily", "hourly"))
-
- async def retry_update(self, _: datetime) -> None:
- """Retry refresh weather forecast."""
- await self.async_update(no_throttle=True)
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self.update_entity_data()
+ super()._handle_coordinator_update()
def _get_forecast_data(
self, forecast_data: list[SMHIForecast] | None
@@ -231,10 +181,10 @@ class SmhiWeather(WeatherEntity):
return data
- async def async_forecast_daily(self) -> list[Forecast] | None:
+ def _async_forecast_daily(self) -> list[Forecast] | None:
"""Service to retrieve the daily forecast."""
- return self._get_forecast_data(self._forecast_daily)
+ return self._get_forecast_data(self.coordinator.data.daily)
- async def async_forecast_hourly(self) -> list[Forecast] | None:
+ def _async_forecast_hourly(self) -> list[Forecast] | None:
"""Service to retrieve the hourly forecast."""
- return self._get_forecast_data(self._forecast_hourly)
+ return self._get_forecast_data(self.coordinator.data.hourly)
diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py
index 8f3e675ef6b..b3a6860e5b7 100644
--- a/homeassistant/components/smlight/__init__.py
+++ b/homeassistant/components/smlight/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from pysmlight import Api2, Info, Radio
+from pysmlight import Api2
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
@@ -50,9 +50,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: SmConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
-
-def get_radio(info: Info, idx: int) -> Radio:
- """Get the radio object from the info."""
- assert info.radios is not None
- return info.radios[idx]
diff --git a/homeassistant/components/smlight/binary_sensor.py b/homeassistant/components/smlight/binary_sensor.py
index de13e648961..aaba15e19f2 100644
--- a/homeassistant/components/smlight/binary_sensor.py
+++ b/homeassistant/components/smlight/binary_sensor.py
@@ -16,12 +16,13 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SCAN_INTERNET_INTERVAL
from .coordinator import SmConfigEntry, SmDataUpdateCoordinator
from .entity import SmEntity
+PARALLEL_UPDATES = 0
SCAN_INTERVAL = SCAN_INTERNET_INTERVAL
@@ -56,7 +57,7 @@ SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: SmConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SMLIGHT sensor based on a config entry."""
coordinator = entry.runtime_data.data
diff --git a/homeassistant/components/smlight/button.py b/homeassistant/components/smlight/button.py
index 20ad507fa78..f834392ea13 100644
--- a/homeassistant/components/smlight/button.py
+++ b/homeassistant/components/smlight/button.py
@@ -17,12 +17,14 @@ from homeassistant.components.button import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SmConfigEntry, SmDataUpdateCoordinator
from .entity import SmEntity
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
@@ -65,7 +67,7 @@ ROUTER = SmButtonDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: SmConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SMLIGHT buttons based on a config entry."""
coordinator = entry.runtime_data.data
diff --git a/homeassistant/components/smlight/config_flow.py b/homeassistant/components/smlight/config_flow.py
index 667e6e2884b..ce4f8f43233 100644
--- a/homeassistant/components/smlight/config_flow.py
+++ b/homeassistant/components/smlight/config_flow.py
@@ -51,14 +51,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
self.client = Api2(self._host, session=async_get_clientsession(self.hass))
try:
- info = await self.client.get_info()
- self._host = str(info.device_ip)
- self._device_name = str(info.hostname)
-
- if info.model not in Devices:
- return self.async_abort(reason="unsupported_device")
-
if not await self._async_check_auth_required(user_input):
+ info = await self.client.get_info()
+ self._host = str(info.device_ip)
+ self._device_name = str(info.hostname)
+
+ if info.model not in Devices:
+ return self.async_abort(reason="unsupported_device")
+
return await self._async_complete_entry(user_input)
except SmlightConnectionError:
errors["base"] = "cannot_connect"
@@ -77,12 +77,14 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
- info = await self.client.get_info()
-
- if info.model not in Devices:
- return self.async_abort(reason="unsupported_device")
-
if not await self._async_check_auth_required(user_input):
+ info = await self.client.get_info()
+ self._host = str(info.device_ip)
+ self._device_name = str(info.hostname)
+
+ if info.model not in Devices:
+ return self.async_abort(reason="unsupported_device")
+
return await self._async_complete_entry(user_input)
except SmlightConnectionError:
return self.async_abort(reason="cannot_connect")
@@ -126,13 +128,13 @@ class SmlightConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None:
try:
- info = await self.client.get_info()
-
- if info.model not in Devices:
- return self.async_abort(reason="unsupported_device")
-
if not await self._async_check_auth_required(user_input):
- return await self._async_complete_entry(user_input)
+ info = await self.client.get_info()
+
+ if info.model not in Devices:
+ return self.async_abort(reason="unsupported_device")
+
+ return await self._async_complete_entry(user_input)
except SmlightConnectionError:
return self.async_abort(reason="cannot_connect")
diff --git a/homeassistant/components/smlight/coordinator.py b/homeassistant/components/smlight/coordinator.py
index 5a118e7de15..8a8dcd74b8f 100644
--- a/homeassistant/components/smlight/coordinator.py
+++ b/homeassistant/components/smlight/coordinator.py
@@ -111,7 +111,11 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
raise ConfigEntryAuthFailed from err
except SmlightConnectionError as err:
- raise UpdateFailed(err) from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect_device",
+ translation_placeholders={"error": str(err)},
+ ) from err
@abstractmethod
async def _internal_update_data(self) -> _DataT:
diff --git a/homeassistant/components/smlight/manifest.json b/homeassistant/components/smlight/manifest.json
index 3f527d1fcd9..b2a03a737fc 100644
--- a/homeassistant/components/smlight/manifest.json
+++ b/homeassistant/components/smlight/manifest.json
@@ -11,7 +11,8 @@
"documentation": "https://www.home-assistant.io/integrations/smlight",
"integration_type": "device",
"iot_class": "local_push",
- "requirements": ["pysmlight==0.2.3"],
+ "quality_scale": "silver",
+ "requirements": ["pysmlight==0.2.4"],
"zeroconf": [
{
"type": "_slzb-06._tcp.local."
diff --git a/homeassistant/components/smlight/quality_scale.yaml b/homeassistant/components/smlight/quality_scale.yaml
new file mode 100644
index 00000000000..5c6d7364704
--- /dev/null
+++ b/homeassistant/components/smlight/quality_scale.yaml
@@ -0,0 +1,85 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional 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: |
+ This integration does not provide additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: done
+ comment: |
+ Entities subscribe to SSE events from pysmlight library.
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup:
+ status: done
+ comment: Handled implicitly within coordinator
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: |
+ This integration does not provide actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ This integration does not provide an option flow.
+ docs-installation-parameters: done
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable:
+ status: done
+ comment: Handled by coordinator
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info: done
+ discovery: done
+ docs-data-update: done
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: todo
+ repair-issues: done
+ stale-devices:
+ status: exempt
+ comment: |
+ Device type integration.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/smlight/sensor.py b/homeassistant/components/smlight/sensor.py
index 3b7683f61fe..f045d009a00 100644
--- a/homeassistant/components/smlight/sensor.py
+++ b/homeassistant/components/smlight/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
@@ -25,6 +25,8 @@ from .const import UPTIME_DEVIATION
from .coordinator import SmConfigEntry, SmDataUpdateCoordinator
from .entity import SmEntity
+PARALLEL_UPDATES = 0
+
@dataclass(frozen=True, kw_only=True)
class SmSensorEntityDescription(SensorEntityDescription):
@@ -37,7 +39,7 @@ class SmSensorEntityDescription(SensorEntityDescription):
class SmInfoEntityDescription(SensorEntityDescription):
"""Class describing SMLIGHT information entities."""
- value_fn: Callable[[Info], StateType]
+ value_fn: Callable[[Info, int], StateType]
INFO: list[SmInfoEntityDescription] = [
@@ -46,24 +48,25 @@ INFO: list[SmInfoEntityDescription] = [
translation_key="device_mode",
device_class=SensorDeviceClass.ENUM,
options=["eth", "wifi", "usb"],
- value_fn=lambda x: x.coord_mode,
+ value_fn=lambda x, idx: x.coord_mode,
),
SmInfoEntityDescription(
key="firmware_channel",
translation_key="firmware_channel",
device_class=SensorDeviceClass.ENUM,
options=["dev", "release"],
- value_fn=lambda x: x.fw_channel,
- ),
- SmInfoEntityDescription(
- key="zigbee_type",
- translation_key="zigbee_type",
- device_class=SensorDeviceClass.ENUM,
- options=["coordinator", "router", "thread"],
- value_fn=lambda x: x.zb_type,
+ value_fn=lambda x, idx: x.fw_channel,
),
]
+RADIO_INFO = SmInfoEntityDescription(
+ key="zigbee_type",
+ translation_key="zigbee_type",
+ device_class=SensorDeviceClass.ENUM,
+ options=["coordinator", "router", "thread"],
+ value_fn=lambda x, idx: x.radios[idx].zb_type,
+)
+
SENSORS: list[SmSensorEntityDescription] = [
SmSensorEntityDescription(
@@ -102,6 +105,16 @@ SENSORS: list[SmSensorEntityDescription] = [
),
]
+EXTRA_SENSOR = SmSensorEntityDescription(
+ key="zigbee_temperature_2",
+ translation_key="zigbee_temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=1,
+ value_fn=lambda x: x.zb_temp2,
+)
+
UPTIME: list[SmSensorEntityDescription] = [
SmSensorEntityDescription(
key="core_uptime",
@@ -123,12 +136,11 @@ UPTIME: list[SmSensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
entry: SmConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SMLIGHT sensor based on a config entry."""
coordinator = entry.runtime_data.data
-
- async_add_entities(
+ entities: list[SmEntity] = list(
chain(
(SmInfoSensorEntity(coordinator, description) for description in INFO),
(SmSensorEntity(coordinator, description) for description in SENSORS),
@@ -136,6 +148,16 @@ async def async_setup_entry(
)
)
+ entities.extend(
+ SmInfoSensorEntity(coordinator, RADIO_INFO, idx)
+ for idx, _ in enumerate(coordinator.data.info.radios)
+ )
+
+ if coordinator.data.sensors.zb_temp2 is not None:
+ entities.append(SmSensorEntity(coordinator, EXTRA_SENSOR))
+
+ async_add_entities(entities)
+
class SmSensorEntity(SmEntity, SensorEntity):
"""Representation of a slzb sensor."""
@@ -172,17 +194,20 @@ class SmInfoSensorEntity(SmEntity, SensorEntity):
self,
coordinator: SmDataUpdateCoordinator,
description: SmInfoEntityDescription,
+ idx: int = 0,
) -> None:
"""Initiate slzb sensor."""
super().__init__(coordinator)
self.entity_description = description
- self._attr_unique_id = f"{coordinator.unique_id}_{description.key}"
+ self.idx = idx
+ sensor = f"_{idx}" if idx else ""
+ self._attr_unique_id = f"{coordinator.unique_id}_{description.key}{sensor}"
@property
def native_value(self) -> StateType:
"""Return the sensor value."""
- value = self.entity_description.value_fn(self.coordinator.data.info)
+ value = self.entity_description.value_fn(self.coordinator.data.info, self.idx)
options = self.entity_description.options
if isinstance(value, int) and options is not None:
diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json
index 21ff5098d27..4abc6349d1e 100644
--- a/homeassistant/components/smlight/strings.json
+++ b/homeassistant/components/smlight/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "description": "Set up SMLIGHT Zigbee Integration",
+ "description": "Set up SMLIGHT Zigbee integration",
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
@@ -15,6 +15,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "Username for the device's web login.",
+ "password": "Password for the device's web login."
}
},
"reauth_confirm": {
@@ -23,6 +27,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "[%key:component::smlight::config::step::auth::data_description::username%]",
+ "password": "[%key:component::smlight::config::step::auth::data_description::password%]"
}
},
"confirm_discovery": {
@@ -111,7 +119,7 @@
"name": "Zigbee flash mode"
},
"reconnect_zigbee_router": {
- "name": "Reconnect zigbee router"
+ "name": "Reconnect Zigbee router"
}
},
"switch": {
@@ -137,6 +145,14 @@
}
}
},
+ "exceptions": {
+ "firmware_update_failed": {
+ "message": "Firmware update failed for {device_name}."
+ },
+ "cannot_connect_device": {
+ "message": "An error occurred while connecting to the SMLIGHT device: {error}."
+ }
+ },
"issues": {
"unsupported_firmware": {
"title": "SLZB core firmware update required",
diff --git a/homeassistant/components/smlight/switch.py b/homeassistant/components/smlight/switch.py
index ce473da358e..5cd187c009c 100644
--- a/homeassistant/components/smlight/switch.py
+++ b/homeassistant/components/smlight/switch.py
@@ -17,11 +17,13 @@ from homeassistant.components.switch import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SmConfigEntry, SmDataUpdateCoordinator
from .entity import SmEntity
+PARALLEL_UPDATES = 1
+
_LOGGER = logging.getLogger(__name__)
@@ -67,7 +69,7 @@ SWITCHES: list[SmSwitchEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
entry: SmConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize switches for SLZB-06 device."""
coordinator = entry.runtime_data.data
diff --git a/homeassistant/components/smlight/update.py b/homeassistant/components/smlight/update.py
index 662195bdfc0..d7aed0ecb4d 100644
--- a/homeassistant/components/smlight/update.py
+++ b/homeassistant/components/smlight/update.py
@@ -20,13 +20,14 @@ from homeassistant.components.update import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import get_radio
-from .const import LOGGER
+from .const import DOMAIN, LOGGER
from .coordinator import SmConfigEntry, SmFirmwareUpdateCoordinator, SmFwData
from .entity import SmEntity
+PARALLEL_UPDATES = 1
+
def zigbee_latest_version(data: SmFwData, idx: int) -> Firmware | None:
"""Get the latest Zigbee firmware version."""
@@ -56,13 +57,15 @@ CORE_UPDATE_ENTITY = SmUpdateEntityDescription(
ZB_UPDATE_ENTITY = SmUpdateEntityDescription(
key="zigbee_update",
translation_key="zigbee_update",
- installed_version=lambda x, idx: get_radio(x, idx).zb_version,
+ installed_version=lambda x, idx: x.radios[idx].zb_version,
latest_version=zigbee_latest_version,
)
async def async_setup_entry(
- hass: HomeAssistant, entry: SmConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: SmConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SMLIGHT update entities."""
coordinator = entry.runtime_data.firmware
@@ -73,7 +76,6 @@ async def async_setup_entry(
entities = [SmUpdateEntity(coordinator, CORE_UPDATE_ENTITY)]
radios = coordinator.data.info.radios
- assert radios is not None
entities.extend(
SmUpdateEntity(coordinator, ZB_UPDATE_ENTITY, idx)
@@ -208,7 +210,13 @@ class SmUpdateEntity(SmEntity, UpdateEntity):
def _update_failed(self, event: MessageEvent) -> None:
self._update_done()
self.coordinator.in_progress = False
- raise HomeAssistantError(f"Update failed for {self.name}")
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="firmware_update_failed",
+ translation_placeholders={
+ "device_name": str(self.name),
+ },
+ )
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py
index 821200f68b1..46ee754a1f1 100644
--- a/homeassistant/components/sms/sensor.py
+++ b/homeassistant/components/sms/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, GATEWAY, NETWORK_COORDINATOR, SIGNAL_COORDINATOR, SMS_GATEWAY
@@ -77,7 +77,7 @@ NETWORK_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all device sensors."""
sms_data = hass.data[DOMAIN][SMS_GATEWAY]
diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py
index e86b22690a4..943be229ec3 100644
--- a/homeassistant/components/smtp/notify.py
+++ b/homeassistant/components/smtp/notify.py
@@ -38,7 +38,7 @@ from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.reload import setup_reload_service
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
-from homeassistant.util.ssl import client_context
+from homeassistant.util.ssl import create_client_context
from .const import (
ATTR_HTML,
@@ -86,6 +86,7 @@ def get_service(
) -> MailNotificationService | None:
"""Get the mail notification service."""
setup_reload_service(hass, DOMAIN, PLATFORMS)
+ ssl_context = create_client_context() if config[CONF_VERIFY_SSL] else None
mail_service = MailNotificationService(
config[CONF_SERVER],
config[CONF_PORT],
@@ -98,6 +99,7 @@ def get_service(
config.get(CONF_SENDER_NAME),
config[CONF_DEBUG],
config[CONF_VERIFY_SSL],
+ ssl_context,
)
if mail_service.connection_is_valid():
@@ -122,6 +124,7 @@ class MailNotificationService(BaseNotificationService):
sender_name,
debug,
verify_ssl,
+ ssl_context,
):
"""Initialize the SMTP service."""
self._server = server
@@ -136,23 +139,23 @@ class MailNotificationService(BaseNotificationService):
self.debug = debug
self._verify_ssl = verify_ssl
self.tries = 2
+ self._ssl_context = ssl_context
def connect(self):
"""Connect/authenticate to SMTP Server."""
- ssl_context = client_context() if self._verify_ssl else None
if self.encryption == "tls":
mail = smtplib.SMTP_SSL(
self._server,
self._port,
timeout=self._timeout,
- context=ssl_context,
+ context=self._ssl_context,
)
else:
mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout)
mail.set_debuglevel(self.debug)
mail.ehlo_or_helo_if_needed()
if self.encryption == "starttls":
- mail.starttls(context=ssl_context)
+ mail.starttls(context=self._ssl_context)
mail.ehlo()
if self.username and self.password:
mail.login(self.username, self.password)
diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py
index 0ec27c1ad9c..5f011ca41ee 100644
--- a/homeassistant/components/snapcast/media_player.py
+++ b/homeassistant/components/snapcast/media_player.py
@@ -25,7 +25,7 @@ from homeassistant.helpers import (
entity_platform,
entity_registry as er,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_LATENCY,
@@ -73,7 +73,7 @@ def register_services() -> None:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the snapcast config entry."""
diff --git a/homeassistant/components/snips/strings.json b/homeassistant/components/snips/strings.json
index 724e1a86477..23b255b05a9 100644
--- a/homeassistant/components/snips/strings.json
+++ b/homeassistant/components/snips/strings.json
@@ -44,7 +44,7 @@
"fields": {
"can_be_enqueued": {
"name": "Can be enqueued",
- "description": "If True, session waits for an open session to end, if False session is dropped if one is running."
+ "description": "Whether the session should wait for an open session to end. Otherwise it is dropped if another session is already running."
},
"custom_data": {
"name": "[%key:component::snips::services::say::fields::custom_data::name%]",
diff --git a/homeassistant/components/snoo/__init__.py b/homeassistant/components/snoo/__init__.py
new file mode 100644
index 00000000000..54834bf58ce
--- /dev/null
+++ b/homeassistant/components/snoo/__init__.py
@@ -0,0 +1,69 @@
+"""The Happiest Baby Snoo integration."""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+
+from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException, SnooDeviceError
+from python_snoo.snoo import Snoo
+
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .coordinator import SnooConfigEntry, SnooCoordinator
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORMS: list[Platform] = [
+ Platform.BINARY_SENSOR,
+ Platform.EVENT,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.SWITCH,
+]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool:
+ """Set up Happiest Baby Snoo from a config entry."""
+
+ snoo = Snoo(
+ email=entry.data[CONF_USERNAME],
+ password=entry.data[CONF_PASSWORD],
+ clientsession=async_get_clientsession(hass),
+ )
+
+ try:
+ await snoo.authorize()
+ except (SnooAuthException, InvalidSnooAuth) as ex:
+ raise ConfigEntryNotReady from ex
+ try:
+ devices = await snoo.get_devices()
+ except SnooDeviceError as ex:
+ raise ConfigEntryNotReady from ex
+ coordinators: dict[str, SnooCoordinator] = {}
+ tasks = []
+ for device in devices:
+ coordinators[device.serialNumber] = SnooCoordinator(hass, device, snoo)
+ tasks.append(coordinators[device.serialNumber].setup())
+ await asyncio.gather(*tasks)
+ entry.runtime_data = coordinators
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: SnooConfigEntry) -> bool:
+ """Unload a config entry."""
+ disconnects = await asyncio.gather(
+ *(coordinator.snoo.disconnect() for coordinator in entry.runtime_data.values()),
+ return_exceptions=True,
+ )
+ for disconnect in disconnects:
+ if isinstance(disconnect, Exception):
+ _LOGGER.warning(
+ "Failed to disconnect a logger with exception: %s", disconnect
+ )
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/snoo/binary_sensor.py b/homeassistant/components/snoo/binary_sensor.py
new file mode 100644
index 00000000000..3c91db5b86d
--- /dev/null
+++ b/homeassistant/components/snoo/binary_sensor.py
@@ -0,0 +1,70 @@
+"""Support for Snoo Binary Sensors."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from python_snoo.containers import SnooData
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDeviceClass,
+ BinarySensorEntity,
+ BinarySensorEntityDescription,
+ EntityCategory,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import SnooConfigEntry
+from .entity import SnooDescriptionEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class SnooBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Describes a Snoo Binary Sensor."""
+
+ value_fn: Callable[[SnooData], bool]
+
+
+BINARY_SENSOR_DESCRIPTIONS: list[SnooBinarySensorEntityDescription] = [
+ SnooBinarySensorEntityDescription(
+ key="left_clip",
+ translation_key="left_clip",
+ value_fn=lambda data: data.left_safety_clip,
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ SnooBinarySensorEntityDescription(
+ key="right_clip",
+ translation_key="right_clip",
+ value_fn=lambda data: data.left_safety_clip,
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SnooConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Snoo device."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ SnooBinarySensor(coordinator, description)
+ for coordinator in coordinators.values()
+ for description in BINARY_SENSOR_DESCRIPTIONS
+ )
+
+
+class SnooBinarySensor(SnooDescriptionEntity, BinarySensorEntity):
+ """A Binary sensor using Snoo coordinator."""
+
+ entity_description: SnooBinarySensorEntityDescription
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if the binary sensor is on."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/snoo/config_flow.py b/homeassistant/components/snoo/config_flow.py
new file mode 100644
index 00000000000..986ef6a0071
--- /dev/null
+++ b/homeassistant/components/snoo/config_flow.py
@@ -0,0 +1,68 @@
+"""Config flow for the Happiest Baby Snoo integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+import jwt
+from python_snoo.exceptions import InvalidSnooAuth, SnooAuthException
+from python_snoo.snoo import Snoo
+import voluptuous as vol
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ }
+)
+
+
+class SnooConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Happiest Baby Snoo."""
+
+ VERSION = 1
+
+ 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:
+ hub = Snoo(
+ email=user_input[CONF_USERNAME],
+ password=user_input[CONF_PASSWORD],
+ clientsession=async_get_clientsession(self.hass),
+ )
+
+ try:
+ tokens = await hub.authorize()
+ except SnooAuthException:
+ errors["base"] = "cannot_connect"
+ except InvalidSnooAuth:
+ errors["base"] = "invalid_auth"
+ except Exception:
+ _LOGGER.exception("Unexpected exception %s")
+ errors["base"] = "unknown"
+ else:
+ user_uuid = jwt.decode(
+ tokens.aws_access, options={"verify_signature": False}
+ )["username"]
+ await self.async_set_unique_id(user_uuid)
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(
+ title=user_input[CONF_USERNAME], data=user_input
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
diff --git a/homeassistant/components/snoo/const.py b/homeassistant/components/snoo/const.py
new file mode 100644
index 00000000000..ff8afe25056
--- /dev/null
+++ b/homeassistant/components/snoo/const.py
@@ -0,0 +1,3 @@
+"""Constants for the Happiest Baby Snoo integration."""
+
+DOMAIN = "snoo"
diff --git a/homeassistant/components/snoo/coordinator.py b/homeassistant/components/snoo/coordinator.py
new file mode 100644
index 00000000000..bc06d20955c
--- /dev/null
+++ b/homeassistant/components/snoo/coordinator.py
@@ -0,0 +1,39 @@
+"""Support for Snoo Coordinators."""
+
+import logging
+
+from python_snoo.containers import SnooData, SnooDevice
+from python_snoo.snoo import Snoo
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+
+type SnooConfigEntry = ConfigEntry[dict[str, SnooCoordinator]]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class SnooCoordinator(DataUpdateCoordinator[SnooData]):
+ """Snoo coordinator."""
+
+ config_entry: SnooConfigEntry
+
+ def __init__(self, hass: HomeAssistant, device: SnooDevice, snoo: Snoo) -> None:
+ """Set up Snoo Coordinator."""
+ super().__init__(
+ hass,
+ name=device.name,
+ logger=_LOGGER,
+ )
+ self.device_unique_id = device.serialNumber
+ self.device = device
+ self.sensor_data_set: bool = False
+ self.snoo = snoo
+
+ async def setup(self) -> None:
+ """Perform setup needed on every coordintaor creation."""
+ await self.snoo.subscribe(self.device, self.async_set_updated_data)
+ # After we subscribe - get the status so that we have something to start with.
+ # We only need to do this once. The device will auto update otherwise.
+ await self.snoo.get_status(self.device)
diff --git a/homeassistant/components/snoo/entity.py b/homeassistant/components/snoo/entity.py
new file mode 100644
index 00000000000..25f54344674
--- /dev/null
+++ b/homeassistant/components/snoo/entity.py
@@ -0,0 +1,37 @@
+"""Base entity for the Snoo integration."""
+
+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 .const import DOMAIN
+from .coordinator import SnooCoordinator
+
+
+class SnooDescriptionEntity(CoordinatorEntity[SnooCoordinator]):
+ """Defines an Snoo entity that uses a description."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self, coordinator: SnooCoordinator, description: EntityDescription
+ ) -> None:
+ """Initialize the Snoo entity."""
+ super().__init__(coordinator)
+ self.device = coordinator.device
+ self.entity_description = description
+ self._attr_unique_id = f"{coordinator.device_unique_id}_{description.key}"
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, coordinator.device_unique_id)},
+ name=self.device.name,
+ manufacturer="Happiest Baby",
+ model="Snoo",
+ serial_number=self.device.serialNumber,
+ )
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return self.coordinator.data is not None and super().available
diff --git a/homeassistant/components/snoo/event.py b/homeassistant/components/snoo/event.py
new file mode 100644
index 00000000000..1e50ee46d90
--- /dev/null
+++ b/homeassistant/components/snoo/event.py
@@ -0,0 +1,63 @@
+"""Support for Snoo Events."""
+
+from homeassistant.components.event import EventEntity, EventEntityDescription
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import SnooConfigEntry
+from .entity import SnooDescriptionEntity
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SnooConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Snoo device."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ SnooEvent(
+ coordinator,
+ EventEntityDescription(
+ key="event",
+ translation_key="event",
+ event_types=[
+ "timer",
+ "cry",
+ "command",
+ "safety_clip",
+ "long_activity_press",
+ "activity",
+ "power",
+ "status_requested",
+ "sticky_white_noise_updated",
+ "config_change",
+ ],
+ ),
+ )
+ for coordinator in coordinators.values()
+ )
+
+
+class SnooEvent(SnooDescriptionEntity, EventEntity):
+ """A event using Snoo coordinator."""
+
+ @callback
+ def _async_handle_event(self) -> None:
+ """Handle the demo button event."""
+ self._trigger_event(
+ self.coordinator.data.event.value,
+ )
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self) -> None:
+ """Add Event."""
+ await super().async_added_to_hass()
+ if self.coordinator.data:
+ # If we were able to get data on startup - set it
+ # Otherwise, it will update when the coordinator gets data.
+ self._async_handle_event()
+
+ def _handle_coordinator_update(self) -> None:
+ self._async_handle_event()
+ return super()._handle_coordinator_update()
diff --git a/homeassistant/components/snoo/manifest.json b/homeassistant/components/snoo/manifest.json
new file mode 100644
index 00000000000..839382b2d84
--- /dev/null
+++ b/homeassistant/components/snoo/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "snoo",
+ "name": "Happiest Baby Snoo",
+ "codeowners": ["@Lash-L"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/snoo",
+ "iot_class": "cloud_push",
+ "loggers": ["snoo"],
+ "quality_scale": "bronze",
+ "requirements": ["python-snoo==0.6.5"]
+}
diff --git a/homeassistant/components/snoo/quality_scale.yaml b/homeassistant/components/snoo/quality_scale.yaml
new file mode 100644
index 00000000000..f10bccb131a
--- /dev/null
+++ b/homeassistant/components/snoo/quality_scale.yaml
@@ -0,0 +1,72 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration does not poll.
+ brands: done
+ common-modules:
+ status: done
+ comment: |
+ There are no common patterns currenty.
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ 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: todo
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: todo
+ test-coverage: todo
+
+ # Gold
+ devices: done
+ diagnostics: todo
+ discovery-update-info: todo
+ discovery: todo
+ 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: done
+ entity-device-class: done
+ entity-disabled-by-default: todo
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues: todo
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/snoo/select.py b/homeassistant/components/snoo/select.py
new file mode 100644
index 00000000000..44624ed1a2d
--- /dev/null
+++ b/homeassistant/components/snoo/select.py
@@ -0,0 +1,78 @@
+"""Support for Snoo Select."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+
+from python_snoo.containers import SnooData, SnooDevice, SnooLevels
+from python_snoo.exceptions import SnooCommandException
+from python_snoo.snoo import Snoo
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .const import DOMAIN
+from .coordinator import SnooConfigEntry
+from .entity import SnooDescriptionEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class SnooSelectEntityDescription(SelectEntityDescription):
+ """Describes a Snoo Select."""
+
+ value_fn: Callable[[SnooData], str]
+ set_value_fn: Callable[[Snoo, SnooDevice, str], Awaitable[None]]
+
+
+SELECT_DESCRIPTIONS: list[SnooSelectEntityDescription] = [
+ SnooSelectEntityDescription(
+ key="intensity",
+ translation_key="intensity",
+ value_fn=lambda data: data.state_machine.level.name,
+ set_value_fn=lambda snoo_api, device, state: snoo_api.set_level(
+ device, SnooLevels[state]
+ ),
+ options=[level.name for level in SnooLevels],
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SnooConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Snoo device."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ SnooSelect(coordinator, description)
+ for coordinator in coordinators.values()
+ for description in SELECT_DESCRIPTIONS
+ )
+
+
+class SnooSelect(SnooDescriptionEntity, SelectEntity):
+ """A sensor using Snoo coordinator."""
+
+ entity_description: SnooSelectEntityDescription
+
+ @property
+ def current_option(self) -> str | None:
+ """Return the selected entity option to represent the entity state."""
+ return self.entity_description.value_fn(self.coordinator.data)
+
+ async def async_select_option(self, option: str) -> None:
+ """Change the selected option."""
+ try:
+ await self.entity_description.set_value_fn(
+ self.coordinator.snoo, self.device, option
+ )
+ except SnooCommandException as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="select_failed",
+ translation_placeholders={"name": str(self.name), "option": option},
+ ) from err
diff --git a/homeassistant/components/snoo/sensor.py b/homeassistant/components/snoo/sensor.py
new file mode 100644
index 00000000000..e45b2b88592
--- /dev/null
+++ b/homeassistant/components/snoo/sensor.py
@@ -0,0 +1,71 @@
+"""Support for Snoo Sensors."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass
+
+from python_snoo.containers import SnooData, SnooStates
+
+from homeassistant.components.sensor import (
+ EntityCategory,
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ StateType,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .coordinator import SnooConfigEntry
+from .entity import SnooDescriptionEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class SnooSensorEntityDescription(SensorEntityDescription):
+ """Describes a Snoo sensor."""
+
+ value_fn: Callable[[SnooData], StateType]
+
+
+SENSOR_DESCRIPTIONS: list[SnooSensorEntityDescription] = [
+ SnooSensorEntityDescription(
+ key="state",
+ translation_key="state",
+ value_fn=lambda data: data.state_machine.state.name,
+ device_class=SensorDeviceClass.ENUM,
+ options=[e.name for e in SnooStates],
+ ),
+ SnooSensorEntityDescription(
+ key="time_left",
+ translation_key="time_left",
+ value_fn=lambda data: data.state_machine.time_left_timestamp,
+ device_class=SensorDeviceClass.TIMESTAMP,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SnooConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Snoo device."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ SnooSensor(coordinator, description)
+ for coordinator in coordinators.values()
+ for description in SENSOR_DESCRIPTIONS
+ )
+
+
+class SnooSensor(SnooDescriptionEntity, SensorEntity):
+ """A sensor using Snoo coordinator."""
+
+ entity_description: SnooSensorEntityDescription
+
+ @property
+ def native_value(self) -> StateType:
+ """Return the value reported by the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/snoo/strings.json b/homeassistant/components/snoo/strings.json
new file mode 100644
index 00000000000..1c86c066c7f
--- /dev/null
+++ b/homeassistant/components/snoo/strings.json
@@ -0,0 +1,105 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "username": "Your Snoo username or email",
+ "password": "Your Snoo password"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ },
+ "exceptions": {
+ "select_failed": {
+ "message": "Error while updating {name} to {option}"
+ },
+ "switch_on_failed": {
+ "message": "Turning {name} on failed"
+ },
+ "switch_off_failed": {
+ "message": "Turning {name} off failed"
+ }
+ },
+ "entity": {
+ "binary_sensor": {
+ "left_clip": {
+ "name": "Left safety clip"
+ },
+ "right_clip": {
+ "name": "Right safety clip"
+ }
+ },
+ "event": {
+ "event": {
+ "name": "Snoo event",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "timer": "Timer",
+ "cry": "Cry",
+ "command": "Command sent",
+ "safety_clip": "Safety clip changed",
+ "long_activity_press": "Long activity press",
+ "activity": "Activity press",
+ "power": "Power button pressed",
+ "status_requested": "Status requested",
+ "sticky_white_noise_updated": "Sleepytime sounds updated",
+ "config_change": "Config changed"
+ }
+ }
+ }
+ }
+ },
+ "sensor": {
+ "state": {
+ "name": "State",
+ "state": {
+ "baseline": "Baseline",
+ "level1": "Level 1",
+ "level2": "Level 2",
+ "level3": "Level 3",
+ "level4": "Level 4",
+ "stop": "[%key:common::state::stopped%]",
+ "pretimeout": "Pre-timeout",
+ "timeout": "Timeout"
+ }
+ },
+ "time_left": {
+ "name": "Time left"
+ }
+ },
+ "select": {
+ "intensity": {
+ "name": "Intensity",
+ "state": {
+ "baseline": "[%key:component::snoo::entity::sensor::state::state::baseline%]",
+ "level1": "[%key:component::snoo::entity::sensor::state::state::level1%]",
+ "level2": "[%key:component::snoo::entity::sensor::state::state::level2%]",
+ "level3": "[%key:component::snoo::entity::sensor::state::state::level3%]",
+ "level4": "[%key:component::snoo::entity::sensor::state::state::level4%]",
+ "stop": "[%key:common::state::stopped%]"
+ }
+ }
+ },
+ "switch": {
+ "sticky_white_noise": {
+ "name": "Sleepytime sounds"
+ },
+ "hold": {
+ "name": "Level lock"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/snoo/switch.py b/homeassistant/components/snoo/switch.py
new file mode 100644
index 00000000000..2ed322d5f6b
--- /dev/null
+++ b/homeassistant/components/snoo/switch.py
@@ -0,0 +1,105 @@
+"""Support for Snoo Switches."""
+
+from __future__ import annotations
+
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Any
+
+from python_snoo.containers import SnooData, SnooDevice
+from python_snoo.exceptions import SnooCommandException
+from python_snoo.snoo import Snoo
+
+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 .const import DOMAIN
+from .coordinator import SnooConfigEntry
+from .entity import SnooDescriptionEntity
+
+
+@dataclass(frozen=True, kw_only=True)
+class SnooSwitchEntityDescription(SwitchEntityDescription):
+ """Describes a Snoo sensor."""
+
+ value_fn: Callable[[SnooData], bool]
+ set_value_fn: Callable[[Snoo, SnooDevice, SnooData, bool], Awaitable[None]]
+
+
+BINARY_SENSOR_DESCRIPTIONS: list[SnooSwitchEntityDescription] = [
+ SnooSwitchEntityDescription(
+ key="sticky_white_noise",
+ translation_key="sticky_white_noise",
+ value_fn=lambda data: data.state_machine.sticky_white_noise == "on",
+ set_value_fn=lambda snoo_api, device, _, state: snoo_api.set_sticky_white_noise(
+ device, state
+ ),
+ ),
+ SnooSwitchEntityDescription(
+ key="hold",
+ translation_key="hold",
+ value_fn=lambda data: data.state_machine.hold == "on",
+ set_value_fn=lambda snoo_api, device, data, state: snoo_api.set_level(
+ device, data.state_machine.level, state
+ ),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SnooConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Snoo device."""
+ coordinators = entry.runtime_data
+ async_add_entities(
+ SnooSwitch(coordinator, description)
+ for coordinator in coordinators.values()
+ for description in BINARY_SENSOR_DESCRIPTIONS
+ )
+
+
+class SnooSwitch(SnooDescriptionEntity, SwitchEntity):
+ """A switch using Snoo coordinator."""
+
+ entity_description: SnooSwitchEntityDescription
+
+ @property
+ def is_on(self) -> bool | None:
+ """Return True if entity is on."""
+ return self.entity_description.value_fn(self.coordinator.data)
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ try:
+ await self.entity_description.set_value_fn(
+ self.coordinator.snoo,
+ self.coordinator.device,
+ self.coordinator.data,
+ True,
+ )
+ except SnooCommandException as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="switch_on_failed",
+ translation_placeholders={"name": str(self.name), "status": "on"},
+ ) from err
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ try:
+ await self.entity_description.set_value_fn(
+ self.coordinator.snoo,
+ self.coordinator.device,
+ self.coordinator.data,
+ False,
+ )
+ except SnooCommandException as err:
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="switch_off_failed",
+ translation_placeholders={"name": str(self.name), "status": "off"},
+ ) from err
diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py
index bfe773b4780..ce804450cab 100644
--- a/homeassistant/components/snooz/fan.py
+++ b/homeassistant/components/snooz/fan.py
@@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
@@ -38,7 +38,9 @@ from .models import SnoozConfigurationData
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Snooz device from a config entry."""
diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json
index 94ca434e589..ca252b2117c 100644
--- a/homeassistant/components/snooz/strings.json
+++ b/homeassistant/components/snooz/strings.json
@@ -27,25 +27,25 @@
"services": {
"transition_on": {
"name": "Transition on",
- "description": "Transitions to a target volume level over time.",
+ "description": "Transitions the volume level over a specified duration. If the device is powered off, the transition will start at the lowest volume level.",
"fields": {
"duration": {
"name": "Transition duration",
- "description": "Time it takes to reach the target volume level."
+ "description": "Time to transition to the target volume."
},
"volume": {
"name": "Target volume",
- "description": "If not specified, the volume level is read from the device."
+ "description": "Relative volume level. If not specified, the setting on the device is used."
}
}
},
"transition_off": {
"name": "Transition off",
- "description": "Transitions volume off over time.",
+ "description": "Transitions the volume level to the lowest setting over a specified duration, then powers off the device.",
"fields": {
"duration": {
"name": "[%key:component::snooz::services::transition_on::fields::duration::name%]",
- "description": "Time it takes to turn off."
+ "description": "Time to complete the transition."
}
}
}
diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py
index 004335b644b..acb86f875c9 100644
--- a/homeassistant/components/solaredge/sensor.py
+++ b/homeassistant/components/solaredge/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -201,7 +201,7 @@ SENSOR_TYPES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: SolarEdgeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add an solarEdge entry."""
# Add the needed sensors to hass
diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json
index 2b626987546..105a9282a6d 100644
--- a/homeassistant/components/solaredge/strings.json
+++ b/homeassistant/components/solaredge/strings.json
@@ -5,7 +5,7 @@
"title": "Define the API parameters for this installation",
"data": {
"name": "The name of this installation",
- "site_id": "The SolarEdge site-id",
+ "site_id": "The SolarEdge site ID",
"api_key": "[%key:common::config_flow::data::api_key%]"
}
}
@@ -14,7 +14,7 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
"site_not_active": "The site is not active",
- "could_not_connect": "Could not connect to the solaredge API"
+ "could_not_connect": "Could not connect to the SolarEdge API"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
@@ -65,7 +65,7 @@
"name": "Grid power"
},
"storage_power": {
- "name": "Stored power"
+ "name": "Storage power"
},
"purchased_energy": {
"name": "Imported energy"
diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py
index 6292b1332d7..48ebeece1ba 100644
--- a/homeassistant/components/solarlog/coordinator.py
+++ b/homeassistant/components/solarlog/coordinator.py
@@ -75,7 +75,7 @@ class SolarLogCoordinator(DataUpdateCoordinator[SolarlogData]):
await self.solarlog.test_extended_data_available()
if logged_in or await self.solarlog.test_extended_data_available():
device_list = await self.solarlog.update_device_list()
- self.solarlog.set_enabled_devices({key: True for key in device_list})
+ self.solarlog.set_enabled_devices(dict.fromkeys(device_list, True))
async def _async_update_data(self) -> SolarlogData:
"""Update the data from the SolarLog device."""
diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py
index 8fd6e3c0194..c4bb119c006 100644
--- a/homeassistant/components/solarlog/sensor.py
+++ b/homeassistant/components/solarlog/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import SolarlogConfigEntry
@@ -276,7 +276,7 @@ INVERTER_SENSOR_TYPES: tuple[SolarLogInverterSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: SolarlogConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add solarlog entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py
index 6ca0bac0c38..1cdec0389fe 100644
--- a/homeassistant/components/solax/sensor.py
+++ b/homeassistant/components/solax/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SolaxConfigEntry
@@ -89,7 +89,7 @@ SENSOR_DESCRIPTIONS: dict[tuple[Units, bool], SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: SolaxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Entry setup."""
api = entry.runtime_data.api
diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py
index e64fee00f16..15aa21b1f48 100644
--- a/homeassistant/components/soma/cover.py
+++ b/homeassistant/components/soma/cover.py
@@ -14,7 +14,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import API, DEVICES, DOMAIN
from .entity import SomaEntity
@@ -24,7 +24,7 @@ from .utils import is_api_response_success
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Soma cover platform."""
diff --git a/homeassistant/components/soma/entity.py b/homeassistant/components/soma/entity.py
index f9824d107b1..4b2fcee5405 100644
--- a/homeassistant/components/soma/entity.py
+++ b/homeassistant/components/soma/entity.py
@@ -71,7 +71,7 @@ class SomaEntity(Entity):
self.api_is_available = True
@property
- def available(self):
+ def available(self) -> bool:
"""Return true if the last API commands returned successfully."""
return self.is_available
diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py
index 806886009f3..839f28e9a65 100644
--- a/homeassistant/components/soma/sensor.py
+++ b/homeassistant/components/soma/sensor.py
@@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from .const import API, DEVICES, DOMAIN
@@ -18,7 +18,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Soma sensor platform."""
diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py
index 8c64e58362b..5b888ea4b96 100644
--- a/homeassistant/components/somfy_mylink/cover.py
+++ b/homeassistant/components/somfy_mylink/cover.py
@@ -7,7 +7,7 @@ from homeassistant.components.cover import CoverDeviceClass, CoverEntity, CoverS
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
@@ -29,7 +29,7 @@ MYLINK_COVER_TYPE_TO_DEVICE_CLASS = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Discover and configure Somfy covers."""
reversed_target_ids = config_entry.options.get(CONF_REVERSED_TARGET_IDS, {})
diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py
index fa7d0aa7756..983ac76d93e 100644
--- a/homeassistant/components/sonarr/sensor.py
+++ b/homeassistant/components/sonarr/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfInformation
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
@@ -90,7 +90,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = {
"commands": SonarrSensorEntityDescription[list[Command]](
key="commands",
translation_key="commands",
- native_unit_of_measurement="Commands",
entity_registry_enabled_default=False,
value_fn=len,
attributes_fn=lambda data: {c.name: c.status for c in data},
@@ -107,7 +106,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = {
"queue": SonarrSensorEntityDescription[SonarrQueue](
key="queue",
translation_key="queue",
- native_unit_of_measurement="Episodes",
entity_registry_enabled_default=False,
value_fn=lambda data: data.totalRecords,
attributes_fn=get_queue_attr,
@@ -115,7 +113,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = {
"series": SonarrSensorEntityDescription[list[SonarrSeries]](
key="series",
translation_key="series",
- native_unit_of_measurement="Series",
entity_registry_enabled_default=False,
value_fn=len,
attributes_fn=lambda data: {
@@ -129,7 +126,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = {
"upcoming": SonarrSensorEntityDescription[list[SonarrCalendar]](
key="upcoming",
translation_key="upcoming",
- native_unit_of_measurement="Episodes",
value_fn=len,
attributes_fn=lambda data: {
e.series.title: f"S{e.seasonNumber:02d}E{e.episodeNumber:02d}" for e in data
@@ -138,7 +134,6 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = {
"wanted": SonarrSensorEntityDescription[SonarrWantedMissing](
key="wanted",
translation_key="wanted",
- native_unit_of_measurement="Episodes",
entity_registry_enabled_default=False,
value_fn=lambda data: data.totalRecords,
attributes_fn=get_wanted_attr,
@@ -149,7 +144,7 @@ SENSOR_TYPES: dict[str, SonarrSensorEntityDescription[Any]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sonarr sensors based on a config entry."""
coordinators: dict[str, SonarrDataUpdateCoordinator[Any]] = hass.data[DOMAIN][
diff --git a/homeassistant/components/sonarr/strings.json b/homeassistant/components/sonarr/strings.json
index 5b17f3283e8..940ec650270 100644
--- a/homeassistant/components/sonarr/strings.json
+++ b/homeassistant/components/sonarr/strings.json
@@ -37,22 +37,27 @@
"entity": {
"sensor": {
"commands": {
- "name": "Commands"
+ "name": "Commands",
+ "unit_of_measurement": "commands"
},
"diskspace": {
"name": "Disk space"
},
"queue": {
- "name": "Queue"
+ "name": "Queue",
+ "unit_of_measurement": "episodes"
},
"series": {
- "name": "Shows"
+ "name": "Shows",
+ "unit_of_measurement": "series"
},
"upcoming": {
- "name": "Upcoming"
+ "name": "Upcoming",
+ "unit_of_measurement": "[%key:component::sonarr::entity::sensor::queue::unit_of_measurement%]"
},
"wanted": {
- "name": "Wanted"
+ "name": "Wanted",
+ "unit_of_measurement": "[%key:component::sonarr::entity::sensor::queue::unit_of_measurement%]"
}
}
}
diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py
index b4063b09691..3fc75d712a7 100644
--- a/homeassistant/components/songpal/media_player.py
+++ b/homeassistant/components/songpal/media_player.py
@@ -34,7 +34,10 @@ from homeassistant.helpers import (
entity_platform,
)
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_ENDPOINT, DOMAIN, ERROR_REQUEST_RETRY, SET_SOUND_SETTING
@@ -63,7 +66,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up songpal media player."""
name = config_entry.data[CONF_NAME]
diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py
index d530fa21e39..24580971ae2 100644
--- a/homeassistant/components/sonos/__init__.py
+++ b/homeassistant/components/sonos/__init__.py
@@ -7,6 +7,7 @@ from collections import OrderedDict
from dataclasses import dataclass, field
import datetime
from functools import partial
+from ipaddress import AddressValueError, IPv4Address
import logging
import socket
from typing import Any, cast
@@ -208,6 +209,14 @@ class SonosDiscoveryManager:
async def async_subscribe_to_zone_updates(self, ip_address: str) -> None:
"""Test subscriptions and create SonosSpeakers based on results."""
+ try:
+ _ = IPv4Address(ip_address)
+ except AddressValueError:
+ _LOGGER.debug(
+ "Sonos integration only supports IPv4 addresses, invalid ip_address received: %s",
+ ip_address,
+ )
+ return
soco = SoCo(ip_address)
# Cache now to avoid household ID lookup during first ZoneGroupState processing
await self.hass.async_add_executor_job(
diff --git a/homeassistant/components/sonos/binary_sensor.py b/homeassistant/components/sonos/binary_sensor.py
index 2c1e8af9961..322beaed092 100644
--- a/homeassistant/components/sonos/binary_sensor.py
+++ b/homeassistant/components/sonos/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SONOS_CREATE_BATTERY, SONOS_CREATE_MIC_SENSOR
from .entity import SonosEntity
@@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sonos from a config entry."""
diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py
index 057cdb8ec08..b5e2c684281 100644
--- a/homeassistant/components/sonos/config_flow.py
+++ b/homeassistant/components/sonos/config_flow.py
@@ -31,6 +31,8 @@ class SonosDiscoveryFlowHandler(DiscoveryFlowHandler[Awaitable[bool]], domain=DO
hostname = discovery_info.hostname
if hostname is None or not hostname.lower().startswith("sonos"):
return self.async_abort(reason="not_sonos_device")
+ if discovery_info.ip_address.version != 4:
+ return self.async_abort(reason="not_ipv4_address")
if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER):
host = discovery_info.host
mdns_name = discovery_info.name
diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py
index 610a68afedf..cda40729dbc 100644
--- a/homeassistant/components/sonos/const.py
+++ b/homeassistant/components/sonos/const.py
@@ -32,6 +32,7 @@ SONOS_TRACKS = "tracks"
SONOS_COMPOSER = "composers"
SONOS_RADIO = "radio"
SONOS_OTHER_ITEM = "other items"
+SONOS_AUDIO_BOOK = "audio book"
SONOS_STATE_PLAYING = "PLAYING"
SONOS_STATE_TRANSITIONING = "TRANSITIONING"
@@ -67,6 +68,7 @@ SONOS_TO_MEDIA_CLASSES = {
"object.item": MediaClass.TRACK,
"object.item.audioItem.musicTrack": MediaClass.TRACK,
"object.item.audioItem.audioBroadcast": MediaClass.GENRE,
+ "object.item.audioItem.audioBook": MediaClass.TRACK,
}
SONOS_TO_MEDIA_TYPES = {
@@ -84,6 +86,7 @@ SONOS_TO_MEDIA_TYPES = {
"object.container.playlistContainer.sameArtist": MediaType.ARTIST,
"object.container.playlistContainer": MediaType.PLAYLIST,
"object.item.audioItem.musicTrack": MediaType.TRACK,
+ "object.item.audioItem.audioBook": MediaType.TRACK,
}
MEDIA_TYPES_TO_SONOS: dict[MediaType | str, str] = {
@@ -113,6 +116,7 @@ SONOS_TYPES_MAPPING = {
"object.item": SONOS_OTHER_ITEM,
"object.item.audioItem.musicTrack": SONOS_TRACKS,
"object.item.audioItem.audioBroadcast": SONOS_RADIO,
+ "object.item.audioItem.audioBook": SONOS_AUDIO_BOOK,
}
LIBRARY_TITLES_MAPPING = {
@@ -170,6 +174,7 @@ MODELS_TV_ONLY = (
"BEAM",
"PLAYBAR",
"PLAYBASE",
+ "ULTRA",
)
MODELS_LINEIN_AND_TV = ("AMP",)
diff --git a/homeassistant/components/sonos/favorites.py b/homeassistant/components/sonos/favorites.py
index 5050555a7cb..333c4809e62 100644
--- a/homeassistant/components/sonos/favorites.py
+++ b/homeassistant/components/sonos/favorites.py
@@ -105,7 +105,7 @@ class SonosFavorites(SonosHouseholdCoordinator):
@soco_error()
def update_cache(self, soco: SoCo, update_id: int | None = None) -> bool:
"""Update cache of known favorites and return if cache has changed."""
- new_favorites = soco.music_library.get_sonos_favorites()
+ new_favorites = soco.music_library.get_sonos_favorites(full_album_art_uri=True)
# Polled update_id values do not match event_id values
# Each speaker can return a different polled update_id
diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json
index bfdf0da9dbb..5bbfc33ae5b 100644
--- a/homeassistant/components/sonos/manifest.json
+++ b/homeassistant/components/sonos/manifest.json
@@ -7,8 +7,8 @@
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/sonos",
"iot_class": "local_push",
- "loggers": ["soco"],
- "requirements": ["soco==0.30.8", "sonos-websocket==0.1.3"],
+ "loggers": ["soco", "sonos_websocket"],
+ "requirements": ["soco==0.30.9", "sonos-websocket==0.1.3"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py
index 995d6cea08c..16b425dae50 100644
--- a/homeassistant/components/sonos/media_browser.py
+++ b/homeassistant/components/sonos/media_browser.py
@@ -165,6 +165,8 @@ async def async_browse_media(
favorites_folder_payload,
speaker.favorites,
media_content_id,
+ media,
+ get_browse_image_url,
)
payload = {
@@ -443,7 +445,10 @@ def favorites_payload(favorites: SonosFavorites) -> BrowseMedia:
def favorites_folder_payload(
- favorites: SonosFavorites, media_content_id: str
+ favorites: SonosFavorites,
+ media_content_id: str,
+ media: SonosMedia,
+ get_browse_image_url: GetBrowseImageUrlType,
) -> BrowseMedia:
"""Create response payload to describe all items of a type of favorite.
@@ -463,7 +468,14 @@ def favorites_folder_payload(
media_content_type="favorite_item_id",
can_play=True,
can_expand=False,
- thumbnail=getattr(favorite, "album_art_uri", None),
+ thumbnail=get_thumbnail_url_full(
+ media=media,
+ is_internal=True,
+ media_content_type="favorite_item_id",
+ media_content_id=favorite.item_id,
+ get_browse_image_url=get_browse_image_url,
+ item=favorite,
+ ),
)
)
diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py
index 8d0917c5dba..a774de0ae5b 100644
--- a/homeassistant/components/sonos/media_player.py
+++ b/homeassistant/components/sonos/media_player.py
@@ -46,7 +46,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, cal
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform, service
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import UnjoinData, media_browser
@@ -108,7 +108,7 @@ ATTR_QUEUE_POSITION = "queue_position"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sonos from a config entry."""
platform = entity_platform.async_get_current_platform()
@@ -462,11 +462,20 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
"""Play a favorite."""
uri = favorite.reference.get_uri()
soco = self.coordinator.soco
- if soco.music_source_from_uri(uri) in [
- MUSIC_SRC_RADIO,
- MUSIC_SRC_LINE_IN,
- ]:
- soco.play_uri(uri, title=favorite.title, timeout=LONG_SERVICE_TIMEOUT)
+ if (
+ soco.music_source_from_uri(uri)
+ in [
+ MUSIC_SRC_RADIO,
+ MUSIC_SRC_LINE_IN,
+ ]
+ or favorite.reference.item_class == "object.item.audioItem.audioBook"
+ ):
+ soco.play_uri(
+ uri,
+ title=favorite.title,
+ meta=favorite.resource_meta_data,
+ timeout=LONG_SERVICE_TIMEOUT,
+ )
else:
soco.clear_queue()
soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT)
diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py
index 272218cc01e..c23ba51a877 100644
--- a/homeassistant/components/sonos/number.py
+++ b/homeassistant/components/sonos/number.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SONOS_CREATE_LEVELS
from .entity import SonosEntity
@@ -70,7 +70,7 @@ LEVEL_FROM_NUMBER = {"balance": _balance_from_number}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Sonos number platform from a config entry."""
diff --git a/homeassistant/components/sonos/sensor.py b/homeassistant/components/sonos/sensor.py
index a089c09b33c..d888ee669bb 100644
--- a/homeassistant/components/sonos/sensor.py
+++ b/homeassistant/components/sonos/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
SONOS_CREATE_AUDIO_FORMAT_SENSOR,
@@ -29,7 +29,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sonos from a config entry."""
diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json
index 07d2e2db4e0..433bb3cc36a 100644
--- a/homeassistant/components/sonos/strings.json
+++ b/homeassistant/components/sonos/strings.json
@@ -8,7 +8,8 @@
"abort": {
"not_sonos_device": "Discovered device is not a Sonos device",
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
- "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
+ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
+ "not_ipv4_address": "No IPv4 address in SSDP discovery information"
}
},
"issues": {
diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py
index 4bf5487b1a6..ce4774a4138 100644
--- a/homeassistant/components/sonos/switch.py
+++ b/homeassistant/components/sonos/switch.py
@@ -15,7 +15,7 @@ from homeassistant.const import ATTR_TIME, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_change
from .const import (
@@ -74,7 +74,7 @@ WEEKEND_DAYS = (0, 6)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sonos from a config entry."""
diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py
index 5edd42b931a..c540b8dfd64 100644
--- a/homeassistant/components/soundtouch/media_player.py
+++ b/homeassistant/components/soundtouch/media_player.py
@@ -27,7 +27,7 @@ from homeassistant.helpers.device_registry import (
DeviceInfo,
format_mac,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -47,7 +47,7 @@ ATTR_SOUNDTOUCH_ZONE = "soundtouch_zone"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Bose SoundTouch media player based on a config entry."""
device = hass.data[DOMAIN][entry.entry_id].device
diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py
index 4363be5cf93..c2b7a6de28c 100644
--- a/homeassistant/components/speedtestdotnet/sensor.py
+++ b/homeassistant/components/speedtestdotnet/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import UnitOfDataRate, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -69,7 +69,7 @@ SENSOR_TYPES: tuple[SpeedtestSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SpeedTestConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Speedtestdotnet sensors."""
speedtest_coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/spider/__init__.py b/homeassistant/components/spider/__init__.py
index 4b138ec77a8..c0d85c02dd4 100644
--- a/homeassistant/components/spider/__init__.py
+++ b/homeassistant/components/spider/__init__.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
@@ -29,11 +29,13 @@ async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- if all(
- config_entry.state is ConfigEntryState.NOT_LOADED
- for config_entry in hass.config_entries.async_entries(DOMAIN)
- if config_entry.entry_id != entry.entry_id
- ):
- ir.async_delete_issue(hass, DOMAIN, DOMAIN)
-
return True
+
+
+async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Remove a config entry."""
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
+ ir.async_delete_issue(hass, DOMAIN, DOMAIN)
+ # Remove any remaining disabled or ignored entries
+ for _entry in hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
diff --git a/homeassistant/components/spotify/browse_media.py b/homeassistant/components/spotify/browse_media.py
index 458525dde28..686431da249 100644
--- a/homeassistant/components/spotify/browse_media.py
+++ b/homeassistant/components/spotify/browse_media.py
@@ -226,17 +226,17 @@ async def async_browse_media(
if media_content_id is None or not media_content_id.startswith(MEDIA_PLAYER_PREFIX):
raise BrowseError("Invalid Spotify URL specified")
- # Check for config entry specifier, and extract Spotify URI
+ # The config entry id is the host name of the URL, the Spotify URI is the name
parsed_url = yarl.URL(media_content_id)
- host = parsed_url.host
+ config_entry_id = parsed_url.host
if (
- host is None
+ config_entry_id is None
# config entry ids can be upper or lower case. Yarl always returns host
# names in lower case, so we need to look for the config entry in both
or (
- entry := hass.config_entries.async_get_entry(host)
- or hass.config_entries.async_get_entry(host.upper())
+ entry := hass.config_entries.async_get_entry(config_entry_id)
+ or hass.config_entries.async_get_entry(config_entry_id.upper())
)
is None
or entry.state is not ConfigEntryState.LOADED
diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py
index d99fa7793df..3478887d64c 100644
--- a/homeassistant/components/spotify/config_flow.py
+++ b/homeassistant/components/spotify/config_flow.py
@@ -41,7 +41,8 @@ class SpotifyFlowHandler(
try:
current_user = await spotify.get_current_user()
- except Exception: # noqa: BLE001
+ except Exception:
+ self.logger.exception("Error while connecting to Spotify")
return self.async_abort(reason="connection_error")
name = current_user.display_name
diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py
index 20a634efb42..d6265cbc39d 100644
--- a/homeassistant/components/spotify/media_player.py
+++ b/homeassistant/components/spotify/media_player.py
@@ -31,7 +31,7 @@ from homeassistant.components.media_player import (
RepeatMode,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .browse_media import async_browse_media_internal
@@ -70,7 +70,7 @@ AFTER_REQUEST_SLEEP = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: SpotifyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Spotify based on a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json
index 90e573a1706..66d837c503f 100644
--- a/homeassistant/components/spotify/strings.json
+++ b/homeassistant/components/spotify/strings.json
@@ -13,7 +13,7 @@
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "The Spotify integration is not configured. Please follow the documentation.",
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
- "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication.",
+ "reauth_account_mismatch": "The Spotify account authenticated with does not match the account that needed re-authentication.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json
index c18b1b9f05f..e6a45390120 100644
--- a/homeassistant/components/sql/manifest.json
+++ b/homeassistant/components/sql/manifest.json
@@ -1,9 +1,10 @@
{
"domain": "sql",
"name": "SQL",
+ "after_dependencies": ["recorder"],
"codeowners": ["@gjohansson-ST", "@dougiteixeira"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sql",
"iot_class": "local_polling",
- "requirements": ["SQLAlchemy==2.0.38", "sqlparse==0.5.0"]
+ "requirements": ["SQLAlchemy==2.0.40", "sqlparse==0.5.0"]
}
diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py
index 312b0cd345e..a7b488dd521 100644
--- a/homeassistant/components/sql/sensor.py
+++ b/homeassistant/components/sql/sensor.py
@@ -36,7 +36,10 @@ from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.template import Template
from homeassistant.helpers.trigger_template_entity import (
CONF_AVAILABILITY,
@@ -101,7 +104,9 @@ async def async_setup_platform(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SQL sensor from config entry."""
@@ -178,7 +183,7 @@ async def async_setup_sensor(
unique_id: str | None,
db_url: str,
yaml: bool,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SQL sensor."""
try:
diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py
index 789f6ddb3a8..78a97e38833 100644
--- a/homeassistant/components/squeezebox/__init__.py
+++ b/homeassistant/components/squeezebox/__init__.py
@@ -53,6 +53,7 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [
Platform.BINARY_SENSOR,
+ Platform.BUTTON,
Platform.MEDIA_PLAYER,
Platform.SENSOR,
]
@@ -129,10 +130,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -
server_coordinator = LMSStatusDataUpdateCoordinator(hass, entry, lms)
- entry.runtime_data = SqueezeboxData(
- coordinator=server_coordinator,
- server=lms,
- )
+ entry.runtime_data = SqueezeboxData(coordinator=server_coordinator, server=lms)
# set up player discovery
known_servers = hass.data.setdefault(DOMAIN, {}).setdefault(KNOWN_SERVERS, {})
diff --git a/homeassistant/components/squeezebox/binary_sensor.py b/homeassistant/components/squeezebox/binary_sensor.py
index ec0bac0fe43..daae8703597 100644
--- a/homeassistant/components/squeezebox/binary_sensor.py
+++ b/homeassistant/components/squeezebox/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SqueezeboxConfigEntry
from .const import STATUS_SENSOR_NEEDSRESTART, STATUS_SENSOR_RESCAN
@@ -35,7 +35,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: SqueezeboxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Platform setup using common elements."""
diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py
index 331bf383c70..3f4af99fffd 100644
--- a/homeassistant/components/squeezebox/browse_media.py
+++ b/homeassistant/components/squeezebox/browse_media.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import contextlib
+from dataclasses import dataclass, field
from typing import Any
from pysqueezebox import Player
@@ -18,74 +19,216 @@ from homeassistant.components.media_player import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.network import is_internal_request
+from .const import UNPLAYABLE_TYPES
+
LIBRARY = [
- "Favorites",
- "Artists",
- "Albums",
- "Tracks",
- "Playlists",
- "Genres",
- "New Music",
+ "favorites",
+ "artists",
+ "albums",
+ "tracks",
+ "playlists",
+ "genres",
+ "new music",
+ "album artists",
+ "apps",
+ "radios",
]
-MEDIA_TYPE_TO_SQUEEZEBOX = {
- "Favorites": "favorites",
- "Artists": "artists",
- "Albums": "albums",
- "Tracks": "titles",
- "Playlists": "playlists",
- "Genres": "genres",
- "New Music": "new music",
+MEDIA_TYPE_TO_SQUEEZEBOX: dict[str | MediaType, str] = {
+ "favorites": "favorites",
+ "artists": "artists",
+ "albums": "albums",
+ "tracks": "titles",
+ "playlists": "playlists",
+ "genres": "genres",
+ "new music": "new music",
+ "album artists": "album artists",
MediaType.ALBUM: "album",
MediaType.ARTIST: "artist",
MediaType.TRACK: "title",
MediaType.PLAYLIST: "playlist",
MediaType.GENRE: "genre",
+ MediaType.APPS: "apps",
+ "radios": "radios",
}
-SQUEEZEBOX_ID_BY_TYPE = {
+SQUEEZEBOX_ID_BY_TYPE: dict[str | MediaType, str] = {
MediaType.ALBUM: "album_id",
MediaType.ARTIST: "artist_id",
MediaType.TRACK: "track_id",
MediaType.PLAYLIST: "playlist_id",
MediaType.GENRE: "genre_id",
- "Favorites": "item_id",
+ "favorites": "item_id",
+ MediaType.APPS: "item_id",
}
-CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | None]] = {
- "Favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
- "Artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
- "Albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
- "Tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
- "Playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST},
- "Genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE},
- "New Music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
+CONTENT_TYPE_MEDIA_CLASS: dict[str | MediaType, dict[str, MediaClass | str]] = {
+ "favorites": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
+ "radios": {"item": MediaClass.DIRECTORY, "children": MediaClass.APP},
+ "artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
+ "albums": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
+ "tracks": {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
+ "playlists": {"item": MediaClass.DIRECTORY, "children": MediaClass.PLAYLIST},
+ "genres": {"item": MediaClass.DIRECTORY, "children": MediaClass.GENRE},
+ "new music": {"item": MediaClass.DIRECTORY, "children": MediaClass.ALBUM},
+ "album artists": {"item": MediaClass.DIRECTORY, "children": MediaClass.ARTIST},
MediaType.ALBUM: {"item": MediaClass.ALBUM, "children": MediaClass.TRACK},
MediaType.ARTIST: {"item": MediaClass.ARTIST, "children": MediaClass.ALBUM},
- MediaType.TRACK: {"item": MediaClass.TRACK, "children": None},
+ MediaType.TRACK: {"item": MediaClass.TRACK, "children": ""},
MediaType.GENRE: {"item": MediaClass.GENRE, "children": MediaClass.ARTIST},
MediaType.PLAYLIST: {"item": MediaClass.PLAYLIST, "children": MediaClass.TRACK},
+ MediaType.APP: {"item": MediaClass.DIRECTORY, "children": MediaClass.TRACK},
+ MediaType.APPS: {"item": MediaClass.DIRECTORY, "children": MediaClass.APP},
}
-CONTENT_TYPE_TO_CHILD_TYPE = {
+CONTENT_TYPE_TO_CHILD_TYPE: dict[
+ str | MediaType,
+ str | MediaType | None,
+] = {
MediaType.ALBUM: MediaType.TRACK,
MediaType.PLAYLIST: MediaType.PLAYLIST,
MediaType.ARTIST: MediaType.ALBUM,
MediaType.GENRE: MediaType.ARTIST,
- "Artists": MediaType.ARTIST,
- "Albums": MediaType.ALBUM,
- "Tracks": MediaType.TRACK,
- "Playlists": MediaType.PLAYLIST,
- "Genres": MediaType.GENRE,
- "Favorites": None, # can only be determined after inspecting the item
- "New Music": MediaType.ALBUM,
+ "artists": MediaType.ARTIST,
+ "albums": MediaType.ALBUM,
+ "tracks": MediaType.TRACK,
+ "playlists": MediaType.PLAYLIST,
+ "genres": MediaType.GENRE,
+ "favorites": None, # can only be determined after inspecting the item
+ "radios": MediaClass.APP,
+ "new music": MediaType.ALBUM,
+ "album artists": MediaType.ARTIST,
+ MediaType.APPS: MediaType.APP,
+ MediaType.APP: MediaType.TRACK,
}
-BROWSE_LIMIT = 1000
+
+@dataclass
+class BrowseData:
+ """Class for browser to squeezebox mappings and other browse data."""
+
+ content_type_to_child_type: dict[
+ str | MediaType,
+ str | MediaType | None,
+ ] = field(default_factory=dict)
+ content_type_media_class: dict[str | MediaType, dict[str, MediaClass | str]] = (
+ field(default_factory=dict)
+ )
+ squeezebox_id_by_type: dict[str | MediaType, str] = field(default_factory=dict)
+ media_type_to_squeezebox: dict[str | MediaType, str] = field(default_factory=dict)
+ known_apps_radios: set[str] = field(default_factory=set)
+
+ def __post_init__(self) -> None:
+ """Initialise the maps."""
+ self.content_type_media_class.update(CONTENT_TYPE_MEDIA_CLASS)
+ self.content_type_to_child_type.update(CONTENT_TYPE_TO_CHILD_TYPE)
+ self.squeezebox_id_by_type.update(SQUEEZEBOX_ID_BY_TYPE)
+ self.media_type_to_squeezebox.update(MEDIA_TYPE_TO_SQUEEZEBOX)
+
+
+def _add_new_command_to_browse_data(
+ browse_data: BrowseData, cmd: str | MediaType, type: str
+) -> None:
+ """Add items to maps for new apps or radios."""
+ browse_data.media_type_to_squeezebox[cmd] = cmd
+ browse_data.squeezebox_id_by_type[cmd] = type
+ browse_data.content_type_media_class[cmd] = {
+ "item": MediaClass.DIRECTORY,
+ "children": MediaClass.TRACK,
+ }
+ browse_data.content_type_to_child_type[cmd] = MediaType.TRACK
+
+
+def _build_response_apps_radios_category(
+ browse_data: BrowseData, cmd: str | MediaType, item: dict[str, Any]
+) -> BrowseMedia:
+ """Build item for App or radio category."""
+ return BrowseMedia(
+ media_content_id=item["id"],
+ title=item["title"],
+ media_content_type=cmd,
+ media_class=browse_data.content_type_media_class[cmd]["item"],
+ can_expand=True,
+ can_play=False,
+ )
+
+
+def _build_response_known_app(
+ browse_data: BrowseData, search_type: str, item: dict[str, Any]
+) -> BrowseMedia:
+ """Build item for app or radio."""
+
+ return BrowseMedia(
+ media_content_id=item["id"],
+ title=item["title"],
+ media_content_type=search_type,
+ media_class=browse_data.content_type_media_class[search_type]["item"],
+ can_play=bool(item["isaudio"] and item.get("url")),
+ can_expand=item["hasitems"],
+ )
+
+
+def _build_response_favorites(item: dict[str, Any]) -> BrowseMedia:
+ """Build item for favorites."""
+ if "album_id" in item:
+ return BrowseMedia(
+ media_content_id=str(item["album_id"]),
+ title=item["title"],
+ media_content_type=MediaType.ALBUM,
+ media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM]["item"],
+ can_expand=True,
+ can_play=True,
+ )
+ if item.get("hasitems") and not item.get("isaudio"):
+ return BrowseMedia(
+ media_content_id=item["id"],
+ title=item["title"],
+ media_content_type="favorites",
+ media_class=CONTENT_TYPE_MEDIA_CLASS["favorites"]["item"],
+ can_expand=True,
+ can_play=False,
+ )
+ return BrowseMedia(
+ media_content_id=item["id"],
+ title=item["title"],
+ media_content_type="favorites",
+ media_class=CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]["item"],
+ can_expand=bool(item.get("hasitems")),
+ can_play=bool(item["isaudio"] and item.get("url")),
+ )
+
+
+def _get_item_thumbnail(
+ item: dict[str, Any],
+ player: Player,
+ entity: MediaPlayerEntity,
+ item_type: str | MediaType | None,
+ search_type: str,
+ internal_request: bool,
+) -> str | None:
+ """Construct path to thumbnail image."""
+ item_thumbnail: str | None = None
+ if artwork_track_id := item.get("artwork_track_id"):
+ if internal_request:
+ item_thumbnail = player.generate_image_url_from_track_id(artwork_track_id)
+ elif item_type is not None:
+ item_thumbnail = entity.get_browse_image_url(
+ item_type, item["id"], artwork_track_id
+ )
+
+ elif search_type in ["apps", "radios"]:
+ item_thumbnail = player.generate_image_url(item["icon"])
+ if item_thumbnail is None:
+ item_thumbnail = item.get("image_url") # will not be proxied by HA
+ return item_thumbnail
async def build_item_response(
- entity: MediaPlayerEntity, player: Player, payload: dict[str, str | None]
+ entity: MediaPlayerEntity,
+ player: Player,
+ payload: dict[str, str | None],
+ browse_limit: int,
+ browse_data: BrowseData,
) -> BrowseMedia:
"""Create response payload for search described by payload."""
@@ -96,78 +239,80 @@ async def build_item_response(
assert (
search_type is not None
) # async_browse_media will not call this function if search_type is None
- media_class = CONTENT_TYPE_MEDIA_CLASS[search_type]
+ media_class = browse_data.content_type_media_class[search_type]
children = None
if search_id and search_id != search_type:
- browse_id = (SQUEEZEBOX_ID_BY_TYPE[search_type], search_id)
+ browse_id = (browse_data.squeezebox_id_by_type[search_type], search_id)
else:
browse_id = None
result = await player.async_browse(
- MEDIA_TYPE_TO_SQUEEZEBOX[search_type],
- limit=BROWSE_LIMIT,
+ browse_data.media_type_to_squeezebox[search_type],
+ limit=browse_limit,
browse_id=browse_id,
)
if result is not None and result.get("items"):
- item_type = CONTENT_TYPE_TO_CHILD_TYPE[search_type]
+ item_type = browse_data.content_type_to_child_type[search_type]
children = []
- list_playable = []
for item in result["items"]:
- item_id = str(item["id"])
- item_thumbnail: str | None = None
- if item_type:
- child_item_type: MediaType | str = item_type
- child_media_class = CONTENT_TYPE_MEDIA_CLASS[item_type]
- can_expand = child_media_class["children"] is not None
- can_play = True
+ # Force the item id to a string in case it's numeric from some lms
+ item["id"] = str(item.get("id", ""))
+ if search_type == "favorites":
+ child_media = _build_response_favorites(item)
- if search_type == "Favorites":
- if "album_id" in item:
- item_id = str(item["album_id"])
- child_item_type = MediaType.ALBUM
- child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.ALBUM]
- can_expand = True
- can_play = True
- elif item["hasitems"] and not item["isaudio"]:
- child_item_type = "Favorites"
- child_media_class = CONTENT_TYPE_MEDIA_CLASS["Favorites"]
- can_expand = True
- can_play = False
- else:
- child_item_type = "Favorites"
- child_media_class = CONTENT_TYPE_MEDIA_CLASS[MediaType.TRACK]
- can_expand = item["hasitems"]
- can_play = item["isaudio"] and item.get("url")
+ elif search_type in ["apps", "radios"]:
+ # item["cmd"] contains the name of the command to use with the cli for the app
+ # add the command to the dictionaries
+ if item["title"] == "Search" or item.get("type") in UNPLAYABLE_TYPES:
+ # Skip searches in apps as they'd need UI or if the link isn't to audio
+ continue
+ app_cmd = "app-" + item["cmd"]
- if artwork_track_id := item.get("artwork_track_id"):
- if internal_request:
- item_thumbnail = player.generate_image_url_from_track_id(
- artwork_track_id
- )
- elif item_type is not None:
- item_thumbnail = entity.get_browse_image_url(
- item_type, item_id, artwork_track_id
- )
- else:
- item_thumbnail = item.get("image_url") # will not be proxied by HA
+ if app_cmd not in browse_data.known_apps_radios:
+ browse_data.known_apps_radios.add(app_cmd)
+ _add_new_command_to_browse_data(browse_data, app_cmd, "item_id")
- assert child_media_class["item"] is not None
- children.append(
- BrowseMedia(
- title=item["title"],
- media_class=child_media_class["item"],
- media_content_id=item_id,
- media_content_type=child_item_type,
- can_play=can_play,
- can_expand=can_expand,
- thumbnail=item_thumbnail,
+ child_media = _build_response_apps_radios_category(
+ browse_data=browse_data, cmd=app_cmd, item=item
)
+
+ elif search_type in browse_data.known_apps_radios:
+ if (
+ item.get("title") in ["Search", None]
+ or item.get("type") in UNPLAYABLE_TYPES
+ ):
+ # Skip searches in apps as they'd need UI
+ continue
+
+ child_media = _build_response_known_app(browse_data, search_type, item)
+
+ elif item_type:
+ child_media = BrowseMedia(
+ media_content_id=item["id"],
+ title=item["title"],
+ media_content_type=item_type,
+ media_class=CONTENT_TYPE_MEDIA_CLASS[item_type]["item"],
+ can_expand=CONTENT_TYPE_MEDIA_CLASS[item_type]["children"]
+ is not None,
+ can_play=True,
+ )
+
+ assert child_media.media_class is not None
+
+ child_media.thumbnail = _get_item_thumbnail(
+ item=item,
+ player=player,
+ entity=entity,
+ item_type=item_type,
+ search_type=search_type,
+ internal_request=internal_request,
)
- list_playable.append(can_play)
+
+ children.append(child_media)
if children is None:
raise BrowseError(f"Media not found: {search_type} / {search_id}")
@@ -175,19 +320,24 @@ async def build_item_response(
assert media_class["item"] is not None
if not search_id:
search_id = search_type
+
return BrowseMedia(
title=result.get("title"),
media_class=media_class["item"],
children_media_class=media_class["children"],
media_content_id=search_id,
media_content_type=search_type,
- can_play=any(list_playable),
+ can_play=any(child.can_play for child in children),
children=children,
can_expand=True,
)
-async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia:
+async def library_payload(
+ hass: HomeAssistant,
+ player: Player,
+ browse_media: BrowseData,
+) -> BrowseMedia:
"""Create response payload to describe contents of library."""
library_info: dict[str, Any] = {
"title": "Music Library",
@@ -200,21 +350,21 @@ async def library_payload(hass: HomeAssistant, player: Player) -> BrowseMedia:
}
for item in LIBRARY:
- media_class = CONTENT_TYPE_MEDIA_CLASS[item]
+ media_class = browse_media.content_type_media_class[item]
result = await player.async_browse(
- MEDIA_TYPE_TO_SQUEEZEBOX[item],
+ browse_media.media_type_to_squeezebox[item],
limit=1,
)
if result is not None and result.get("items") is not None:
assert media_class["children"] is not None
library_info["children"].append(
BrowseMedia(
- title=item,
+ title=item.title(),
media_class=media_class["children"],
media_content_id=item,
media_content_type=item,
- can_play=item != "Favorites",
+ can_play=item not in ["favorites", "apps", "radios"],
can_expand=True,
)
)
@@ -237,17 +387,27 @@ def media_source_content_filter(item: BrowseMedia) -> bool:
return item.media_content_type.startswith("audio/")
-async def generate_playlist(player: Player, payload: dict[str, str]) -> list | None:
+async def generate_playlist(
+ player: Player,
+ payload: dict[str, str],
+ browse_limit: int,
+ browse_media: BrowseData,
+) -> list | None:
"""Generate playlist from browsing payload."""
media_type = payload["search_type"]
media_id = payload["search_id"]
- if media_type not in SQUEEZEBOX_ID_BY_TYPE:
+ if media_type not in browse_media.squeezebox_id_by_type:
raise BrowseError(f"Media type not supported: {media_type}")
- browse_id = (SQUEEZEBOX_ID_BY_TYPE[media_type], media_id)
+ browse_id = (browse_media.squeezebox_id_by_type[media_type], media_id)
+ if media_type.startswith("app-"):
+ category = media_type
+ else:
+ category = "titles"
+
result = await player.async_browse(
- "titles", limit=BROWSE_LIMIT, browse_id=browse_id
+ category, limit=browse_limit, browse_id=browse_id
)
if result and "items" in result:
items: list = result["items"]
diff --git a/homeassistant/components/squeezebox/button.py b/homeassistant/components/squeezebox/button.py
new file mode 100644
index 00000000000..098df3a1b5c
--- /dev/null
+++ b/homeassistant/components/squeezebox/button.py
@@ -0,0 +1,155 @@
+"""Platform for button integration for squeezebox."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+import logging
+
+from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import SqueezeboxConfigEntry
+from .const import SIGNAL_PLAYER_DISCOVERED
+from .coordinator import SqueezeBoxPlayerUpdateCoordinator
+from .entity import SqueezeboxEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+HARDWARE_MODELS_WITH_SCREEN = [
+ "Squeezebox Boom",
+ "Squeezebox Radio",
+ "Transporter",
+ "Squeezebox Touch",
+ "Squeezebox",
+ "SliMP3",
+ "Squeezebox 1",
+ "Squeezebox 2",
+ "Squeezebox 3",
+]
+
+HARDWARE_MODELS_WITH_TONE = [
+ *HARDWARE_MODELS_WITH_SCREEN,
+ "Squeezebox Receiver",
+]
+
+
+@dataclass(frozen=True, kw_only=True)
+class SqueezeboxButtonEntityDescription(ButtonEntityDescription):
+ """Squeezebox Button description."""
+
+ press_action: str
+
+
+BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = tuple(
+ SqueezeboxButtonEntityDescription(
+ key=f"preset_{i}",
+ translation_key="preset",
+ translation_placeholders={"index": str(i)},
+ press_action=f"preset_{i}.single",
+ )
+ for i in range(1, 7)
+)
+
+SCREEN_BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = (
+ SqueezeboxButtonEntityDescription(
+ key="brightness_up",
+ translation_key="brightness_up",
+ press_action="brightness_up",
+ ),
+ SqueezeboxButtonEntityDescription(
+ key="brightness_down",
+ translation_key="brightness_down",
+ press_action="brightness_down",
+ ),
+)
+
+TONE_BUTTON_ENTITIES: tuple[SqueezeboxButtonEntityDescription, ...] = (
+ SqueezeboxButtonEntityDescription(
+ key="bass_up",
+ translation_key="bass_up",
+ press_action="bass_up",
+ ),
+ SqueezeboxButtonEntityDescription(
+ key="bass_down",
+ translation_key="bass_down",
+ press_action="bass_down",
+ ),
+ SqueezeboxButtonEntityDescription(
+ key="treble_up",
+ translation_key="treble_up",
+ press_action="treble_up",
+ ),
+ SqueezeboxButtonEntityDescription(
+ key="treble_down",
+ translation_key="treble_down",
+ press_action="treble_down",
+ ),
+)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: SqueezeboxConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Squeezebox button platform from a server config entry."""
+
+ # Add button entities when player discovered
+ async def _player_discovered(
+ player_coordinator: SqueezeBoxPlayerUpdateCoordinator,
+ ) -> None:
+ _LOGGER.debug(
+ "Setting up button entity for player %s, model %s",
+ player_coordinator.player.name,
+ player_coordinator.player.model,
+ )
+
+ entities: list[SqueezeboxButtonEntity] = []
+
+ entities.extend(
+ SqueezeboxButtonEntity(player_coordinator, description)
+ for description in BUTTON_ENTITIES
+ )
+
+ entities.extend(
+ SqueezeboxButtonEntity(player_coordinator, description)
+ for description in TONE_BUTTON_ENTITIES
+ if player_coordinator.player.model in HARDWARE_MODELS_WITH_TONE
+ )
+
+ entities.extend(
+ SqueezeboxButtonEntity(player_coordinator, description)
+ for description in SCREEN_BUTTON_ENTITIES
+ if player_coordinator.player.model in HARDWARE_MODELS_WITH_SCREEN
+ )
+
+ async_add_entities(entities)
+
+ entry.async_on_unload(
+ async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered)
+ )
+
+
+class SqueezeboxButtonEntity(SqueezeboxEntity, ButtonEntity):
+ """Representation of Buttons for Squeezebox entities."""
+
+ entity_description: SqueezeboxButtonEntityDescription
+
+ def __init__(
+ self,
+ coordinator: SqueezeBoxPlayerUpdateCoordinator,
+ entity_description: SqueezeboxButtonEntityDescription,
+ ) -> None:
+ """Initialize the SqueezeBox Button."""
+ super().__init__(coordinator)
+ self.entity_description = entity_description
+ self._attr_unique_id = (
+ f"{format_mac(self._player.player_id)}_{entity_description.key}"
+ )
+
+ async def async_press(self) -> None:
+ """Execute the button action."""
+ await self._player.async_query("button", self.entity_description.press_action)
diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py
index 97eb848c21c..31dd5b003b7 100644
--- a/homeassistant/components/squeezebox/config_flow.py
+++ b/homeassistant/components/squeezebox/config_flow.py
@@ -11,15 +11,34 @@ from pysqueezebox import Server, async_discover
import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.config_entries import (
+ ConfigEntry,
+ ConfigFlow,
+ ConfigFlowResult,
+ OptionsFlow,
+)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME
+from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
+from homeassistant.helpers.selector import (
+ NumberSelector,
+ NumberSelectorConfig,
+ NumberSelectorMode,
+)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
-from .const import CONF_HTTPS, DEFAULT_PORT, DOMAIN
+from .const import (
+ CONF_BROWSE_LIMIT,
+ CONF_HTTPS,
+ CONF_VOLUME_STEP,
+ DEFAULT_BROWSE_LIMIT,
+ DEFAULT_PORT,
+ DEFAULT_VOLUME_STEP,
+ DOMAIN,
+)
_LOGGER = logging.getLogger(__name__)
@@ -77,6 +96,12 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
self.data_schema = _base_schema()
self.discovery_info: dict[str, Any] | None = None
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler:
+ """Get the options flow for this handler."""
+ return OptionsFlowHandler()
+
async def _discover(self, uuid: str | None = None) -> None:
"""Discover an unconfigured LMS server."""
self.discovery_info = None
@@ -126,7 +151,8 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
if server.http_status == HTTPStatus.UNAUTHORIZED:
return "invalid_auth"
return "cannot_connect"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unknown exception while validating connection")
return "unknown"
if "uuid" in status:
@@ -222,3 +248,48 @@ class SqueezeboxConfigFlow(ConfigFlow, domain=DOMAIN):
# if the player is unknown, then we likely need to configure its server
return await self.async_step_user()
+
+
+OPTIONS_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_BROWSE_LIMIT): vol.All(
+ NumberSelector(
+ NumberSelectorConfig(min=1, max=65534, mode=NumberSelectorMode.BOX)
+ ),
+ vol.Coerce(int),
+ ),
+ vol.Required(CONF_VOLUME_STEP): vol.All(
+ NumberSelector(
+ NumberSelectorConfig(min=1, max=20, mode=NumberSelectorMode.SLIDER)
+ ),
+ vol.Coerce(int),
+ ),
+ }
+)
+
+
+class OptionsFlowHandler(OptionsFlow):
+ """Options Flow Handler."""
+
+ async def async_step_init(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Options Flow Steps."""
+
+ if user_input is not None:
+ return self.async_create_entry(title="", data=user_input)
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=self.add_suggested_values_to_schema(
+ OPTIONS_SCHEMA,
+ {
+ CONF_BROWSE_LIMIT: self.config_entry.options.get(
+ CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT
+ ),
+ CONF_VOLUME_STEP: self.config_entry.options.get(
+ CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP
+ ),
+ },
+ ),
+ )
diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py
index 8bc33214170..5ce95d25632 100644
--- a/homeassistant/components/squeezebox/const.py
+++ b/homeassistant/components/squeezebox/const.py
@@ -27,8 +27,20 @@ STATUS_QUERY_LIBRARYNAME = "libraryname"
STATUS_QUERY_MAC = "mac"
STATUS_QUERY_UUID = "uuid"
STATUS_QUERY_VERSION = "version"
-SQUEEZEBOX_SOURCE_STRINGS = ("source:", "wavin:", "spotify:")
+SQUEEZEBOX_SOURCE_STRINGS = (
+ "source:",
+ "wavin:",
+ "spotify:",
+ "loop:",
+)
SIGNAL_PLAYER_DISCOVERED = "squeezebox_player_discovered"
SIGNAL_PLAYER_REDISCOVERED = "squeezebox_player_rediscovered"
DISCOVERY_INTERVAL = 60
PLAYER_UPDATE_INTERVAL = 5
+CONF_BROWSE_LIMIT = "browse_limit"
+CONF_VOLUME_STEP = "volume_step"
+DEFAULT_BROWSE_LIMIT = 1000
+DEFAULT_VOLUME_STEP = 5
+ATTR_ANNOUNCE_VOLUME = "announce_volume"
+ATTR_ANNOUNCE_TIMEOUT = "announce_timeout"
+UNPLAYABLE_TYPES = ("text", "actions")
diff --git a/homeassistant/components/squeezebox/entity.py b/homeassistant/components/squeezebox/entity.py
index 027ca68edc6..2c443c24ffd 100644
--- a/homeassistant/components/squeezebox/entity.py
+++ b/homeassistant/components/squeezebox/entity.py
@@ -1,11 +1,37 @@
"""Base class for Squeezebox Sensor entities."""
-from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.device_registry import (
+ CONNECTION_NETWORK_MAC,
+ DeviceInfo,
+ format_mac,
+)
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, STATUS_QUERY_UUID
-from .coordinator import LMSStatusDataUpdateCoordinator
+from .coordinator import (
+ LMSStatusDataUpdateCoordinator,
+ SqueezeBoxPlayerUpdateCoordinator,
+)
+
+
+class SqueezeboxEntity(CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator]):
+ """Base entity class for Squeezebox entities."""
+
+ _attr_has_entity_name = True
+
+ def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None:
+ """Initialize the SqueezeBox entity."""
+ super().__init__(coordinator)
+ self._player = coordinator.player
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, format_mac(self._player.player_id))},
+ name=self._player.name,
+ connections={(CONNECTION_NETWORK_MAC, format_mac(self._player.player_id))},
+ via_device=(DOMAIN, coordinator.server_uuid),
+ model=self._player.model,
+ manufacturer=self._player.creator,
+ )
class LMSStatusEntity(CoordinatorEntity[LMSStatusDataUpdateCoordinator]):
diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json
index 09eaa4026f4..e9b89291749 100644
--- a/homeassistant/components/squeezebox/manifest.json
+++ b/homeassistant/components/squeezebox/manifest.json
@@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/squeezebox",
"iot_class": "local_polling",
"loggers": ["pysqueezebox"],
- "requirements": ["pysqueezebox==0.11.1"]
+ "requirements": ["pysqueezebox==0.12.0"]
}
diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py
index 19cd1e36910..6e99099ccb1 100644
--- a/homeassistant/components/squeezebox/media_player.py
+++ b/homeassistant/components/squeezebox/media_player.py
@@ -14,6 +14,7 @@ import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
+ ATTR_MEDIA_EXTRA,
BrowseError,
BrowseMedia,
MediaPlayerEnqueue,
@@ -34,24 +35,26 @@ from homeassistant.helpers import (
entity_platform,
entity_registry as er,
)
-from homeassistant.helpers.device_registry import (
- CONNECTION_NETWORK_MAC,
- DeviceInfo,
- format_mac,
-)
+from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.start import async_at_start
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utcnow
from .browse_media import (
+ BrowseData,
build_item_response,
generate_playlist,
library_payload,
media_source_content_filter,
)
from .const import (
+ ATTR_ANNOUNCE_TIMEOUT,
+ ATTR_ANNOUNCE_VOLUME,
+ CONF_BROWSE_LIMIT,
+ CONF_VOLUME_STEP,
+ DEFAULT_BROWSE_LIMIT,
+ DEFAULT_VOLUME_STEP,
DISCOVERY_TASK,
DOMAIN,
KNOWN_PLAYERS,
@@ -60,6 +63,7 @@ from .const import (
SQUEEZEBOX_SOURCE_STRINGS,
)
from .coordinator import SqueezeBoxPlayerUpdateCoordinator
+from .entity import SqueezeboxEntity
if TYPE_CHECKING:
from . import SqueezeboxConfigEntry
@@ -113,7 +117,7 @@ async def start_server_discovery(hass: HomeAssistant) -> None:
async def async_setup_entry(
hass: HomeAssistant,
entry: SqueezeboxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Squeezebox media_player platform from a server config entry."""
@@ -153,9 +157,27 @@ async def async_setup_entry(
entry.async_on_unload(async_at_start(hass, start_server_discovery))
-class SqueezeBoxMediaPlayerEntity(
- CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator], MediaPlayerEntity
-):
+def get_announce_volume(extra: dict) -> float | None:
+ """Get announce volume from extra service data."""
+ if ATTR_ANNOUNCE_VOLUME not in extra:
+ return None
+ announce_volume = float(extra[ATTR_ANNOUNCE_VOLUME])
+ if not (0 < announce_volume <= 1):
+ raise ValueError
+ return announce_volume * 100
+
+
+def get_announce_timeout(extra: dict) -> int | None:
+ """Get announce volume from extra service data."""
+ if ATTR_ANNOUNCE_TIMEOUT not in extra:
+ return None
+ announce_timeout = int(extra[ATTR_ANNOUNCE_TIMEOUT])
+ if announce_timeout < 1:
+ raise ValueError
+ return announce_timeout
+
+
+class SqueezeBoxMediaPlayerEntity(SqueezeboxEntity, MediaPlayerEntity):
"""Representation of the media player features of a SqueezeBox device.
Wraps a pysqueezebox.Player() object.
@@ -166,6 +188,7 @@ class SqueezeBoxMediaPlayerEntity(
| MediaPlayerEntityFeature.PAUSE
| MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
+ | MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.PREVIOUS_TRACK
| MediaPlayerEntityFeature.NEXT_TRACK
| MediaPlayerEntityFeature.SEEK
@@ -179,41 +202,20 @@ class SqueezeBoxMediaPlayerEntity(
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.GROUPING
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
+ | MediaPlayerEntityFeature.MEDIA_ANNOUNCE
)
_attr_has_entity_name = True
_attr_name = None
_last_update: datetime | None = None
- def __init__(
- self,
- coordinator: SqueezeBoxPlayerUpdateCoordinator,
- ) -> None:
+ def __init__(self, coordinator: SqueezeBoxPlayerUpdateCoordinator) -> None:
"""Initialize the SqueezeBox device."""
super().__init__(coordinator)
- player = coordinator.player
- self._player = player
self._query_result: bool | dict = {}
self._remove_dispatcher: Callable | None = None
self._previous_media_position = 0
- self._attr_unique_id = format_mac(player.player_id)
- _manufacturer = None
- if player.model == "SqueezeLite" or "SqueezePlay" in player.model:
- _manufacturer = "Ralph Irving"
- elif (
- "Squeezebox" in player.model
- or "Transporter" in player.model
- or "Slim" in player.model
- ):
- _manufacturer = "Logitech"
-
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, self._attr_unique_id)},
- name=player.name,
- connections={(CONNECTION_NETWORK_MAC, self._attr_unique_id)},
- via_device=(DOMAIN, coordinator.server_uuid),
- model=player.model,
- manufacturer=_manufacturer,
- )
+ self._attr_unique_id = format_mac(self._player.player_id)
+ self._browse_data = BrowseData()
@callback
def _handle_coordinator_update(self) -> None:
@@ -223,6 +225,23 @@ class SqueezeBoxMediaPlayerEntity(
self._last_update = utcnow()
self.async_write_ha_state()
+ @property
+ def volume_step(self) -> float:
+ """Return the step to be used for volume up down."""
+ return float(
+ self.coordinator.config_entry.options.get(
+ CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP
+ )
+ / 100
+ )
+
+ @property
+ def browse_limit(self) -> int:
+ """Return the step to be used for volume up down."""
+ return self.coordinator.config_entry.options.get(
+ CONF_BROWSE_LIMIT, DEFAULT_BROWSE_LIMIT
+ )
+
@property
def available(self) -> bool:
"""Return True if entity is available."""
@@ -366,16 +385,6 @@ class SqueezeBoxMediaPlayerEntity(
await self._player.async_set_power(False)
await self.coordinator.async_refresh()
- async def async_volume_up(self) -> None:
- """Volume up media player."""
- await self._player.async_set_volume("+5")
- await self.coordinator.async_refresh()
-
- async def async_volume_down(self) -> None:
- """Volume down media player."""
- await self._player.async_set_volume("-5")
- await self.coordinator.async_refresh()
-
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1."""
volume_percent = str(int(volume * 100))
@@ -428,11 +437,18 @@ class SqueezeBoxMediaPlayerEntity(
await self.coordinator.async_refresh()
async def async_play_media(
- self, media_type: MediaType | str, media_id: str, **kwargs: Any
+ self,
+ media_type: MediaType | str,
+ media_id: str,
+ announce: bool | None = None,
+ **kwargs: Any,
) -> None:
"""Send the play_media command to the media player."""
index = None
+ if media_type:
+ media_type = media_type.lower()
+
enqueue: MediaPlayerEnqueue | None = kwargs.get(ATTR_MEDIA_ENQUEUE)
if enqueue == MediaPlayerEnqueue.ADD:
@@ -451,6 +467,32 @@ class SqueezeBoxMediaPlayerEntity(
)
media_id = play_item.url
+ if announce:
+ if media_type not in MediaType.MUSIC:
+ raise ServiceValidationError(
+ "Announcements must have media type of 'music'. Playlists are not supported"
+ )
+
+ extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
+ cmd = "announce"
+ try:
+ announce_volume = get_announce_volume(extra)
+ except ValueError:
+ raise ServiceValidationError(
+ f"{ATTR_ANNOUNCE_VOLUME} must be a number greater than 0 and less than or equal to 1"
+ ) from None
+ else:
+ self._player.set_announce_volume(announce_volume)
+
+ try:
+ announce_timeout = get_announce_timeout(extra)
+ except ValueError:
+ raise ServiceValidationError(
+ f"{ATTR_ANNOUNCE_TIMEOUT} must be a whole number greater than 0"
+ ) from None
+ else:
+ self._player.set_announce_timeout(announce_timeout)
+
if media_type in MediaType.MUSIC:
if not media_id.startswith(SQUEEZEBOX_SOURCE_STRINGS):
# do not process special squeezebox "source" media ids
@@ -466,7 +508,9 @@ class SqueezeBoxMediaPlayerEntity(
"search_id": media_id,
"search_type": MediaType.PLAYLIST,
}
- playlist = await generate_playlist(self._player, payload)
+ playlist = await generate_playlist(
+ self._player, payload, self.browse_limit, self._browse_data
+ )
except BrowseError:
# a list of urls
content = json.loads(media_id)
@@ -477,7 +521,9 @@ class SqueezeBoxMediaPlayerEntity(
"search_id": media_id,
"search_type": media_type,
}
- playlist = await generate_playlist(self._player, payload)
+ playlist = await generate_playlist(
+ self._player, payload, self.browse_limit, self._browse_data
+ )
_LOGGER.debug("Generated playlist: %s", playlist)
@@ -574,8 +620,11 @@ class SqueezeBoxMediaPlayerEntity(
media_content_id,
)
+ if media_content_type:
+ media_content_type = media_content_type.lower()
+
if media_content_type in [None, "library"]:
- return await library_payload(self.hass, self._player)
+ return await library_payload(self.hass, self._player, self._browse_data)
if media_content_id and media_source.is_media_source_id(media_content_id):
return await media_source.async_browse_media(
@@ -587,7 +636,13 @@ class SqueezeBoxMediaPlayerEntity(
"search_id": media_content_id,
}
- return await build_item_response(self, self._player, payload)
+ return await build_item_response(
+ self,
+ self._player,
+ payload,
+ self.browse_limit,
+ self._browse_data,
+ )
async def async_get_browse_image(
self,
diff --git a/homeassistant/components/squeezebox/sensor.py b/homeassistant/components/squeezebox/sensor.py
index 0ca33179f9f..9d9490208ea 100644
--- a/homeassistant/components/squeezebox/sensor.py
+++ b/homeassistant/components/squeezebox/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import SqueezeboxConfigEntry
@@ -43,6 +43,7 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
state_class=SensorStateClass.TOTAL,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
),
SensorEntityDescription(
key=STATUS_SENSOR_INFO_TOTAL_GENRES,
@@ -73,7 +74,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: SqueezeboxConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Platform setup using common elements."""
diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json
index bce71ddb5f2..83c5d7dd5d0 100644
--- a/homeassistant/components/squeezebox/strings.json
+++ b/homeassistant/components/squeezebox/strings.json
@@ -63,6 +63,29 @@
}
},
"entity": {
+ "button": {
+ "preset": {
+ "name": "Preset {index}"
+ },
+ "brightness_up": {
+ "name": "Brightness up"
+ },
+ "brightness_down": {
+ "name": "Brightness down"
+ },
+ "bass_up": {
+ "name": "Bass up"
+ },
+ "bass_down": {
+ "name": "Bass down"
+ },
+ "treble_up": {
+ "name": "Treble up"
+ },
+ "treble_down": {
+ "name": "Treble down"
+ }
+ },
"binary_sensor": {
"rescan": {
"name": "Library rescan"
@@ -103,5 +126,20 @@
"unit_of_measurement": "[%key:component::squeezebox::entity::sensor::player_count::unit_of_measurement%]"
}
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "title": "LMS Configuration",
+ "data": {
+ "browse_limit": "Browse limit",
+ "volume_step": "Volume step"
+ },
+ "data_description": {
+ "browse_limit": "Maximum number of items when browsing or in a playlist.",
+ "volume_step": "Amount to adjust the volume when turning volume up or down."
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py
index a9f5c25d6a5..89274390411 100644
--- a/homeassistant/components/srp_energy/sensor.py
+++ b/homeassistant/components/srp_energy/sensor.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -20,7 +20,9 @@ from .const import DEVICE_CONFIG_URL, DEVICE_MANUFACTURER, DEVICE_MODEL, DOMAIN
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SRP Energy Usage sensor."""
coordinator: SRPEnergyDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json
index eca4f465435..5fa97b00b57 100644
--- a/homeassistant/components/srp_energy/strings.json
+++ b/homeassistant/components/srp_energy/strings.json
@@ -3,10 +3,10 @@
"step": {
"user": {
"data": {
- "id": "Account Id",
+ "id": "Account ID",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
- "is_tou": "Is Time of Use Plan"
+ "is_tou": "Is Time-of-Use Price Plan"
}
}
},
diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json
index 6e1fba8c3a3..93943b0a9ea 100644
--- a/homeassistant/components/ssdp/manifest.json
+++ b/homeassistant/components/ssdp/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"quality_scale": "internal",
- "requirements": ["async-upnp-client==0.43.0"]
+ "requirements": ["async-upnp-client==0.44.0"]
}
diff --git a/homeassistant/components/starline/binary_sensor.py b/homeassistant/components/starline/binary_sensor.py
index ac1ad4f2b6e..a570b26a0d1 100644
--- a/homeassistant/components/starline/binary_sensor.py
+++ b/homeassistant/components/starline/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .account import StarlineAccount, StarlineDevice
from .const import DOMAIN
@@ -70,7 +70,9 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the StarLine sensors."""
account: StarlineAccount = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/starline/button.py b/homeassistant/components/starline/button.py
index 6fb307cda74..fa46d2a3773 100644
--- a/homeassistant/components/starline/button.py
+++ b/homeassistant/components/starline/button.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .account import StarlineAccount, StarlineDevice
from .const import DOMAIN
@@ -34,7 +34,9 @@ BUTTON_TYPES: tuple[ButtonEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the StarLine button."""
account: StarlineAccount = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/starline/device_tracker.py b/homeassistant/components/starline/device_tracker.py
index 610317b72c3..0c8418d28fc 100644
--- a/homeassistant/components/starline/device_tracker.py
+++ b/homeassistant/components/starline/device_tracker.py
@@ -3,7 +3,7 @@
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .account import StarlineAccount, StarlineDevice
@@ -12,7 +12,9 @@ from .entity import StarlineEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up StarLine entry."""
account: StarlineAccount = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py
index 74807996dfb..f8846c2a97f 100644
--- a/homeassistant/components/starline/entity.py
+++ b/homeassistant/components/starline/entity.py
@@ -27,20 +27,20 @@ class StarlineEntity(Entity):
self._unsubscribe_api: Callable | None = None
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if entity is available."""
return self._account.api.available
- def update(self):
+ def update(self) -> None:
"""Read new state data."""
self.schedule_update_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Call when entity about to be added to Home Assistant."""
await super().async_added_to_hass()
self._unsubscribe_api = self._account.api.add_update_listener(self.update)
- async def async_will_remove_from_hass(self):
+ async def async_will_remove_from_hass(self) -> None:
"""Call when entity is being removed from Home Assistant."""
await super().async_will_remove_from_hass()
if self._unsubscribe_api is not None:
diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py
index 19aad1a19b2..43886d63962 100644
--- a/homeassistant/components/starline/lock.py
+++ b/homeassistant/components/starline/lock.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .account import StarlineAccount, StarlineDevice
from .const import DOMAIN
@@ -15,7 +15,9 @@ from .entity import StarlineEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the StarLine lock."""
account: StarlineAccount = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py
index f9bd304c1e1..916d0a9f26b 100644
--- a/homeassistant/components/starline/sensor.py
+++ b/homeassistant/components/starline/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level, icon_for_signal_level
from .account import StarlineAccount, StarlineDevice
@@ -61,6 +61,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="fuel",
translation_key="fuel",
+ device_class=SensorDeviceClass.VOLUME,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
@@ -87,7 +88,9 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the StarLine sensors."""
account: StarlineAccount = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py
index eb71f0b73b5..79d4fa86ddf 100644
--- a/homeassistant/components/starline/switch.py
+++ b/homeassistant/components/starline/switch.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .account import StarlineAccount, StarlineDevice
from .const import DOMAIN
@@ -34,7 +34,9 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the StarLine switch."""
account: StarlineAccount = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/starlink/__init__.py b/homeassistant/components/starlink/__init__.py
index 4528a35858c..0c512bb21c5 100644
--- a/homeassistant/components/starlink/__init__.py
+++ b/homeassistant/components/starlink/__init__.py
@@ -2,12 +2,10 @@
from __future__ import annotations
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import StarlinkUpdateCoordinator
+from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
@@ -19,21 +17,19 @@ PLATFORMS = [
]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: StarlinkConfigEntry
+) -> bool:
"""Set up Starlink from a config entry."""
- coordinator = StarlinkUpdateCoordinator(hass, entry)
+ config_entry.runtime_data = StarlinkUpdateCoordinator(hass, config_entry)
+ await config_entry.runtime_data.async_config_entry_first_refresh()
- await coordinator.async_config_entry_first_refresh()
-
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
-
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+ await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, config_entry: StarlinkConfigEntry
+) -> bool:
"""Unload a config entry."""
- if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
diff --git a/homeassistant/components/starlink/binary_sensor.py b/homeassistant/components/starlink/binary_sensor.py
index b03648e81c5..e06e79009c3 100644
--- a/homeassistant/components/starlink/binary_sensor.py
+++ b/homeassistant/components/starlink/binary_sensor.py
@@ -10,24 +10,22 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import StarlinkData
+from .coordinator import StarlinkConfigEntry, StarlinkData
from .entity import StarlinkEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config_entry: StarlinkConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkBinarySensorEntity(coordinator, description)
+ StarlinkBinarySensorEntity(config_entry.runtime_data, description)
for description in BINARY_SENSORS
)
diff --git a/homeassistant/components/starlink/button.py b/homeassistant/components/starlink/button.py
index f8f18763d30..15f35659b49 100644
--- a/homeassistant/components/starlink/button.py
+++ b/homeassistant/components/starlink/button.py
@@ -10,24 +10,23 @@ from homeassistant.components.button import (
ButtonEntity,
ButtonEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import StarlinkUpdateCoordinator
+from .coordinator import StarlinkConfigEntry, StarlinkUpdateCoordinator
from .entity import StarlinkEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config_entry: StarlinkConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkButtonEntity(coordinator, description) for description in BUTTONS
+ StarlinkButtonEntity(config_entry.runtime_data, description)
+ for description in BUTTONS
)
diff --git a/homeassistant/components/starlink/coordinator.py b/homeassistant/components/starlink/coordinator.py
index 4ae771c9582..02d51cd805e 100644
--- a/homeassistant/components/starlink/coordinator.py
+++ b/homeassistant/components/starlink/coordinator.py
@@ -34,6 +34,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
_LOGGER = logging.getLogger(__name__)
+type StarlinkConfigEntry = ConfigEntry[StarlinkUpdateCoordinator]
+
@dataclass
class StarlinkData:
@@ -51,9 +53,9 @@ class StarlinkData:
class StarlinkUpdateCoordinator(DataUpdateCoordinator[StarlinkData]):
"""Coordinates updates between all Starlink sensors defined in this file."""
- config_entry: ConfigEntry
+ config_entry: StarlinkConfigEntry
- def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ def __init__(self, hass: HomeAssistant, config_entry: StarlinkConfigEntry) -> None:
"""Initialize an UpdateCoordinator for a group of sensors."""
self.channel_context = ChannelContext(target=config_entry.data[CONF_IP_ADDRESS])
self.history_stats_start = None
diff --git a/homeassistant/components/starlink/device_tracker.py b/homeassistant/components/starlink/device_tracker.py
index 5174be19760..dbe31947b55 100644
--- a/homeassistant/components/starlink/device_tracker.py
+++ b/homeassistant/components/starlink/device_tracker.py
@@ -8,23 +8,22 @@ from homeassistant.components.device_tracker import (
TrackerEntity,
TrackerEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import ATTR_ALTITUDE, DOMAIN
-from .coordinator import StarlinkData
+from .const import ATTR_ALTITUDE
+from .coordinator import StarlinkConfigEntry, StarlinkData
from .entity import StarlinkEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config_entry: StarlinkConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkDeviceTrackerEntity(coordinator, description)
+ StarlinkDeviceTrackerEntity(config_entry.runtime_data, description)
for description in DEVICE_TRACKERS
)
diff --git a/homeassistant/components/starlink/diagnostics.py b/homeassistant/components/starlink/diagnostics.py
index c619458b1dd..543fe9d8dde 100644
--- a/homeassistant/components/starlink/diagnostics.py
+++ b/homeassistant/components/starlink/diagnostics.py
@@ -4,18 +4,15 @@ from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import StarlinkUpdateCoordinator
+from .coordinator import StarlinkConfigEntry
TO_REDACT = {"id", "latitude", "longitude", "altitude"}
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, config_entry: StarlinkConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for Starlink config entries."""
- coordinator: StarlinkUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
- return async_redact_data(asdict(coordinator.data), TO_REDACT)
+ return async_redact_data(asdict(config_entry.runtime_data.data), TO_REDACT)
diff --git a/homeassistant/components/starlink/sensor.py b/homeassistant/components/starlink/sensor.py
index 5481e310fbd..14cbf6fe876 100644
--- a/homeassistant/components/starlink/sensor.py
+++ b/homeassistant/components/starlink/sensor.py
@@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
DEGREE,
PERCENTAGE,
@@ -24,23 +23,23 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import now
-from .const import DOMAIN
-from .coordinator import StarlinkData
+from .coordinator import StarlinkConfigEntry, StarlinkData
from .entity import StarlinkEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config_entry: StarlinkConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all sensors for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkSensorEntity(coordinator, description) for description in SENSORS
+ StarlinkSensorEntity(config_entry.runtime_data, description)
+ for description in SENSORS
)
@@ -114,7 +113,9 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
translation_key="last_boot_time",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
- value_fn=lambda data: now() - timedelta(seconds=data.status["uptime"]),
+ value_fn=lambda data: (
+ now() - timedelta(seconds=data.status["uptime"])
+ ).replace(microsecond=0),
),
StarlinkSensorEntityDescription(
key="ping_drop_rate",
diff --git a/homeassistant/components/starlink/switch.py b/homeassistant/components/starlink/switch.py
index 3534748127e..c6dc237643e 100644
--- a/homeassistant/components/starlink/switch.py
+++ b/homeassistant/components/starlink/switch.py
@@ -11,23 +11,22 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import StarlinkData, StarlinkUpdateCoordinator
+from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator
from .entity import StarlinkEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config_entry: StarlinkConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all binary sensors for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkSwitchEntity(coordinator, description) for description in SWITCHES
+ StarlinkSwitchEntity(config_entry.runtime_data, description)
+ for description in SWITCHES
)
diff --git a/homeassistant/components/starlink/time.py b/homeassistant/components/starlink/time.py
index 7395ec101ba..9f564333218 100644
--- a/homeassistant/components/starlink/time.py
+++ b/homeassistant/components/starlink/time.py
@@ -8,24 +8,23 @@ from datetime import UTC, datetime, time, tzinfo
import math
from homeassistant.components.time import TimeEntity, TimeEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import StarlinkData, StarlinkUpdateCoordinator
+from .coordinator import StarlinkConfigEntry, StarlinkData, StarlinkUpdateCoordinator
from .entity import StarlinkEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config_entry: StarlinkConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all time entities for this entry."""
- coordinator = hass.data[DOMAIN][entry.entry_id]
-
async_add_entities(
- StarlinkTimeEntity(coordinator, description) for description in TIMES
+ StarlinkTimeEntity(config_entry.runtime_data, description)
+ for description in TIMES
)
diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py
index 5252c23fd3d..a5c5f10ecd0 100644
--- a/homeassistant/components/statistics/sensor.py
+++ b/homeassistant/components/statistics/sensor.py
@@ -47,7 +47,10 @@ from homeassistant.core import (
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_device_info_to_link_from_entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import (
async_track_point_in_utc_time,
async_track_state_change_event,
@@ -617,7 +620,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Statistics sensor entry."""
sampling_size = entry.options.get(CONF_SAMPLES_MAX_BUFFER_SIZE)
diff --git a/homeassistant/components/statistics/strings.json b/homeassistant/components/statistics/strings.json
index 51858034340..e1085a016ce 100644
--- a/homeassistant/components/statistics/strings.json
+++ b/homeassistant/components/statistics/strings.json
@@ -21,7 +21,7 @@
}
},
"state_characteristic": {
- "description": "Read the documention for further details on available options and how to use them.",
+ "description": "Read the documentation for further details on available options and how to use them.",
"data": {
"state_characteristic": "Statistic characteristic"
},
@@ -30,7 +30,7 @@
}
},
"options": {
- "description": "Read the documention for further details on how to configure the statistics sensor using these options.",
+ "description": "Read the documentation for further details on how to configure the statistics sensor using these options.",
"data": {
"sampling_size": "Sampling size",
"max_age": "Max age",
diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py
index 625a8b95979..c1e20933185 100644
--- a/homeassistant/components/steam_online/sensor.py
+++ b/homeassistant/components/steam_online/sensor.py
@@ -8,7 +8,7 @@ from typing import cast
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utc_from_timestamp
@@ -29,7 +29,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: SteamConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Steam platform."""
async_add_entities(
diff --git a/homeassistant/components/steamist/sensor.py b/homeassistant/components/steamist/sensor.py
index 7c24d015513..94e3ff86ee1 100644
--- a/homeassistant/components/steamist/sensor.py
+++ b/homeassistant/components/steamist/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SteamistDataUpdateCoordinator
@@ -58,7 +58,7 @@ SENSORS: tuple[SteamistSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors."""
coordinator: SteamistDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/steamist/switch.py b/homeassistant/components/steamist/switch.py
index 91806f4fa0c..17e1d6d47ac 100644
--- a/homeassistant/components/steamist/switch.py
+++ b/homeassistant/components/steamist/switch.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SteamistDataUpdateCoordinator
@@ -22,7 +22,7 @@ ACTIVE_SWITCH = SwitchEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors."""
coordinator: SteamistDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/stookwijzer/__init__.py b/homeassistant/components/stookwijzer/__init__.py
index d8b9561bde9..9adfc09de0e 100644
--- a/homeassistant/components/stookwijzer/__init__.py
+++ b/homeassistant/components/stookwijzer/__init__.py
@@ -9,7 +9,6 @@ from stookwijzer import Stookwijzer
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er, issue_registry as ir
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, LOGGER
from .coordinator import StookwijzerConfigEntry, StookwijzerCoordinator
@@ -43,13 +42,12 @@ async def async_migrate_entry(
LOGGER.debug("Migrating from version %s", entry.version)
if entry.version == 1:
- latitude, longitude = await Stookwijzer.async_transform_coordinates(
- async_get_clientsession(hass),
+ xy = await Stookwijzer.async_transform_coordinates(
entry.data[CONF_LOCATION][CONF_LATITUDE],
entry.data[CONF_LOCATION][CONF_LONGITUDE],
)
- if not latitude or not longitude:
+ if not xy:
ir.async_create_issue(
hass,
DOMAIN,
@@ -67,8 +65,8 @@ async def async_migrate_entry(
entry,
version=2,
data={
- CONF_LATITUDE: latitude,
- CONF_LONGITUDE: longitude,
+ CONF_LATITUDE: xy["x"],
+ CONF_LONGITUDE: xy["y"],
},
)
diff --git a/homeassistant/components/stookwijzer/config_flow.py b/homeassistant/components/stookwijzer/config_flow.py
index 32b4836763f..ff14bce26e6 100644
--- a/homeassistant/components/stookwijzer/config_flow.py
+++ b/homeassistant/components/stookwijzer/config_flow.py
@@ -9,7 +9,6 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import LocationSelector
from .const import DOMAIN
@@ -26,15 +25,14 @@ class StookwijzerFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a flow initialized by the user."""
errors = {}
if user_input is not None:
- latitude, longitude = await Stookwijzer.async_transform_coordinates(
- async_get_clientsession(self.hass),
+ xy = await Stookwijzer.async_transform_coordinates(
user_input[CONF_LOCATION][CONF_LATITUDE],
user_input[CONF_LOCATION][CONF_LONGITUDE],
)
- if latitude and longitude:
+ if xy:
return self.async_create_entry(
title="Stookwijzer",
- data={CONF_LATITUDE: latitude, CONF_LONGITUDE: longitude},
+ data={CONF_LATITUDE: xy["x"], CONF_LONGITUDE: xy["y"]},
)
errors["base"] = "unknown"
diff --git a/homeassistant/components/stookwijzer/diagnostics.py b/homeassistant/components/stookwijzer/diagnostics.py
index 2849e0e976a..1f3ef4ee4ba 100644
--- a/homeassistant/components/stookwijzer/diagnostics.py
+++ b/homeassistant/components/stookwijzer/diagnostics.py
@@ -18,4 +18,5 @@ async def async_get_config_entry_diagnostics(
"advice": client.advice,
"air_quality_index": client.lki,
"windspeed_ms": client.windspeed_ms,
+ "forecast": await client.async_get_forecast(),
}
diff --git a/homeassistant/components/stookwijzer/manifest.json b/homeassistant/components/stookwijzer/manifest.json
index 3fe16fb3d33..dd10f57f485 100644
--- a/homeassistant/components/stookwijzer/manifest.json
+++ b/homeassistant/components/stookwijzer/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/stookwijzer",
"integration_type": "service",
"iot_class": "cloud_polling",
- "requirements": ["stookwijzer==1.5.1"]
+ "requirements": ["stookwijzer==1.6.1"]
}
diff --git a/homeassistant/components/stookwijzer/sensor.py b/homeassistant/components/stookwijzer/sensor.py
index 2660ff2ddb2..91224b711be 100644
--- a/homeassistant/components/stookwijzer/sensor.py
+++ b/homeassistant/components/stookwijzer/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import UnitOfSpeed
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -59,7 +59,7 @@ STOOKWIJZER_SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: StookwijzerConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Stookwijzer sensor from a config entry."""
async_add_entities(
diff --git a/homeassistant/components/stookwijzer/strings.json b/homeassistant/components/stookwijzer/strings.json
index 189af89b282..a028f1f19c5 100644
--- a/homeassistant/components/stookwijzer/strings.json
+++ b/homeassistant/components/stookwijzer/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "description": "Select the location you want to recieve the Stookwijzer information for.",
+ "description": "Select the location you want to receive the Stookwijzer information for.",
"data": {
"location": "[%key:common::config_flow::data::location%]"
},
@@ -29,7 +29,7 @@
},
"issues": {
"location_migration_failed": {
- "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integrations uses.\n\nMake sure you are connected to the internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.",
+ "description": "The Stookwijzer integration was unable to automatically migrate your location to a new format the updated integration uses.\n\nMake sure you are connected to the Internet and restart Home Assistant to try again.\n\nIf this doesn't resolve the error, remove and re-add the integration.",
"title": "Migration of your location failed"
}
},
diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py
index 5a0073c25d3..e3e966edde0 100644
--- a/homeassistant/components/streamlabswater/binary_sensor.py
+++ b/homeassistant/components/streamlabswater/binary_sensor.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import StreamlabsCoordinator
from .const import DOMAIN
@@ -15,7 +15,7 @@ from .entity import StreamlabsWaterEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Streamlabs water binary sensor from a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py
index 412b2187495..dea3f081326 100644
--- a/homeassistant/components/streamlabswater/sensor.py
+++ b/homeassistant/components/streamlabswater/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfVolume
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import StreamlabsCoordinator
@@ -60,7 +60,7 @@ SENSORS: tuple[StreamlabsWaterSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Streamlabs water sensor from a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/subaru/device_tracker.py b/homeassistant/components/subaru/device_tracker.py
index d406234c36e..6bcca848ef2 100644
--- a/homeassistant/components/subaru/device_tracker.py
+++ b/homeassistant/components/subaru/device_tracker.py
@@ -9,7 +9,7 @@ from subarulink.const import LATITUDE, LONGITUDE, TIMESTAMP
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -29,7 +29,7 @@ from .const import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Subaru device tracker by config_entry."""
entry: dict = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py
index e21102f0b0c..07caa0d6367 100644
--- a/homeassistant/components/subaru/lock.py
+++ b/homeassistant/components/subaru/lock.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import SERVICE_LOCK, SERVICE_UNLOCK
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, get_device_info
from .const import (
@@ -32,7 +32,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Subaru locks by config_entry."""
entry = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/subaru/sensor.py b/homeassistant/components/subaru/sensor.py
index ba9b7d46b06..aa4c4ee16be 100644
--- a/homeassistant/components/subaru/sensor.py
+++ b/homeassistant/components/subaru/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfPressure, UnitOfVolume
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -141,7 +141,7 @@ EV_SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Subaru sensors by config_entry."""
entry = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json
index 00da729dccd..7525e73f802 100644
--- a/homeassistant/components/subaru/strings.json
+++ b/homeassistant/components/subaru/strings.json
@@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
- "title": "Subaru Starlink Configuration",
+ "title": "Subaru Starlink configuration",
"description": "Please enter your MySubaru credentials\nNOTE: Initial setup may take up to 30 seconds",
"data": {
"username": "[%key:common::config_flow::data::username%]",
@@ -49,7 +49,7 @@
"options": {
"step": {
"init": {
- "title": "Subaru Starlink Options",
+ "title": "Subaru Starlink options",
"description": "When enabled, vehicle polling will send a remote command to your vehicle every 2 hours to obtain new sensor data. Without vehicle polling, new sensor data is only received when the vehicle automatically pushes data (normally after engine shutdown).",
"data": {
"update_enabled": "Enable vehicle polling"
@@ -106,7 +106,7 @@
"fields": {
"door": {
"name": "Door",
- "description": "One of the following: 'all', 'driver', 'tailgate'."
+ "description": "Which door(s) to open."
}
}
}
diff --git a/homeassistant/components/suez_water/coordinator.py b/homeassistant/components/suez_water/coordinator.py
index 38f94b8937e..10d4d3cdbcb 100644
--- a/homeassistant/components/suez_water/coordinator.py
+++ b/homeassistant/components/suez_water/coordinator.py
@@ -20,8 +20,8 @@ class SuezWaterAggregatedAttributes:
this_month_consumption: dict[str, float]
previous_month_consumption: dict[str, float]
- last_year_overall: dict[str, float]
- this_year_overall: dict[str, float]
+ last_year_overall: int
+ this_year_overall: int
history: dict[str, float]
highest_monthly_consumption: float
diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json
index 5d317ea5ba3..f09d2e22633 100644
--- a/homeassistant/components/suez_water/manifest.json
+++ b/homeassistant/components/suez_water/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["pysuez", "regex"],
"quality_scale": "bronze",
- "requirements": ["pysuezV2==2.0.3"]
+ "requirements": ["pysuezV2==2.0.4"]
}
diff --git a/homeassistant/components/suez_water/sensor.py b/homeassistant/components/suez_water/sensor.py
index 1152ebd551b..a162cc6168d 100644
--- a/homeassistant/components/suez_water/sensor.py
+++ b/homeassistant/components/suez_water/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import CURRENCY_EURO, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_COUNTER_ID, DOMAIN
@@ -53,7 +53,7 @@ SENSORS: tuple[SuezWaterSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: SuezWaterConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Suez Water sensor from a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/suez_water/strings.json b/homeassistant/components/suez_water/strings.json
index be2d4849e76..a8632fcb24a 100644
--- a/homeassistant/components/suez_water/strings.json
+++ b/homeassistant/components/suez_water/strings.json
@@ -5,21 +5,21 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]",
- "counter_id": "Meter id"
+ "counter_id": "Meter ID"
},
"data_description": {
"username": "Enter your login associated with your {tout_sur_mon_eau} account",
"password": "Enter your password associated with your {tout_sur_mon_eau} account",
- "counter_id": "Enter your meter id (ex: 12345678). Should be found automatically during setup, if not see integration documentation for more information"
+ "counter_id": "Enter your meter ID (ex: 12345678). Should be found automatically during setup, if not see integration documentation for more information"
},
- "description": "Connect your suez water {tout_sur_mon_eau} account to retrieve your water consumption"
+ "description": "Connect your Suez Water {tout_sur_mon_eau} account to retrieve your water consumption"
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
- "counter_not_found": "Could not find meter id automatically"
+ "counter_not_found": "Could not find meter ID automatically"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py
index e7e621d06cd..a042adb9b83 100644
--- a/homeassistant/components/sun/sensor.py
+++ b/homeassistant/components/sun/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.const import DEGREE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN, SIGNAL_EVENTS_CHANGED, SIGNAL_POSITION_CHANGED
@@ -106,7 +106,9 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: SunConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: SunConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sun sensor platform."""
diff --git a/homeassistant/components/sunweg/__init__.py b/homeassistant/components/sunweg/__init__.py
index 86da0a247b1..0dfed0e6bb3 100644
--- a/homeassistant/components/sunweg/__init__.py
+++ b/homeassistant/components/sunweg/__init__.py
@@ -1,197 +1,39 @@
"""The Sun WEG inverter sensor integration."""
-import datetime
-import json
-import logging
-
-from sunweg.api import APIHelper
-from sunweg.plant import Plant
-
-from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from homeassistant.exceptions import ConfigEntryAuthFailed
-from homeassistant.helpers.typing import StateType, UndefinedType
-from homeassistant.util import Throttle
+from homeassistant.helpers import issue_registry as ir
-from .const import CONF_PLANT_ID, DOMAIN, PLATFORMS, DeviceType
-
-SCAN_INTERVAL = datetime.timedelta(minutes=5)
-
-_LOGGER = logging.getLogger(__name__)
+DOMAIN = "sunweg"
-async def async_setup_entry(
- hass: HomeAssistant, entry: config_entries.ConfigEntry
-) -> bool:
+async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool:
"""Load the saved entities."""
- api = APIHelper(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD])
- if not await hass.async_add_executor_job(api.authenticate):
- raise ConfigEntryAuthFailed("Username or Password may be incorrect!")
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SunWEGData(
- api, entry.data[CONF_PLANT_ID]
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ DOMAIN,
+ is_fixable=False,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="integration_removed",
+ translation_placeholders={
+ "issue": "https://github.com/rokam/sunweg/issues/13",
+ "entries": "/config/integrations/integration/sunweg",
+ },
)
- await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- hass.data[DOMAIN].pop(entry.entry_id)
- if len(hass.data[DOMAIN]) == 0:
- hass.data.pop(DOMAIN)
- return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ return True
-class SunWEGData:
- """The class for handling data retrieval."""
-
- def __init__(
- self,
- api: APIHelper,
- plant_id: int,
- ) -> None:
- """Initialize the probe."""
-
- self.api = api
- self.plant_id = plant_id
- self.data: Plant = None
- self.previous_values: dict = {}
-
- @Throttle(SCAN_INTERVAL)
- def update(self) -> None:
- """Update probe data."""
- _LOGGER.debug("Updating data for plant %s", self.plant_id)
- try:
- self.data = self.api.plant(self.plant_id)
- for inverter in self.data.inverters:
- self.api.complete_inverter(inverter)
- except json.decoder.JSONDecodeError:
- _LOGGER.error("Unable to fetch data from SunWEG server")
- _LOGGER.debug("Finished updating data for plant %s", self.plant_id)
-
- def get_api_value(
- self,
- variable: str,
- device_type: DeviceType,
- inverter_id: int = 0,
- deep_name: str | None = None,
- ):
- """Retrieve from a Plant the desired variable value."""
- if device_type == DeviceType.TOTAL:
- return self.data.__dict__.get(variable)
-
- inverter_list = [i for i in self.data.inverters if i.id == inverter_id]
- if len(inverter_list) == 0:
- return None
- inverter = inverter_list[0]
-
- if device_type == DeviceType.INVERTER:
- return inverter.__dict__.get(variable)
- if device_type == DeviceType.PHASE:
- for phase in inverter.phases:
- if phase.name == deep_name:
- return phase.__dict__.get(variable)
- elif device_type == DeviceType.STRING:
- for mppt in inverter.mppts:
- for string in mppt.strings:
- if string.name == deep_name:
- return string.__dict__.get(variable)
- return None
-
- def get_data(
- self,
- *,
- api_variable_key: str,
- api_variable_unit: str | None,
- deep_name: str | None,
- device_type: DeviceType,
- inverter_id: int,
- name: str | UndefinedType | None,
- native_unit_of_measurement: str | None,
- never_resets: bool,
- previous_value_drop_threshold: float | None,
- ) -> tuple[StateType | datetime.datetime, str | None]:
- """Get the data."""
- _LOGGER.debug(
- "Data request for: %s",
- name,
- )
- variable = api_variable_key
- previous_unit = native_unit_of_measurement
- api_value = self.get_api_value(variable, device_type, inverter_id, deep_name)
- previous_value = self.previous_values.get(variable)
- return_value = api_value
- if api_variable_unit is not None:
- native_unit_of_measurement = self.get_api_value(
- api_variable_unit,
- device_type,
- inverter_id,
- deep_name,
- )
-
- # If we have a 'drop threshold' specified, then check it and correct if needed
- if (
- previous_value_drop_threshold is not None
- and previous_value is not None
- and api_value is not None
- and previous_unit == native_unit_of_measurement
- ):
- _LOGGER.debug(
- (
- "%s - Drop threshold specified (%s), checking for drop... API"
- " Value: %s, Previous Value: %s"
- ),
- name,
- previous_value_drop_threshold,
- api_value,
- previous_value,
- )
- diff = float(api_value) - float(previous_value)
-
- # Check if the value has dropped (negative value i.e. < 0) and it has only
- # dropped by a small amount, if so, use the previous value.
- # Note - The energy dashboard takes care of drops within 10%
- # of the current value, however if the value is low e.g. 0.2
- # and drops by 0.1 it classes as a reset.
- if -(previous_value_drop_threshold) <= diff < 0:
- _LOGGER.debug(
- (
- "Diff is negative, but only by a small amount therefore not a"
- " nightly reset, using previous value (%s) instead of api value"
- " (%s)"
- ),
- previous_value,
- api_value,
- )
- return_value = previous_value
- else:
- _LOGGER.debug("%s - No drop detected, using API value", name)
-
- # Lifetime total values should always be increasing, they will never reset,
- # however the API sometimes returns 0 values when the clock turns to 00:00
- # local time in that scenario we should just return the previous value
- # Scenarios:
- # 1 - System has a genuine 0 value when it it first commissioned:
- # - will return 0 until a non-zero value is registered
- # 2 - System has been running fine but temporarily resets to 0 briefly
- # at midnight:
- # - will return the previous value
- # 3 - HA is restarted during the midnight 'outage' - Not handled:
- # - Previous value will not exist meaning 0 will be returned
- # - This is an edge case that would be better handled by looking
- # up the previous value of the entity from the recorder
- if never_resets and api_value == 0 and previous_value:
- _LOGGER.debug(
- (
- "API value is 0, but this value should never reset, returning"
- " previous value (%s) instead"
- ),
- previous_value,
- )
- return_value = previous_value
-
- self.previous_values[variable] = return_value
-
- return (return_value, native_unit_of_measurement)
+async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
+ """Remove a config entry."""
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
+ ir.async_delete_issue(hass, DOMAIN, DOMAIN)
+ # Remove any remaining disabled or ignored entries
+ for _entry in hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.async_remove(_entry.entry_id))
diff --git a/homeassistant/components/sunweg/config_flow.py b/homeassistant/components/sunweg/config_flow.py
index 24df8c02f55..42535a9ef58 100644
--- a/homeassistant/components/sunweg/config_flow.py
+++ b/homeassistant/components/sunweg/config_flow.py
@@ -1,129 +1,11 @@
"""Config flow for Sun WEG integration."""
-from collections.abc import Mapping
-from typing import Any
+from homeassistant.config_entries import ConfigFlow
-from sunweg.api import APIHelper, SunWegApiError
-import voluptuous as vol
-
-from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
-from homeassistant.core import callback
-
-from .const import CONF_PLANT_ID, DOMAIN
+from . import DOMAIN
class SunWEGConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow class."""
VERSION = 1
-
- def __init__(self) -> None:
- """Initialise sun weg server flow."""
- self.api: APIHelper = None
- self.data: dict[str, Any] = {}
-
- @callback
- def _async_show_user_form(self, step_id: str, errors=None) -> ConfigFlowResult:
- """Show the form to the user."""
- default_username = ""
- if CONF_USERNAME in self.data:
- default_username = self.data[CONF_USERNAME]
- data_schema = vol.Schema(
- {
- vol.Required(CONF_USERNAME, default=default_username): str,
- vol.Required(CONF_PASSWORD): str,
- }
- )
-
- return self.async_show_form(
- step_id=step_id, data_schema=data_schema, errors=errors
- )
-
- def _set_auth_data(
- self, step: str, username: str, password: str
- ) -> ConfigFlowResult | None:
- """Set username and password."""
- if self.api:
- # Set username and password
- self.api.username = username
- self.api.password = password
- else:
- # Initialise the library with the username & password
- self.api = APIHelper(username, password)
-
- try:
- if not self.api.authenticate():
- return self._async_show_user_form(step, {"base": "invalid_auth"})
- except SunWegApiError:
- return self._async_show_user_form(step, {"base": "timeout_connect"})
-
- return None
-
- async def async_step_user(self, user_input=None) -> ConfigFlowResult:
- """Handle the start of the config flow."""
- if not user_input:
- return self._async_show_user_form("user")
-
- # Store authentication info
- self.data = user_input
-
- conf_result = await self.hass.async_add_executor_job(
- self._set_auth_data,
- "user",
- user_input[CONF_USERNAME],
- user_input[CONF_PASSWORD],
- )
-
- return await self.async_step_plant() if conf_result is None else conf_result
-
- async def async_step_plant(self, user_input=None) -> ConfigFlowResult:
- """Handle adding a "plant" to Home Assistant."""
- plant_list = await self.hass.async_add_executor_job(self.api.listPlants)
-
- if len(plant_list) == 0:
- return self.async_abort(reason="no_plants")
-
- plants = {plant.id: plant.name for plant in plant_list}
-
- if user_input is None and len(plant_list) > 1:
- data_schema = vol.Schema({vol.Required(CONF_PLANT_ID): vol.In(plants)})
-
- return self.async_show_form(step_id="plant", data_schema=data_schema)
-
- if user_input is None and len(plant_list) == 1:
- user_input = {CONF_PLANT_ID: plant_list[0].id}
-
- user_input[CONF_NAME] = plants[user_input[CONF_PLANT_ID]]
- await self.async_set_unique_id(user_input[CONF_PLANT_ID])
- self._abort_if_unique_id_configured()
- self.data.update(user_input)
- return self.async_create_entry(title=self.data[CONF_NAME], data=self.data)
-
- async def async_step_reauth(
- self, entry_data: Mapping[str, Any]
- ) -> ConfigFlowResult:
- """Handle reauthorization request from SunWEG."""
- self.data.update(entry_data)
- return await self.async_step_reauth_confirm()
-
- async def async_step_reauth_confirm(
- self, user_input: dict[str, Any] | None = None
- ) -> ConfigFlowResult:
- """Handle reauthorization flow."""
- if user_input is None:
- return self._async_show_user_form("reauth_confirm")
-
- self.data.update(user_input)
- conf_result = await self.hass.async_add_executor_job(
- self._set_auth_data,
- "reauth_confirm",
- user_input[CONF_USERNAME],
- user_input[CONF_PASSWORD],
- )
- if conf_result is not None:
- return conf_result
-
- return self.async_update_reload_and_abort(
- self._get_reauth_entry(), data=self.data
- )
diff --git a/homeassistant/components/sunweg/const.py b/homeassistant/components/sunweg/const.py
deleted file mode 100644
index 11d24352962..00000000000
--- a/homeassistant/components/sunweg/const.py
+++ /dev/null
@@ -1,25 +0,0 @@
-"""Define constants for the Sun WEG component."""
-
-from enum import Enum
-
-from homeassistant.const import Platform
-
-
-class DeviceType(Enum):
- """Device Type Enum."""
-
- TOTAL = 1
- INVERTER = 2
- PHASE = 3
- STRING = 4
-
-
-CONF_PLANT_ID = "plant_id"
-
-DEFAULT_PLANT_ID = 0
-
-DEFAULT_NAME = "Sun WEG"
-
-DOMAIN = "sunweg"
-
-PLATFORMS = [Platform.SENSOR]
diff --git a/homeassistant/components/sunweg/manifest.json b/homeassistant/components/sunweg/manifest.json
index 3ebe9ef8cb4..3e5c669f37f 100644
--- a/homeassistant/components/sunweg/manifest.json
+++ b/homeassistant/components/sunweg/manifest.json
@@ -1,10 +1,10 @@
{
"domain": "sunweg",
"name": "Sun WEG",
- "codeowners": ["@rokam"],
+ "codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sunweg",
"iot_class": "cloud_polling",
- "loggers": ["sunweg"],
- "requirements": ["sunweg==3.0.2"]
+ "loggers": [],
+ "requirements": []
}
diff --git a/homeassistant/components/sunweg/sensor/__init__.py b/homeassistant/components/sunweg/sensor/__init__.py
deleted file mode 100644
index e582b5135d3..00000000000
--- a/homeassistant/components/sunweg/sensor/__init__.py
+++ /dev/null
@@ -1,178 +0,0 @@
-"""Read status of SunWEG inverters."""
-
-from __future__ import annotations
-
-import logging
-from types import MappingProxyType
-from typing import Any
-
-from sunweg.api import APIHelper
-from sunweg.device import Inverter
-from sunweg.plant import Plant
-
-from homeassistant.components.sensor import SensorEntity
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_NAME
-from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-
-from .. import SunWEGData
-from ..const import CONF_PLANT_ID, DEFAULT_PLANT_ID, DOMAIN, DeviceType
-from .inverter import INVERTER_SENSOR_TYPES
-from .phase import PHASE_SENSOR_TYPES
-from .sensor_entity_description import SunWEGSensorEntityDescription
-from .string import STRING_SENSOR_TYPES
-from .total import TOTAL_SENSOR_TYPES
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def get_device_list(
- api: APIHelper, config: MappingProxyType[str, Any]
-) -> tuple[list[Inverter], int]:
- """Retrieve the device list for the selected plant."""
- plant_id = int(config[CONF_PLANT_ID])
-
- if plant_id == DEFAULT_PLANT_ID:
- plant_info: list[Plant] = api.listPlants()
- plant_id = plant_info[0].id
-
- devices: list[Inverter] = []
- # Get a list of devices for specified plant to add sensors for.
- for inverter in api.plant(plant_id).inverters:
- api.complete_inverter(inverter)
- devices.append(inverter)
- return (devices, plant_id)
-
-
-async def async_setup_entry(
- hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
-) -> None:
- """Set up the SunWEG sensor."""
- name = config_entry.data[CONF_NAME]
-
- probe: SunWEGData = hass.data[DOMAIN][config_entry.entry_id]
-
- devices, plant_id = await hass.async_add_executor_job(
- get_device_list, probe.api, config_entry.data
- )
-
- entities = [
- SunWEGInverter(
- probe,
- name=f"{name} Total",
- unique_id=f"{plant_id}-{description.key}",
- description=description,
- device_type=DeviceType.TOTAL,
- )
- for description in TOTAL_SENSOR_TYPES
- ]
-
- # Add sensors for each device in the specified plant.
- entities.extend(
- [
- SunWEGInverter(
- probe,
- name=f"{device.name}",
- unique_id=f"{device.sn}-{description.key}",
- description=description,
- device_type=DeviceType.INVERTER,
- inverter_id=device.id,
- )
- for device in devices
- for description in INVERTER_SENSOR_TYPES
- ]
- )
-
- entities.extend(
- [
- SunWEGInverter(
- probe,
- name=f"{device.name} {phase.name}",
- unique_id=f"{device.sn}-{phase.name}-{description.key}",
- description=description,
- inverter_id=device.id,
- device_type=DeviceType.PHASE,
- deep_name=phase.name,
- )
- for device in devices
- for phase in device.phases
- for description in PHASE_SENSOR_TYPES
- ]
- )
-
- entities.extend(
- [
- SunWEGInverter(
- probe,
- name=f"{device.name} {string.name}",
- unique_id=f"{device.sn}-{string.name}-{description.key}",
- description=description,
- inverter_id=device.id,
- device_type=DeviceType.STRING,
- deep_name=string.name,
- )
- for device in devices
- for mppt in device.mppts
- for string in mppt.strings
- for description in STRING_SENSOR_TYPES
- ]
- )
-
- async_add_entities(entities, True)
-
-
-class SunWEGInverter(SensorEntity):
- """Representation of a SunWEG Sensor."""
-
- entity_description: SunWEGSensorEntityDescription
-
- def __init__(
- self,
- probe: SunWEGData,
- name: str,
- unique_id: str,
- description: SunWEGSensorEntityDescription,
- device_type: DeviceType,
- inverter_id: int = 0,
- deep_name: str | None = None,
- ) -> None:
- """Initialize a sensor."""
- self.probe = probe
- self.entity_description = description
- self.device_type = device_type
- self.inverter_id = inverter_id
- self.deep_name = deep_name
-
- self._attr_name = f"{name} {description.name}"
- self._attr_unique_id = unique_id
- self._attr_icon = (
- description.icon if description.icon is not None else "mdi:solar-power"
- )
-
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, str(probe.plant_id))},
- manufacturer="SunWEG",
- name=name,
- )
-
- def update(self) -> None:
- """Get the latest data from the Sun WEG API and updates the state."""
- self.probe.update()
- (
- self._attr_native_value,
- self._attr_native_unit_of_measurement,
- ) = self.probe.get_data(
- api_variable_key=self.entity_description.api_variable_key,
- api_variable_unit=self.entity_description.api_variable_unit,
- deep_name=self.deep_name,
- device_type=self.device_type,
- inverter_id=self.inverter_id,
- name=self.entity_description.name,
- native_unit_of_measurement=self.native_unit_of_measurement,
- never_resets=self.entity_description.never_resets,
- previous_value_drop_threshold=self.entity_description.previous_value_drop_threshold,
- )
diff --git a/homeassistant/components/sunweg/sensor/inverter.py b/homeassistant/components/sunweg/sensor/inverter.py
deleted file mode 100644
index 1010488b38a..00000000000
--- a/homeassistant/components/sunweg/sensor/inverter.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""SunWEG Sensor definitions for the Inverter type."""
-
-from __future__ import annotations
-
-from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
-from homeassistant.const import (
- UnitOfEnergy,
- UnitOfFrequency,
- UnitOfPower,
- UnitOfTemperature,
-)
-
-from .sensor_entity_description import SunWEGSensorEntityDescription
-
-INVERTER_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = (
- SunWEGSensorEntityDescription(
- key="inverter_energy_today",
- name="Energy today",
- api_variable_key="_today_energy",
- api_variable_unit="_today_energy_metric",
- native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
- device_class=SensorDeviceClass.ENERGY,
- state_class=SensorStateClass.TOTAL_INCREASING,
- suggested_display_precision=1,
- ),
- SunWEGSensorEntityDescription(
- key="inverter_energy_total",
- name="Lifetime energy output",
- api_variable_key="_total_energy",
- api_variable_unit="_total_energy_metric",
- native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
- device_class=SensorDeviceClass.ENERGY,
- suggested_display_precision=1,
- state_class=SensorStateClass.TOTAL,
- never_resets=True,
- ),
- SunWEGSensorEntityDescription(
- key="inverter_frequency",
- name="AC frequency",
- api_variable_key="_frequency",
- native_unit_of_measurement=UnitOfFrequency.HERTZ,
- device_class=SensorDeviceClass.FREQUENCY,
- suggested_display_precision=1,
- ),
- SunWEGSensorEntityDescription(
- key="inverter_current_wattage",
- name="Output power",
- api_variable_key="_power",
- api_variable_unit="_power_metric",
- native_unit_of_measurement=UnitOfPower.WATT,
- device_class=SensorDeviceClass.POWER,
- state_class=SensorStateClass.MEASUREMENT,
- suggested_display_precision=1,
- ),
- SunWEGSensorEntityDescription(
- key="inverter_temperature",
- name="Temperature",
- api_variable_key="_temperature",
- native_unit_of_measurement=UnitOfTemperature.CELSIUS,
- device_class=SensorDeviceClass.TEMPERATURE,
- icon="mdi:temperature-celsius",
- suggested_display_precision=1,
- ),
- SunWEGSensorEntityDescription(
- key="inverter_power_factor",
- name="Power Factor",
- api_variable_key="_power_factor",
- suggested_display_precision=1,
- ),
-)
diff --git a/homeassistant/components/sunweg/sensor/phase.py b/homeassistant/components/sunweg/sensor/phase.py
deleted file mode 100644
index d9db6c7c714..00000000000
--- a/homeassistant/components/sunweg/sensor/phase.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""SunWEG Sensor definitions for the Phase type."""
-
-from __future__ import annotations
-
-from homeassistant.components.sensor import SensorDeviceClass
-from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential
-
-from .sensor_entity_description import SunWEGSensorEntityDescription
-
-PHASE_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = (
- SunWEGSensorEntityDescription(
- key="voltage",
- name="Voltage",
- api_variable_key="_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- suggested_display_precision=2,
- ),
- SunWEGSensorEntityDescription(
- key="amperage",
- name="Amperage",
- api_variable_key="_amperage",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- suggested_display_precision=1,
- ),
-)
diff --git a/homeassistant/components/sunweg/sensor/sensor_entity_description.py b/homeassistant/components/sunweg/sensor/sensor_entity_description.py
deleted file mode 100644
index 8c792ab617f..00000000000
--- a/homeassistant/components/sunweg/sensor/sensor_entity_description.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Sensor Entity Description for the SunWEG integration."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-from homeassistant.components.sensor import SensorEntityDescription
-
-
-@dataclass(frozen=True)
-class SunWEGRequiredKeysMixin:
- """Mixin for required keys."""
-
- api_variable_key: str
-
-
-@dataclass(frozen=True)
-class SunWEGSensorEntityDescription(SensorEntityDescription, SunWEGRequiredKeysMixin):
- """Describes SunWEG sensor entity."""
-
- api_variable_unit: str | None = None
- previous_value_drop_threshold: float | None = None
- never_resets: bool = False
- icon: str | None = None
diff --git a/homeassistant/components/sunweg/sensor/string.py b/homeassistant/components/sunweg/sensor/string.py
deleted file mode 100644
index ec59da5d20d..00000000000
--- a/homeassistant/components/sunweg/sensor/string.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""SunWEG Sensor definitions for the String type."""
-
-from __future__ import annotations
-
-from homeassistant.components.sensor import SensorDeviceClass
-from homeassistant.const import UnitOfElectricCurrent, UnitOfElectricPotential
-
-from .sensor_entity_description import SunWEGSensorEntityDescription
-
-STRING_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = (
- SunWEGSensorEntityDescription(
- key="voltage",
- name="Voltage",
- api_variable_key="_voltage",
- native_unit_of_measurement=UnitOfElectricPotential.VOLT,
- device_class=SensorDeviceClass.VOLTAGE,
- suggested_display_precision=2,
- ),
- SunWEGSensorEntityDescription(
- key="amperage",
- name="Amperage",
- api_variable_key="_amperage",
- native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
- device_class=SensorDeviceClass.CURRENT,
- suggested_display_precision=1,
- ),
-)
diff --git a/homeassistant/components/sunweg/sensor/total.py b/homeassistant/components/sunweg/sensor/total.py
deleted file mode 100644
index 2b94446a165..00000000000
--- a/homeassistant/components/sunweg/sensor/total.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""SunWEG Sensor definitions for Totals."""
-
-from __future__ import annotations
-
-from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
-from homeassistant.const import UnitOfEnergy, UnitOfPower
-
-from .sensor_entity_description import SunWEGSensorEntityDescription
-
-TOTAL_SENSOR_TYPES: tuple[SunWEGSensorEntityDescription, ...] = (
- SunWEGSensorEntityDescription(
- key="total_money_total",
- name="Money lifetime",
- api_variable_key="_saving",
- icon="mdi:cash",
- native_unit_of_measurement="R$",
- suggested_display_precision=2,
- ),
- SunWEGSensorEntityDescription(
- key="total_energy_today",
- name="Energy Today",
- api_variable_key="_today_energy",
- api_variable_unit="_today_energy_metric",
- native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
- device_class=SensorDeviceClass.ENERGY,
- state_class=SensorStateClass.TOTAL_INCREASING,
- ),
- SunWEGSensorEntityDescription(
- key="total_output_power",
- name="Output Power",
- api_variable_key="_total_power",
- native_unit_of_measurement=UnitOfPower.KILO_WATT,
- device_class=SensorDeviceClass.POWER,
- ),
- SunWEGSensorEntityDescription(
- key="total_energy_output",
- name="Lifetime energy output",
- api_variable_key="_total_energy",
- native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
- device_class=SensorDeviceClass.ENERGY,
- state_class=SensorStateClass.TOTAL,
- never_resets=True,
- ),
- SunWEGSensorEntityDescription(
- key="last_update",
- name="Last Update",
- api_variable_key="_last_update",
- device_class=SensorDeviceClass.DATE,
- ),
-)
diff --git a/homeassistant/components/sunweg/strings.json b/homeassistant/components/sunweg/strings.json
index 9ab7be053b1..75abf5d9271 100644
--- a/homeassistant/components/sunweg/strings.json
+++ b/homeassistant/components/sunweg/strings.json
@@ -1,35 +1,8 @@
{
- "config": {
- "abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "no_plants": "No plants have been found on this account",
- "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
- },
- "error": {
- "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
- "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]"
- },
- "step": {
- "plant": {
- "data": {
- "plant_id": "Plant"
- },
- "title": "Select your plant"
- },
- "user": {
- "data": {
- "password": "[%key:common::config_flow::data::password%]",
- "username": "[%key:common::config_flow::data::username%]"
- },
- "title": "Enter your Sun WEG information"
- },
- "reauth_confirm": {
- "data": {
- "password": "[%key:common::config_flow::data::password%]",
- "username": "[%key:common::config_flow::data::username%]"
- },
- "title": "[%key:common::config_flow::title::reauth%]"
- }
+ "issues": {
+ "integration_removed": {
+ "title": "The SunWEG integration has been removed",
+ "description": "The SunWEG integration has been removed from Home Assistant.\n\nThe library that Home Assistant uses to connect with SunWEG services, [doesn't work as expected anymore, demanding daily token renew]({issue}).\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing SunWEG integration entries]({entries})."
}
}
}
diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py
index 3acd768cb30..416d56d1bdd 100644
--- a/homeassistant/components/surepetcare/binary_sensor.py
+++ b/homeassistant/components/surepetcare/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SurePetcareDataCoordinator
@@ -23,7 +23,9 @@ from .entity import SurePetcareEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sure PetCare Flaps binary sensors based on a config entry."""
diff --git a/homeassistant/components/surepetcare/lock.py b/homeassistant/components/surepetcare/lock.py
index f960400bcbc..09fadf8be60 100644
--- a/homeassistant/components/surepetcare/lock.py
+++ b/homeassistant/components/surepetcare/lock.py
@@ -10,7 +10,7 @@ from surepy.enums import EntityType, LockState as SurepyLockState
from homeassistant.components.lock import LockEntity, LockState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SurePetcareDataCoordinator
@@ -18,7 +18,9 @@ from .entity import SurePetcareEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sure PetCare locks on a config entry."""
diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py
index b4e7c6203a3..b012878caf7 100644
--- a/homeassistant/components/surepetcare/sensor.py
+++ b/homeassistant/components/surepetcare/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_VOLTAGE, PERCENTAGE, EntityCategory, UnitOfVolume
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, SURE_BATT_VOLTAGE_DIFF, SURE_BATT_VOLTAGE_LOW
from .coordinator import SurePetcareDataCoordinator
@@ -20,7 +20,9 @@ from .entity import SurePetcareEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Sure PetCare Flaps sensors."""
diff --git a/homeassistant/components/swiss_public_transport/config_flow.py b/homeassistant/components/swiss_public_transport/config_flow.py
index 4dc6efc2e85..872044097d6 100644
--- a/homeassistant/components/swiss_public_transport/config_flow.py
+++ b/homeassistant/components/swiss_public_transport/config_flow.py
@@ -190,7 +190,7 @@ class SwissPublicTransportConfigFlow(ConfigFlow, domain=DOMAIN):
return "cannot_connect"
except OpendataTransportError:
return "bad_config"
- except Exception: # pylint: disable=broad-except
+ except Exception:
_LOGGER.exception("Unknown error")
return "unknown"
return None
diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py
index c8075a6746c..6475fe802c2 100644
--- a/homeassistant/components/swiss_public_transport/sensor.py
+++ b/homeassistant/components/swiss_public_transport/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -90,7 +90,7 @@ SENSORS: tuple[SwissPublicTransportSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SwissPublicTransportConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor from a config entry created in the integrations UI."""
unique_id = config_entry.unique_id
diff --git a/homeassistant/components/swiss_public_transport/strings.json b/homeassistant/components/swiss_public_transport/strings.json
index 1cdbd527467..f1b28f5ed14 100644
--- a/homeassistant/components/swiss_public_transport/strings.json
+++ b/homeassistant/components/swiss_public_transport/strings.json
@@ -83,8 +83,8 @@
},
"services": {
"fetch_connections": {
- "name": "Fetch Connections",
- "description": "Fetch a list of connections from the swiss public transport.",
+ "name": "Fetch connections",
+ "description": "Fetches a list of connections from Swiss public transport.",
"fields": {
"config_entry_id": {
"name": "Instance",
@@ -92,7 +92,7 @@
},
"limit": {
"name": "Limit",
- "description": "Number of connections to fetch from [1-15]"
+ "description": "Number of connections to fetch."
}
}
}
diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json
index 0663384fe2c..b73cf8f849d 100644
--- a/homeassistant/components/switch/strings.json
+++ b/homeassistant/components/switch/strings.json
@@ -25,10 +25,18 @@
}
},
"switch": {
- "name": "[%key:component::switch::title%]"
+ "name": "[%key:component::switch::title%]",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]"
+ }
},
"outlet": {
- "name": "Outlet"
+ "name": "Outlet",
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]"
+ }
}
},
"services": {
diff --git a/homeassistant/components/switch_as_x/cover.py b/homeassistant/components/switch_as_x/cover.py
index 7c6a7ff38ad..8fd9c799bcb 100644
--- a/homeassistant/components/switch_as_x/cover.py
+++ b/homeassistant/components/switch_as_x/cover.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_INVERT
from .entity import BaseInvertableEntity
@@ -29,7 +29,7 @@ from .entity import BaseInvertableEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Cover Switch config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py
index 858379e71df..846e9ae7e80 100644
--- a/homeassistant/components/switch_as_x/fan.py
+++ b/homeassistant/components/switch_as_x/fan.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import BaseToggleEntity
@@ -21,7 +21,7 @@ from .entity import BaseToggleEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Fan Switch config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/switch_as_x/light.py b/homeassistant/components/switch_as_x/light.py
index 59b816f7935..c043a354869 100644
--- a/homeassistant/components/switch_as_x/light.py
+++ b/homeassistant/components/switch_as_x/light.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import BaseToggleEntity
@@ -19,7 +19,7 @@ from .entity import BaseToggleEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Light Switch config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/switch_as_x/lock.py b/homeassistant/components/switch_as_x/lock.py
index 2095b06bd84..946429e0395 100644
--- a/homeassistant/components/switch_as_x/lock.py
+++ b/homeassistant/components/switch_as_x/lock.py
@@ -16,7 +16,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_INVERT
from .entity import BaseInvertableEntity
@@ -25,7 +25,7 @@ from .entity import BaseInvertableEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Lock Switch config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/switch_as_x/siren.py b/homeassistant/components/switch_as_x/siren.py
index 7d9a41d9cd9..b96c7c6e0ea 100644
--- a/homeassistant/components/switch_as_x/siren.py
+++ b/homeassistant/components/switch_as_x/siren.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import BaseToggleEntity
@@ -19,7 +19,7 @@ from .entity import BaseToggleEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Siren Switch config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/switch_as_x/valve.py b/homeassistant/components/switch_as_x/valve.py
index 8626ca3cfb4..2b5f252ac2d 100644
--- a/homeassistant/components/switch_as_x/valve.py
+++ b/homeassistant/components/switch_as_x/valve.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_INVERT
from .entity import BaseInvertableEntity
@@ -29,7 +29,7 @@ from .entity import BaseInvertableEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Valve Switch config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/switchbee/button.py b/homeassistant/components/switchbee/button.py
index 78b5c0e6888..1ac81ec4e0d 100644
--- a/homeassistant/components/switchbee/button.py
+++ b/homeassistant/components/switchbee/button.py
@@ -7,7 +7,7 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SwitchBeeCoordinator
@@ -15,7 +15,9 @@ from .entity import SwitchBeeEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbee button."""
coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py
index d946ed1761b..7837798b0cb 100644
--- a/homeassistant/components/switchbee/climate.py
+++ b/homeassistant/components/switchbee/climate.py
@@ -27,7 +27,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SwitchBeeCoordinator
@@ -74,7 +74,9 @@ SUPPORTED_FAN_MODES = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW]
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBee climate."""
coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/switchbee/cover.py b/homeassistant/components/switchbee/cover.py
index 02f3d7167e3..247063ab18a 100644
--- a/homeassistant/components/switchbee/cover.py
+++ b/homeassistant/components/switchbee/cover.py
@@ -17,7 +17,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SwitchBeeCoordinator
@@ -25,7 +25,9 @@ from .entity import SwitchBeeDeviceEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBee switch."""
coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/switchbee/light.py b/homeassistant/components/switchbee/light.py
index 0daa6e204aa..228667540df 100644
--- a/homeassistant/components/switchbee/light.py
+++ b/homeassistant/components/switchbee/light.py
@@ -11,7 +11,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SwitchBeeCoordinator
@@ -35,7 +35,9 @@ def _switchbee_brightness_to_hass(value: int) -> int:
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBee light."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/switchbee/switch.py b/homeassistant/components/switchbee/switch.py
index c502e6f22f5..41538f6fd71 100644
--- a/homeassistant/components/switchbee/switch.py
+++ b/homeassistant/components/switchbee/switch.py
@@ -17,7 +17,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SwitchBeeCoordinator
@@ -25,7 +25,9 @@ from .entity import SwitchBeeDeviceEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbee switch."""
coordinator: SwitchBeeCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py
index 09bc157d4d2..73b7307aa2d 100644
--- a/homeassistant/components/switchbot/__init__.py
+++ b/homeassistant/components/switchbot/__init__.py
@@ -66,6 +66,12 @@ PLATFORMS_BY_TYPE = {
SupportedModels.RELAY_SWITCH_1.value: [Platform.SWITCH],
SupportedModels.LEAK.value: [Platform.BINARY_SENSOR, Platform.SENSOR],
SupportedModels.REMOTE.value: [Platform.SENSOR],
+ SupportedModels.ROLLER_SHADE.value: [
+ Platform.COVER,
+ Platform.BINARY_SENSOR,
+ Platform.SENSOR,
+ ],
+ SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@@ -80,6 +86,7 @@ CLASS_BY_DEVICE = {
SupportedModels.BLIND_TILT.value: switchbot.SwitchbotBlindTilt,
SupportedModels.RELAY_SWITCH_1PM.value: switchbot.SwitchbotRelaySwitch,
SupportedModels.RELAY_SWITCH_1.value: switchbot.SwitchbotRelaySwitch,
+ SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
}
diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py
index 144872ff315..6d1490c895b 100644
--- a/homeassistant/components/switchbot/binary_sensor.py
+++ b/homeassistant/components/switchbot/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity
@@ -75,7 +75,7 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot curtain based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py
index 16b41d75541..787c1fa720b 100644
--- a/homeassistant/components/switchbot/const.py
+++ b/homeassistant/components/switchbot/const.py
@@ -35,6 +35,8 @@ class SupportedModels(StrEnum):
RELAY_SWITCH_1 = "relay_switch_1"
LEAK = "leak"
REMOTE = "remote"
+ ROLLER_SHADE = "roller_shade"
+ HUBMINI_MATTER = "hubmini_matter"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -51,6 +53,7 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.HUB2: SupportedModels.HUB2,
SwitchbotModel.RELAY_SWITCH_1PM: SupportedModels.RELAY_SWITCH_1PM,
SwitchbotModel.RELAY_SWITCH_1: SupportedModels.RELAY_SWITCH_1,
+ SwitchbotModel.ROLLER_SHADE: SupportedModels.ROLLER_SHADE,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -62,6 +65,7 @@ NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.MOTION_SENSOR: SupportedModels.MOTION,
SwitchbotModel.LEAK: SupportedModels.LEAK,
SwitchbotModel.REMOTE: SupportedModels.REMOTE,
+ SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER,
}
SUPPORTED_MODEL_TYPES = (
diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py
index d2fd073cdcb..bb73339aa05 100644
--- a/homeassistant/components/switchbot/cover.py
+++ b/homeassistant/components/switchbot/cover.py
@@ -17,7 +17,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
@@ -31,12 +31,14 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot curtain based on a config entry."""
coordinator = entry.runtime_data
if isinstance(coordinator.device, switchbot.SwitchbotBlindTilt):
async_add_entities([SwitchBotBlindTiltEntity(coordinator)])
+ elif isinstance(coordinator.device, switchbot.SwitchbotRollerShade):
+ async_add_entities([SwitchBotRollerShadeEntity(coordinator)])
else:
async_add_entities([SwitchBotCurtainEntity(coordinator)])
@@ -154,7 +156,7 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
ATTR_CURRENT_TILT_POSITION
)
self._last_run_success = last_state.attributes.get("last_run_success")
- if (_tilt := self._attr_current_cover_position) is not None:
+ if (_tilt := self._attr_current_cover_tilt_position) is not None:
self._attr_is_closed = (_tilt < self.CLOSED_DOWN_THRESHOLD) or (
_tilt > self.CLOSED_UP_THRESHOLD
)
@@ -199,3 +201,85 @@ class SwitchBotBlindTiltEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
self._attr_is_opening = self.parsed_data["motionDirection"]["opening"]
self._attr_is_closing = self.parsed_data["motionDirection"]["closing"]
self.async_write_ha_state()
+
+
+class SwitchBotRollerShadeEntity(SwitchbotEntity, CoverEntity, RestoreEntity):
+ """Representation of a Switchbot."""
+
+ _device: switchbot.SwitchbotRollerShade
+ _attr_device_class = CoverDeviceClass.SHADE
+ _attr_supported_features = (
+ CoverEntityFeature.OPEN
+ | CoverEntityFeature.CLOSE
+ | CoverEntityFeature.STOP
+ | CoverEntityFeature.SET_POSITION
+ )
+
+ _attr_translation_key = "cover"
+ _attr_name = None
+
+ def __init__(self, coordinator: SwitchbotDataUpdateCoordinator) -> None:
+ """Initialize the switchbot."""
+ super().__init__(coordinator)
+ self._attr_is_closed = None
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added."""
+ await super().async_added_to_hass()
+ last_state = await self.async_get_last_state()
+ if not last_state or ATTR_CURRENT_POSITION not in last_state.attributes:
+ return
+
+ self._attr_current_cover_position = last_state.attributes.get(
+ ATTR_CURRENT_POSITION
+ )
+ self._last_run_success = last_state.attributes.get("last_run_success")
+ if self._attr_current_cover_position is not None:
+ self._attr_is_closed = self._attr_current_cover_position <= 20
+
+ async def async_open_cover(self, **kwargs: Any) -> None:
+ """Open the roller shade."""
+
+ _LOGGER.debug("Switchbot to open roller shade %s", self._address)
+ self._last_run_success = bool(await self._device.open())
+ self._attr_is_opening = self._device.is_opening()
+ self._attr_is_closing = self._device.is_closing()
+ self.async_write_ha_state()
+
+ async def async_close_cover(self, **kwargs: Any) -> None:
+ """Close the roller shade."""
+
+ _LOGGER.debug("Switchbot to close roller shade %s", self._address)
+ self._last_run_success = bool(await self._device.close())
+ self._attr_is_opening = self._device.is_opening()
+ self._attr_is_closing = self._device.is_closing()
+ self.async_write_ha_state()
+
+ async def async_stop_cover(self, **kwargs: Any) -> None:
+ """Stop the moving of roller shade."""
+
+ _LOGGER.debug("Switchbot to stop roller shade %s", self._address)
+ self._last_run_success = bool(await self._device.stop())
+ self._attr_is_opening = self._device.is_opening()
+ self._attr_is_closing = self._device.is_closing()
+ self.async_write_ha_state()
+
+ async def async_set_cover_position(self, **kwargs: Any) -> None:
+ """Move the cover to a specific position."""
+
+ position = kwargs.get(ATTR_POSITION)
+ _LOGGER.debug("Switchbot to move at %d %s", position, self._address)
+ self._last_run_success = bool(await self._device.set_position(position))
+ self._attr_is_opening = self._device.is_opening()
+ self._attr_is_closing = self._device.is_closing()
+ self.async_write_ha_state()
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._attr_is_closing = self._device.is_closing()
+ self._attr_is_opening = self._device.is_opening()
+ self._attr_current_cover_position = self.parsed_data["position"]
+ self._attr_is_closed = self.parsed_data["position"] <= 20
+
+ self.async_write_ha_state()
diff --git a/homeassistant/components/switchbot/entity.py b/homeassistant/components/switchbot/entity.py
index bde69429bc3..282d23bfd1a 100644
--- a/homeassistant/components/switchbot/entity.py
+++ b/homeassistant/components/switchbot/entity.py
@@ -61,7 +61,7 @@ class SwitchbotEntity(
return self.coordinator.device.parsed_data
@property
- def extra_state_attributes(self) -> Mapping[Any, Any]:
+ def extra_state_attributes(self) -> Mapping[str, Any]:
"""Return the state attributes."""
return {"last_run_success": self._last_run_success}
diff --git a/homeassistant/components/switchbot/humidifier.py b/homeassistant/components/switchbot/humidifier.py
index 40f96577842..34a24948df1 100644
--- a/homeassistant/components/switchbot/humidifier.py
+++ b/homeassistant/components/switchbot/humidifier.py
@@ -12,7 +12,7 @@ from homeassistant.components.humidifier import (
HumidifierEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SwitchbotConfigEntry
from .entity import SwitchbotSwitchedEntity
@@ -23,7 +23,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot based on a config entry."""
async_add_entities([SwitchBotHumidifier(entry.runtime_data)])
diff --git a/homeassistant/components/switchbot/light.py b/homeassistant/components/switchbot/light.py
index 927ad5120c7..4b9a7e1b988 100644
--- a/homeassistant/components/switchbot/light.py
+++ b/homeassistant/components/switchbot/light.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Any
+from typing import Any, cast
from switchbot import ColorMode as SwitchBotColorMode, SwitchbotBaseLight
@@ -14,7 +14,7 @@ from homeassistant.components.light import (
LightEntity,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity
@@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switchbot light."""
async_add_entities([SwitchbotLightEntity(entry.runtime_data)])
@@ -68,7 +68,9 @@ class SwitchbotLightEntity(SwitchbotEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Instruct the light to turn on."""
- brightness = round(kwargs.get(ATTR_BRIGHTNESS, self.brightness) / 255 * 100)
+ brightness = round(
+ cast(int, kwargs.get(ATTR_BRIGHTNESS, self.brightness)) / 255 * 100
+ )
if (
self.supported_color_modes
diff --git a/homeassistant/components/switchbot/lock.py b/homeassistant/components/switchbot/lock.py
index a3bee5661b2..6bad154813a 100644
--- a/homeassistant/components/switchbot/lock.py
+++ b/homeassistant/components/switchbot/lock.py
@@ -7,7 +7,7 @@ from switchbot.const import LockStatus
from homeassistant.components.lock import LockEntity, LockEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
@@ -17,7 +17,7 @@ from .entity import SwitchbotEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot lock based on a config entry."""
force_nightlatch = entry.options.get(CONF_LOCK_NIGHTLATCH, DEFAULT_LOCK_NIGHTLATCH)
diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json
index 92a1c25d6f5..f8887f93384 100644
--- a/homeassistant/components/switchbot/manifest.json
+++ b/homeassistant/components/switchbot/manifest.json
@@ -39,5 +39,5 @@
"documentation": "https://www.home-assistant.io/integrations/switchbot",
"iot_class": "local_push",
"loggers": ["switchbot"],
- "requirements": ["PySwitchbot==0.56.0"]
+ "requirements": ["PySwitchbot==0.60.0"]
}
diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py
index 9787521a5e9..d68c913db15 100644
--- a/homeassistant/components/switchbot/sensor.py
+++ b/homeassistant/components/switchbot/sensor.py
@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
+ LIGHT_LUX,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
@@ -20,7 +21,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
from .entity import SwitchbotEntity
@@ -71,9 +72,14 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
),
+ "illuminance": SensorEntityDescription(
+ key="illuminance",
+ native_unit_of_measurement=LIGHT_LUX,
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.ILLUMINANCE,
+ ),
"temperature": SensorEntityDescription(
key="temperature",
- name=None,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
@@ -102,7 +108,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot sensor based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json
index 9c101204dcb..c9f93cce604 100644
--- a/homeassistant/components/switchbot/strings.json
+++ b/homeassistant/components/switchbot/strings.json
@@ -70,6 +70,10 @@
"data": {
"retry_count": "Retry count",
"lock_force_nightlatch": "Force Nightlatch operation mode"
+ },
+ "data_description": {
+ "retry_count": "How many times to retry sending commands to your SwitchBot devices",
+ "lock_force_nightlatch": "Force Nightlatch operation mode even if Nightlatch is not detected"
}
}
}
diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py
index 427496ef20c..fd1e8bb6393 100644
--- a/homeassistant/components/switchbot/switch.py
+++ b/homeassistant/components/switchbot/switch.py
@@ -9,7 +9,7 @@ import switchbot
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.const import STATE_ON
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .coordinator import SwitchbotConfigEntry, SwitchbotDataUpdateCoordinator
@@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot based on a config entry."""
async_add_entities([SwitchBotSwitch(entry.runtime_data)])
diff --git a/homeassistant/components/switchbot_cloud/button.py b/homeassistant/components/switchbot_cloud/button.py
index a6eb1a134a5..aae2758f3ca 100644
--- a/homeassistant/components/switchbot_cloud/button.py
+++ b/homeassistant/components/switchbot_cloud/button.py
@@ -7,7 +7,7 @@ from switchbot_api import BotCommands
from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
from .const import DOMAIN
@@ -17,7 +17,7 @@ from .entity import SwitchBotCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py
index 9e996649e8c..27698420ae9 100644
--- a/homeassistant/components/switchbot_cloud/climate.py
+++ b/homeassistant/components/switchbot_cloud/climate.py
@@ -13,7 +13,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
from .const import DOMAIN
@@ -42,7 +42,7 @@ _DEFAULT_SWITCHBOT_FAN_MODE = _SWITCHBOT_FAN_MODES[FanState.FAN_AUTO]
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py
index 52f48c66d38..74a9e9d8b1e 100644
--- a/homeassistant/components/switchbot_cloud/lock.py
+++ b/homeassistant/components/switchbot_cloud/lock.py
@@ -7,7 +7,7 @@ from switchbot_api import LockCommands
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
from .const import DOMAIN
@@ -17,7 +17,7 @@ from .entity import SwitchBotCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py
index 1f755c141a2..28384ffd4d5 100644
--- a/homeassistant/components/switchbot_cloud/sensor.py
+++ b/homeassistant/components/switchbot_cloud/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
from .const import DOMAIN
@@ -139,7 +139,7 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py
index 22d033625f9..ebe20620d3e 100644
--- a/homeassistant/components/switchbot_cloud/switch.py
+++ b/homeassistant/components/switchbot_cloud/switch.py
@@ -7,7 +7,7 @@ from switchbot_api import CommonCommands, Device, PowerState, Remote, SwitchBotA
from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
from .const import DOMAIN
@@ -18,7 +18,7 @@ from .entity import SwitchBotCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
diff --git a/homeassistant/components/switchbot_cloud/vacuum.py b/homeassistant/components/switchbot_cloud/vacuum.py
index 84db7cfdbb8..9a9ad49626f 100644
--- a/homeassistant/components/switchbot_cloud/vacuum.py
+++ b/homeassistant/components/switchbot_cloud/vacuum.py
@@ -11,7 +11,7 @@ from homeassistant.components.vacuum import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitchbotCloudData
from .const import (
@@ -28,7 +28,7 @@ from .entity import SwitchBotCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up SwitchBot Cloud entry."""
data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id]
diff --git a/homeassistant/components/switcher_kis/button.py b/homeassistant/components/switcher_kis/button.py
index d2686e2e550..30597ed0738 100644
--- a/homeassistant/components/switcher_kis/button.py
+++ b/homeassistant/components/switcher_kis/button.py
@@ -20,7 +20,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitcherConfigEntry
from .const import SIGNAL_DEVICE_ADD
@@ -81,7 +81,7 @@ THERMOSTAT_BUTTONS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SwitcherConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switcher button from config entry."""
diff --git a/homeassistant/components/switcher_kis/climate.py b/homeassistant/components/switcher_kis/climate.py
index 2fc4a331676..c8bf33eca09 100644
--- a/homeassistant/components/switcher_kis/climate.py
+++ b/homeassistant/components/switcher_kis/climate.py
@@ -29,7 +29,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SwitcherConfigEntry
from .const import SIGNAL_DEVICE_ADD
@@ -62,7 +62,7 @@ HA_TO_DEVICE_FAN = {value: key for key, value in DEVICE_FAN_TO_HA.items()}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: SwitcherConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switcher climate from config entry."""
diff --git a/homeassistant/components/switcher_kis/cover.py b/homeassistant/components/switcher_kis/cover.py
index 513b786a033..5d8e4a4b0ac 100644
--- a/homeassistant/components/switcher_kis/cover.py
+++ b/homeassistant/components/switcher_kis/cover.py
@@ -15,7 +15,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SIGNAL_DEVICE_ADD
from .coordinator import SwitcherDataUpdateCoordinator
@@ -28,7 +28,7 @@ API_STOP = "stop_shutter"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switcher cover from config entry."""
diff --git a/homeassistant/components/switcher_kis/light.py b/homeassistant/components/switcher_kis/light.py
index 75156044efa..b9dc78f5bdf 100644
--- a/homeassistant/components/switcher_kis/light.py
+++ b/homeassistant/components/switcher_kis/light.py
@@ -10,7 +10,7 @@ from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SIGNAL_DEVICE_ADD
from .coordinator import SwitcherDataUpdateCoordinator
@@ -22,7 +22,7 @@ API_SET_LIGHT = "set_light"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switcher light from a config entry."""
diff --git a/homeassistant/components/switcher_kis/sensor.py b/homeassistant/components/switcher_kis/sensor.py
index 0ed60e5a721..029d517bb09 100644
--- a/homeassistant/components/switcher_kis/sensor.py
+++ b/homeassistant/components/switcher_kis/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfElectricCurrent, UnitOfPower
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import SIGNAL_DEVICE_ADD
@@ -61,7 +61,7 @@ THERMOSTAT_SENSORS = TEMPERATURE_SENSORS
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switcher sensor from config entry."""
diff --git a/homeassistant/components/switcher_kis/strings.json b/homeassistant/components/switcher_kis/strings.json
index e380711303d..c3cf111199f 100644
--- a/homeassistant/components/switcher_kis/strings.json
+++ b/homeassistant/components/switcher_kis/strings.json
@@ -9,13 +9,21 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"token": "[%key:common::config_flow::data::access_token%]"
+ },
+ "data_description": {
+ "username": "The email address used to sign in to the Switcher app.",
+ "token": "The local control token received from Switcher."
}
},
"reauth_confirm": {
- "description": "Found a Switcher device that requires a token\nEnter your username and token\nFor more information see https://www.home-assistant.io/integrations/switcher_kis/#prerequisites",
+ "description": "[%key:component::switcher_kis::config::step::credentials::description%]",
"data": {
"username": "[%key:common::config_flow::data::username%]",
"token": "[%key:common::config_flow::data::access_token%]"
+ },
+ "data_description": {
+ "username": "[%key:component::switcher_kis::config::step::credentials::data_description::username%]",
+ "token": "[%key:component::switcher_kis::config::step::credentials::data_description::token%]"
}
}
},
diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py
index 7d3d71a0615..30b0b4161b1 100644
--- a/homeassistant/components/switcher_kis/switch.py
+++ b/homeassistant/components/switcher_kis/switch.py
@@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from .const import (
@@ -49,7 +49,7 @@ SERVICE_TURN_ON_WITH_TIMER_SCHEMA: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switcher switch from config entry."""
platform = entity_platform.async_get_current_platform()
diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py
index fc1f9ae8aea..697ea8aea6e 100644
--- a/homeassistant/components/syncthing/sensor.py
+++ b/homeassistant/components/syncthing/sensor.py
@@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from .const import (
@@ -25,7 +25,7 @@ from .const import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Syncthing sensors."""
syncthing = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py
index 2817f4c21ce..f514f538821 100644
--- a/homeassistant/components/syncthru/__init__.py
+++ b/homeassistant/components/syncthru/__init__.py
@@ -2,100 +2,31 @@
from __future__ import annotations
-import asyncio
-from datetime import timedelta
-import logging
+from pysyncthru import SyncThruAPINotSupported
-from pysyncthru import ConnectionMode, SyncThru, SyncThruAPINotSupported
-
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_URL, Platform
+from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import aiohttp_client, device_registry as dr
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import DOMAIN
-
-_LOGGER = logging.getLogger(__name__)
+from .coordinator import SyncThruConfigEntry, SyncthruCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: SyncThruConfigEntry) -> bool:
"""Set up config entry."""
- session = aiohttp_client.async_get_clientsession(hass)
- hass.data.setdefault(DOMAIN, {})
- printer = SyncThru(
- entry.data[CONF_URL], session, connection_mode=ConnectionMode.API
- )
-
- async def async_update_data() -> SyncThru:
- """Fetch data from the printer."""
- try:
- async with asyncio.timeout(10):
- await printer.update()
- except SyncThruAPINotSupported as api_error:
- # if an exception is thrown, printer does not support syncthru
- _LOGGER.debug(
- "Configured printer at %s does not provide SyncThru JSON API",
- printer.url,
- exc_info=api_error,
- )
- raise
-
- # if the printer is offline, we raise an UpdateFailed
- if printer.is_unknown_state():
- raise UpdateFailed(f"Configured printer at {printer.url} does not respond.")
- return printer
-
- coordinator = DataUpdateCoordinator[SyncThru](
- hass,
- _LOGGER,
- config_entry=entry,
- name=DOMAIN,
- update_method=async_update_data,
- update_interval=timedelta(seconds=30),
- )
+ coordinator = SyncthruCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
- hass.data[DOMAIN][entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
if isinstance(coordinator.last_exception, SyncThruAPINotSupported):
# this means that the printer does not support the syncthru JSON API
# and the config should simply be discarded
return False
- device_registry = dr.async_get(hass)
- device_registry.async_get_or_create(
- config_entry_id=entry.entry_id,
- configuration_url=printer.url,
- connections=device_connections(printer),
- manufacturer="Samsung",
- identifiers=device_identifiers(printer),
- model=printer.model(),
- name=printer.hostname(),
- )
-
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: SyncThruConfigEntry) -> bool:
"""Unload the config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- hass.data[DOMAIN].pop(entry.entry_id, None)
- return unload_ok
-
-
-def device_identifiers(printer: SyncThru) -> set[tuple[str, str]] | None:
- """Get device identifiers for device registry."""
- serial = printer.serial_number()
- if serial is None:
- return None
- return {(DOMAIN, serial)}
-
-
-def device_connections(printer: SyncThru) -> set[tuple[str, str]]:
- """Get device connections for device registry."""
- if mac := printer.raw().get("identity", {}).get("mac_addr"):
- return {(dr.CONNECTION_NETWORK_MAC, mac)}
- return set()
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/syncthru/binary_sensor.py b/homeassistant/components/syncthru/binary_sensor.py
index 2b110c2af1d..56edff38680 100644
--- a/homeassistant/components/syncthru/binary_sensor.py
+++ b/homeassistant/components/syncthru/binary_sensor.py
@@ -2,24 +2,21 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
+
from pysyncthru import SyncThru, SyncthruState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
+ BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import (
- CoordinatorEntity,
- DataUpdateCoordinator,
-)
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import device_identifiers
-from .const import DOMAIN
+from .coordinator import SyncThruConfigEntry
+from .entity import SyncthruEntity
SYNCTHRU_STATE_PROBLEM = {
SyncthruState.INVALID: True,
@@ -32,81 +29,47 @@ SYNCTHRU_STATE_PROBLEM = {
}
+@dataclass(frozen=True, kw_only=True)
+class SyncThruBinarySensorDescription(BinarySensorEntityDescription):
+ """Describes Syncthru binary sensor entities."""
+
+ value_fn: Callable[[SyncThru], bool | None]
+
+
+BINARY_SENSORS: tuple[SyncThruBinarySensorDescription, ...] = (
+ SyncThruBinarySensorDescription(
+ key="online",
+ device_class=BinarySensorDeviceClass.CONNECTIVITY,
+ value_fn=lambda printer: printer.is_online(),
+ ),
+ SyncThruBinarySensorDescription(
+ key="problem",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ value_fn=lambda printer: SYNCTHRU_STATE_PROBLEM[printer.device_status()],
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: SyncThruConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
- coordinator: DataUpdateCoordinator[SyncThru] = hass.data[DOMAIN][
- config_entry.entry_id
- ]
+ coordinator = config_entry.runtime_data
- name: str = config_entry.data[CONF_NAME]
- entities = [
- SyncThruOnlineSensor(coordinator, name),
- SyncThruProblemSensor(coordinator, name),
- ]
-
- async_add_entities(entities)
+ async_add_entities(
+ SyncThruBinarySensor(coordinator, description) for description in BINARY_SENSORS
+ )
-class SyncThruBinarySensor(
- CoordinatorEntity[DataUpdateCoordinator[SyncThru]], BinarySensorEntity
-):
+class SyncThruBinarySensor(SyncthruEntity, BinarySensorEntity):
"""Implementation of an abstract Samsung Printer binary sensor platform."""
- def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator)
- self.syncthru = coordinator.data
- self._attr_name = name
- self._id_suffix = ""
+ entity_description: SyncThruBinarySensorDescription
@property
- def unique_id(self):
- """Return unique ID for the sensor."""
- serial = self.syncthru.serial_number()
- return f"{serial}{self._id_suffix}" if serial else None
-
- @property
- def device_info(self) -> DeviceInfo | None:
- """Return device information."""
- if (identifiers := device_identifiers(self.syncthru)) is None:
- return None
- return DeviceInfo(
- identifiers=identifiers,
- )
-
-
-class SyncThruOnlineSensor(SyncThruBinarySensor):
- """Implementation of a sensor that checks whether is turned on/online."""
-
- _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
-
- def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator, name)
- self._id_suffix = "_online"
-
- @property
- def is_on(self):
- """Set the state to whether the printer is online."""
- return self.syncthru.is_online()
-
-
-class SyncThruProblemSensor(SyncThruBinarySensor):
- """Implementation of a sensor that checks whether the printer works correctly."""
-
- _attr_device_class = BinarySensorDeviceClass.PROBLEM
-
- def __init__(self, syncthru, name):
- """Initialize the sensor."""
- super().__init__(syncthru, name)
- self._id_suffix = "_problem"
-
- @property
- def is_on(self):
- """Set the state to whether there is a problem with the printer."""
- return SYNCTHRU_STATE_PROBLEM[self.syncthru.device_status()]
+ def is_on(self) -> bool | None:
+ """Return true if the binary sensor is on."""
+ return self.entity_description.value_fn(self.coordinator.data)
diff --git a/homeassistant/components/syncthru/coordinator.py b/homeassistant/components/syncthru/coordinator.py
new file mode 100644
index 00000000000..0b96b354436
--- /dev/null
+++ b/homeassistant/components/syncthru/coordinator.py
@@ -0,0 +1,46 @@
+"""Coordinator for Syncthru integration."""
+
+import asyncio
+from datetime import timedelta
+import logging
+
+from pysyncthru import ConnectionMode, SyncThru
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_URL
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+type SyncThruConfigEntry = ConfigEntry[SyncthruCoordinator]
+
+
+class SyncthruCoordinator(DataUpdateCoordinator[SyncThru]):
+ """Class to manage fetching Syncthru data."""
+
+ def __init__(self, hass: HomeAssistant, entry: SyncThruConfigEntry) -> None:
+ """Initialize the Syncthru coordinator."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=timedelta(seconds=30),
+ )
+ self.syncthru = SyncThru(
+ entry.data[CONF_URL],
+ async_get_clientsession(hass),
+ connection_mode=ConnectionMode.API,
+ )
+
+ async def _async_update_data(self) -> SyncThru:
+ async with asyncio.timeout(10):
+ await self.syncthru.update()
+ if self.syncthru.is_unknown_state():
+ raise UpdateFailed(
+ f"Configured printer at {self.syncthru.url} does not respond."
+ )
+ return self.syncthru
diff --git a/homeassistant/components/syncthru/diagnostics.py b/homeassistant/components/syncthru/diagnostics.py
new file mode 100644
index 00000000000..169d354ef76
--- /dev/null
+++ b/homeassistant/components/syncthru/diagnostics.py
@@ -0,0 +1,17 @@
+"""Diagnostics support for Syncthru."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from .coordinator import SyncThruConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, entry: SyncThruConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a config entry."""
+
+ return entry.runtime_data.data.raw()
diff --git a/homeassistant/components/syncthru/entity.py b/homeassistant/components/syncthru/entity.py
new file mode 100644
index 00000000000..3f1aecbf0d4
--- /dev/null
+++ b/homeassistant/components/syncthru/entity.py
@@ -0,0 +1,36 @@
+"""Base class for Syncthru entities."""
+
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import EntityDescription
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN
+from .coordinator import SyncthruCoordinator
+
+
+class SyncthruEntity(CoordinatorEntity[SyncthruCoordinator]):
+ """Base class for Syncthru entities."""
+
+ _attr_has_entity_name = True
+
+ def __init__(
+ self, coordinator: SyncthruCoordinator, entity_description: EntityDescription
+ ) -> None:
+ """Initialize the Syncthru entity."""
+ super().__init__(coordinator)
+ self.entity_description = entity_description
+ serial_number = coordinator.syncthru.serial_number()
+ assert serial_number is not None
+ self._attr_unique_id = f"{serial_number}_{entity_description.key}"
+ connections = set()
+ if mac := coordinator.syncthru.raw().get("identity", {}).get("mac_addr"):
+ connections.add((dr.CONNECTION_NETWORK_MAC, mac))
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, serial_number)},
+ connections=connections,
+ configuration_url=coordinator.syncthru.url,
+ manufacturer="Samsung",
+ model=coordinator.syncthru.model(),
+ name=coordinator.syncthru.hostname(),
+ )
diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json
index 461ce9bfd3a..11c688eb9af 100644
--- a/homeassistant/components/syncthru/manifest.json
+++ b/homeassistant/components/syncthru/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/syncthru",
"iot_class": "local_polling",
"loggers": ["pysyncthru"],
- "requirements": ["PySyncThru==0.8.0", "url-normalize==1.4.3"],
+ "requirements": ["PySyncThru==0.8.0", "url-normalize==2.2.0"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:Printer:1",
diff --git a/homeassistant/components/syncthru/quality_scale.yaml b/homeassistant/components/syncthru/quality_scale.yaml
new file mode 100644
index 00000000000..bc65d0828ea
--- /dev/null
+++ b/homeassistant/components/syncthru/quality_scale.yaml
@@ -0,0 +1,86 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: todo
+ config-flow: todo
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not provide additional actions.
+ docs-high-level-description: todo
+ docs-installation-instructions: todo
+ docs-removal-instructions: todo
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does 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: No options to configure
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: todo
+ reauthentication-flow:
+ status: exempt
+ comment: |
+ This integration does not require authentication.
+ test-coverage: todo
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: todo
+ comment: DHCP or zeroconf is still possible
+ discovery:
+ status: todo
+ comment: DHCP or zeroconf is still possible
+ 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:
+ status: exempt
+ comment: |
+ This integration has a fixed single device.
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: |
+ This integration doesn't have any cases where raising an issue is needed.
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration has a fixed single device.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py
index df2ffd99803..569bf65f37d 100644
--- a/homeassistant/components/syncthru/sensor.py
+++ b/homeassistant/components/syncthru/sensor.py
@@ -2,32 +2,19 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any, cast
+
from pysyncthru import SyncThru, SyncthruState
-from homeassistant.components.sensor import SensorEntity
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_NAME, PERCENTAGE
+from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
+from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.update_coordinator import (
- CoordinatorEntity,
- DataUpdateCoordinator,
-)
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from . import device_identifiers
-from .const import DOMAIN
-
-COLORS = ["black", "cyan", "magenta", "yellow"]
-DRUM_COLORS = COLORS
-TONER_COLORS = COLORS
-TRAYS = range(1, 6)
-OUTPUT_TRAYS = range(6)
-DEFAULT_MONITORED_CONDITIONS = []
-DEFAULT_MONITORED_CONDITIONS.extend([f"toner_{key}" for key in TONER_COLORS])
-DEFAULT_MONITORED_CONDITIONS.extend([f"drum_{key}" for key in DRUM_COLORS])
-DEFAULT_MONITORED_CONDITIONS.extend([f"tray_{key}" for key in TRAYS])
-DEFAULT_MONITORED_CONDITIONS.extend([f"output_tray_{key}" for key in OUTPUT_TRAYS])
+from .coordinator import SyncThruConfigEntry
+from .entity import SyncthruEntity
SYNCTHRU_STATE_HUMAN = {
SyncthruState.INVALID: "invalid",
@@ -40,212 +27,141 @@ SYNCTHRU_STATE_HUMAN = {
}
+@dataclass(frozen=True, kw_only=True)
+class SyncThruSensorDescription(SensorEntityDescription):
+ """Describes a SyncThru sensor entity."""
+
+ value_fn: Callable[[SyncThru], str | None]
+ extra_state_attributes_fn: Callable[[SyncThru], dict[str, str | int]] | None = None
+
+
+def get_toner_entity_description(color: str) -> SyncThruSensorDescription:
+ """Get toner entity description for a specific color."""
+ return SyncThruSensorDescription(
+ key=f"toner_{color}",
+ translation_key=f"toner_{color}",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=PERCENTAGE,
+ value_fn=lambda printer: printer.toner_status().get(color, {}).get("remaining"),
+ extra_state_attributes_fn=lambda printer: printer.toner_status().get(color, {}),
+ )
+
+
+def get_drum_entity_description(color: str) -> SyncThruSensorDescription:
+ """Get drum entity description for a specific color."""
+ return SyncThruSensorDescription(
+ key=f"drum_{color}",
+ translation_key=f"drum_{color}",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=PERCENTAGE,
+ value_fn=lambda printer: printer.drum_status().get(color, {}).get("remaining"),
+ extra_state_attributes_fn=lambda printer: printer.drum_status().get(color, {}),
+ )
+
+
+def get_input_tray_entity_description(tray: str) -> SyncThruSensorDescription:
+ """Get input tray entity description for a specific tray."""
+ placeholders = {}
+ translation_key = f"tray_{tray}"
+ if "_" in tray:
+ _, identifier = tray.split("_")
+ placeholders["tray_number"] = identifier
+ translation_key = "tray"
+ return SyncThruSensorDescription(
+ key=f"tray_{tray}",
+ translation_key=translation_key,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ translation_placeholders=placeholders,
+ value_fn=(
+ lambda printer: printer.input_tray_status().get(tray, {}).get("newError")
+ or "Ready"
+ ),
+ extra_state_attributes_fn=(
+ lambda printer: printer.input_tray_status().get(tray, {})
+ ),
+ )
+
+
+def get_output_tray_entity_description(tray: int) -> SyncThruSensorDescription:
+ """Get output tray entity description for a specific tray."""
+ return SyncThruSensorDescription(
+ key=f"output_tray_{tray}",
+ translation_key="output_tray",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ translation_placeholders={"tray_number": str(tray)},
+ value_fn=(
+ lambda printer: printer.output_tray_status().get(tray, {}).get("status")
+ or "Ready"
+ ),
+ extra_state_attributes_fn=(
+ lambda printer: cast(
+ dict[str, str | int], printer.output_tray_status().get(tray, {})
+ )
+ ),
+ )
+
+
+SENSOR_TYPES: tuple[SyncThruSensorDescription, ...] = (
+ SyncThruSensorDescription(
+ key="active_alerts",
+ translation_key="active_alerts",
+ value_fn=lambda printer: printer.raw().get("GXI_ACTIVE_ALERT_TOTAL"),
+ ),
+ SyncThruSensorDescription(
+ key="main",
+ name=None,
+ value_fn=lambda printer: SYNCTHRU_STATE_HUMAN[printer.device_status()],
+ extra_state_attributes_fn=lambda printer: {
+ "display_text": printer.device_status_details(),
+ },
+ ),
+)
+
+
async def async_setup_entry(
hass: HomeAssistant,
- config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ config_entry: SyncThruConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up from config entry."""
- coordinator: DataUpdateCoordinator[SyncThru] = hass.data[DOMAIN][
- config_entry.entry_id
- ]
- printer: SyncThru = coordinator.data
+ coordinator = config_entry.runtime_data
+ printer = coordinator.data
supp_toner = printer.toner_status(filter_supported=True)
supp_drum = printer.drum_status(filter_supported=True)
supp_tray = printer.input_tray_status(filter_supported=True)
supp_output_tray = printer.output_tray_status()
- name: str = config_entry.data[CONF_NAME]
- entities: list[SyncThruSensor] = [
- SyncThruMainSensor(coordinator, name),
- SyncThruActiveAlertSensor(coordinator, name),
+ entities: list[SyncThruSensorDescription] = [
+ get_toner_entity_description(color) for color in supp_toner
]
- entities.extend(SyncThruTonerSensor(coordinator, name, key) for key in supp_toner)
- entities.extend(SyncThruDrumSensor(coordinator, name, key) for key in supp_drum)
- entities.extend(
- SyncThruInputTraySensor(coordinator, name, key) for key in supp_tray
- )
- entities.extend(
- SyncThruOutputTraySensor(coordinator, name, int_key)
- for int_key in supp_output_tray
+ entities.extend(get_drum_entity_description(color) for color in supp_drum)
+ entities.extend(get_input_tray_entity_description(key) for key in supp_tray)
+ entities.extend(get_output_tray_entity_description(key) for key in supp_output_tray)
+
+ async_add_entities(
+ SyncThruSensor(coordinator, description)
+ for description in SENSOR_TYPES + tuple(entities)
)
- async_add_entities(entities)
-
-class SyncThruSensor(CoordinatorEntity[DataUpdateCoordinator[SyncThru]], SensorEntity):
+class SyncThruSensor(SyncthruEntity, SensorEntity):
"""Implementation of an abstract Samsung Printer sensor platform."""
_attr_icon = "mdi:printer"
-
- def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator)
- self.syncthru = coordinator.data
- self._attr_name = name
- self._id_suffix = ""
+ entity_description: SyncThruSensorDescription
@property
- def unique_id(self):
- """Return unique ID for the sensor."""
- serial = self.syncthru.serial_number()
- return f"{serial}{self._id_suffix}" if serial else None
+ def native_value(self) -> str | int | None:
+ """Return the state of the sensor."""
+ return self.entity_description.value_fn(self.coordinator.data)
@property
- def device_info(self) -> DeviceInfo | None:
- """Return device information."""
- if (identifiers := device_identifiers(self.syncthru)) is None:
- return None
- return DeviceInfo(
- identifiers=identifiers,
- )
-
-
-class SyncThruMainSensor(SyncThruSensor):
- """Implementation of the main sensor, conducting the actual polling.
-
- It also shows the detailed state and presents
- the displayed current status message.
- """
-
- _attr_entity_registry_enabled_default = False
-
- def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator, name)
- self._id_suffix = "_main"
-
- @property
- def native_value(self):
- """Set state to human readable version of syncthru status."""
- return SYNCTHRU_STATE_HUMAN[self.syncthru.device_status()]
-
- @property
- def extra_state_attributes(self):
- """Show current printer display text."""
- return {
- "display_text": self.syncthru.device_status_details(),
- }
-
-
-class SyncThruTonerSensor(SyncThruSensor):
- """Implementation of a Samsung Printer toner sensor platform."""
-
- _attr_native_unit_of_measurement = PERCENTAGE
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[SyncThru], name: str, color: str
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator, name)
- self._attr_name = f"{name} Toner {color}"
- self._color = color
- self._id_suffix = f"_toner_{color}"
-
- @property
- def extra_state_attributes(self):
- """Show all data returned for this toner."""
- return self.syncthru.toner_status().get(self._color, {})
-
- @property
- def native_value(self):
- """Show amount of remaining toner."""
- return self.syncthru.toner_status().get(self._color, {}).get("remaining")
-
-
-class SyncThruDrumSensor(SyncThruSensor):
- """Implementation of a Samsung Printer drum sensor platform."""
-
- _attr_native_unit_of_measurement = PERCENTAGE
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[SyncThru], name: str, color: str
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator, name)
- self._attr_name = f"{name} Drum {color}"
- self._color = color
- self._id_suffix = f"_drum_{color}"
-
- @property
- def extra_state_attributes(self):
- """Show all data returned for this drum."""
- return self.syncthru.drum_status().get(self._color, {})
-
- @property
- def native_value(self):
- """Show amount of remaining drum."""
- return self.syncthru.drum_status().get(self._color, {}).get("remaining")
-
-
-class SyncThruInputTraySensor(SyncThruSensor):
- """Implementation of a Samsung Printer input tray sensor platform."""
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[SyncThru], name: str, number: str
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator, name)
- self._attr_name = f"{name} Tray {number}"
- self._number = number
- self._id_suffix = f"_tray_{number}"
-
- @property
- def extra_state_attributes(self):
- """Show all data returned for this input tray."""
- return self.syncthru.input_tray_status().get(self._number, {})
-
- @property
- def native_value(self):
- """Display ready unless there is some error, then display error."""
- tray_state = (
- self.syncthru.input_tray_status().get(self._number, {}).get("newError")
- )
- if tray_state == "":
- tray_state = "Ready"
- return tray_state
-
-
-class SyncThruOutputTraySensor(SyncThruSensor):
- """Implementation of a Samsung Printer output tray sensor platform."""
-
- def __init__(
- self, coordinator: DataUpdateCoordinator[SyncThru], name: str, number: int
- ) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator, name)
- self._attr_name = f"{name} Output Tray {number}"
- self._number = number
- self._id_suffix = f"_output_tray_{number}"
-
- @property
- def extra_state_attributes(self):
- """Show all data returned for this output tray."""
- return self.syncthru.output_tray_status().get(self._number, {})
-
- @property
- def native_value(self):
- """Display ready unless there is some error, then display error."""
- tray_state = (
- self.syncthru.output_tray_status().get(self._number, {}).get("status")
- )
- if tray_state == "":
- tray_state = "Ready"
- return tray_state
-
-
-class SyncThruActiveAlertSensor(SyncThruSensor):
- """Implementation of a Samsung Printer active alerts sensor platform."""
-
- def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None:
- """Initialize the sensor."""
- super().__init__(coordinator, name)
- self._attr_name = f"{name} Active Alerts"
- self._id_suffix = "_active_alerts"
-
- @property
- def native_value(self):
- """Show number of active alerts."""
- return self.syncthru.raw().get("GXI_ACTIVE_ALERT_TOTAL")
+ def extra_state_attributes(self) -> dict[str, Any] | None:
+ """Return the state attributes."""
+ if self.entity_description.extra_state_attributes_fn:
+ return self.entity_description.extra_state_attributes_fn(
+ self.coordinator.data
+ )
+ return None
diff --git a/homeassistant/components/syncthru/strings.json b/homeassistant/components/syncthru/strings.json
index c4087bdee04..d78d51db86d 100644
--- a/homeassistant/components/syncthru/strings.json
+++ b/homeassistant/components/syncthru/strings.json
@@ -23,5 +23,49 @@
}
}
}
+ },
+ "entity": {
+ "sensor": {
+ "toner_black": {
+ "name": "Black toner level"
+ },
+ "toner_cyan": {
+ "name": "Cyan toner level"
+ },
+ "toner_magenta": {
+ "name": "Magenta toner level"
+ },
+ "toner_yellow": {
+ "name": "Yellow toner level"
+ },
+ "drum_black": {
+ "name": "Black drum level"
+ },
+ "drum_cyan": {
+ "name": "Cyan drum level"
+ },
+ "drum_magenta": {
+ "name": "Magenta drum level"
+ },
+ "drum_yellow": {
+ "name": "Yellow drum level"
+ },
+ "tray_mp": {
+ "name": "Multi-purpose tray"
+ },
+ "tray_manual": {
+ "name": "Manual feed tray"
+ },
+ "tray": {
+ "name": "Input tray {tray_number}"
+ },
+ "output_tray": {
+ "name": "Output tray {tray_number}"
+ },
+ "active_alerts": {
+ "name": "Active alerts",
+ "unit_of_measurement": "alerts"
+ }
+ }
}
}
diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py
index 0b8b8731f8f..d9319beb595 100644
--- a/homeassistant/components/synology_dsm/__init__.py
+++ b/homeassistant/components/synology_dsm/__init__.py
@@ -9,9 +9,8 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation
from synology_dsm.api.surveillance_station.camera import SynoCamera
from synology_dsm.exceptions import SynologyDSMNotLoggedInException
-from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL
+from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
@@ -31,15 +30,16 @@ from .const import (
from .coordinator import (
SynologyDSMCameraUpdateCoordinator,
SynologyDSMCentralUpdateCoordinator,
+ SynologyDSMConfigEntry,
+ SynologyDSMData,
SynologyDSMSwitchUpdateCoordinator,
)
-from .models import SynologyDSMData
from .service import async_setup_services
_LOGGER = logging.getLogger(__name__)
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: SynologyDSMConfigEntry) -> bool:
"""Set up Synology DSM sensors."""
# Migrate device identifiers
@@ -68,6 +68,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
options={**entry.options, CONF_BACKUP_SHARE: None, CONF_BACKUP_PATH: None},
)
+ if CONF_SCAN_INTERVAL in entry.options:
+ current_options = {**entry.options}
+ current_options.pop(CONF_SCAN_INTERVAL)
+ hass.config_entries.async_update_entry(entry, options=current_options)
# Continue setup
api = SynoApi(hass, entry)
@@ -116,52 +120,62 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except SYNOLOGY_CONNECTION_EXCEPTIONS as ex:
raise ConfigEntryNotReady from ex
- synology_data = SynologyDSMData(
+ entry.runtime_data = SynologyDSMData(
api=api,
coordinator_central=coordinator_central,
+ coordinator_central_old_update_success=True,
coordinator_cameras=coordinator_cameras,
coordinator_switches=coordinator_switches,
)
- hass.data.setdefault(DOMAIN, {})[entry.unique_id] = synology_data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
if entry.options[CONF_BACKUP_SHARE]:
- _async_notify_backup_listeners_soon(hass)
+
+ def async_notify_backup_listeners() -> None:
+ for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
+ listener()
+
+ entry.async_on_unload(
+ entry.async_on_state_change(async_notify_backup_listeners)
+ )
+
+ def async_check_last_update_success() -> None:
+ if (
+ last := coordinator_central.last_update_success
+ ) is not entry.runtime_data.coordinator_central_old_update_success:
+ entry.runtime_data.coordinator_central_old_update_success = last
+ async_notify_backup_listeners()
+
+ entry.runtime_data.coordinator_central.async_add_listener(
+ async_check_last_update_success
+ )
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: SynologyDSMConfigEntry
+) -> bool:
"""Unload Synology DSM sensors."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
+ entry_data = entry.runtime_data
await entry_data.api.async_unload()
- hass.data[DOMAIN].pop(entry.unique_id)
- _async_notify_backup_listeners_soon(hass)
return unload_ok
-def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
- for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
- listener()
-
-
-@callback
-def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
- hass.loop.call_soon(_async_notify_backup_listeners, hass)
-
-
-async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def _async_update_listener(
+ hass: HomeAssistant, entry: SynologyDSMConfigEntry
+) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_remove_config_entry_device(
- hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry
+ hass: HomeAssistant, entry: SynologyDSMConfigEntry, device_entry: dr.DeviceEntry
) -> bool:
"""Remove synology_dsm config entry from a device."""
- data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
+ data = entry.runtime_data
api = data.api
assert api.information is not None
serial = api.information.serial
diff --git a/homeassistant/components/synology_dsm/backup.py b/homeassistant/components/synology_dsm/backup.py
index 83c3455bdf1..46e47ebde16 100644
--- a/homeassistant/components/synology_dsm/backup.py
+++ b/homeassistant/components/synology_dsm/backup.py
@@ -14,9 +14,9 @@ from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupAgentError,
+ BackupNotFound,
suggested_filename,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import ChunkAsyncStreamIterator
from homeassistant.helpers.json import json_dumps
@@ -28,7 +28,7 @@ from .const import (
DATA_BACKUP_AGENT_LISTENERS,
DOMAIN,
)
-from .models import SynologyDSMData
+from .coordinator import SynologyDSMConfigEntry
LOGGER = logging.getLogger(__name__)
@@ -46,19 +46,19 @@ async def async_get_backup_agents(
hass: HomeAssistant,
) -> list[BackupAgent]:
"""Return a list of backup agents."""
- if not (
- entries := hass.config_entries.async_loaded_entries(DOMAIN)
- ) or not hass.data.get(DOMAIN):
+ entries: list[SynologyDSMConfigEntry] = hass.config_entries.async_loaded_entries(
+ DOMAIN
+ )
+ if not entries:
LOGGER.debug("No proper config entry found")
return []
- syno_datas: dict[str, SynologyDSMData] = hass.data[DOMAIN]
return [
SynologyDSMBackupAgent(hass, entry, entry.unique_id)
for entry in entries
if entry.unique_id is not None
- and (syno_data := syno_datas.get(entry.unique_id))
- and syno_data.api.file_station
+ and entry.runtime_data.api.file_station
and entry.options.get(CONF_BACKUP_PATH)
+ and entry.runtime_data.coordinator_central.last_update_success
]
@@ -90,7 +90,9 @@ class SynologyDSMBackupAgent(BackupAgent):
domain = DOMAIN
- def __init__(self, hass: HomeAssistant, entry: ConfigEntry, unique_id: str) -> None:
+ def __init__(
+ self, hass: HomeAssistant, entry: SynologyDSMConfigEntry, unique_id: str
+ ) -> None:
"""Initialize the Synology DSM backup agent."""
super().__init__()
LOGGER.debug("Initializing Synology DSM backup agent for %s", entry.unique_id)
@@ -99,8 +101,9 @@ class SynologyDSMBackupAgent(BackupAgent):
self.path = (
f"{entry.options[CONF_BACKUP_SHARE]}/{entry.options[CONF_BACKUP_PATH]}"
)
- syno_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
+ syno_data = entry.runtime_data
self.api = syno_data.api
+ self.backup_base_names: dict[str, str] = {}
@property
def _file_station(self) -> SynoFileStation:
@@ -109,18 +112,18 @@ class SynologyDSMBackupAgent(BackupAgent):
assert self.api.file_station
return self.api.file_station
- async def _async_suggested_filenames(
+ async def _async_backup_filenames(
self,
backup_id: str,
) -> tuple[str, str]:
- """Suggest filenames for the backup.
+ """Return the actual backup filenames.
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: A tuple of tar_filename and meta_filename
"""
- if (backup := await self.async_get_backup(backup_id)) is None:
- raise BackupAgentError("Backup not found")
- return suggested_filenames(backup)
+ await self.async_get_backup(backup_id)
+ base_name = self.backup_base_names[backup_id]
+ return (f"{base_name}.tar", f"{base_name}_meta.json")
async def async_download_backup(
self,
@@ -132,7 +135,7 @@ class SynologyDSMBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups.
:return: An async iterator that yields bytes.
"""
- (filename_tar, _) = await self._async_suggested_filenames(backup_id)
+ (filename_tar, _) = await self._async_backup_filenames(backup_id)
try:
resp = await self._file_station.download_file(
@@ -192,13 +195,7 @@ class SynologyDSMBackupAgent(BackupAgent):
:param backup_id: The ID of the backup that was returned in async_list_backups.
"""
- try:
- (filename_tar, filename_meta) = await self._async_suggested_filenames(
- backup_id
- )
- except BackupAgentError:
- # backup meta data could not be found, so we can't delete the backup
- return
+ (filename_tar, filename_meta) = await self._async_backup_filenames(backup_id)
for filename in (filename_tar, filename_meta):
try:
@@ -247,6 +244,7 @@ class SynologyDSMBackupAgent(BackupAgent):
assert files
backups: dict[str, AgentBackup] = {}
+ backup_base_names: dict[str, str] = {}
for file in files:
if file.name.endswith("_meta.json"):
try:
@@ -255,14 +253,19 @@ class SynologyDSMBackupAgent(BackupAgent):
LOGGER.error("Failed to download meta data: %s", err)
continue
agent_backup = AgentBackup.from_dict(meta_data)
- backups[agent_backup.backup_id] = agent_backup
+ backup_id = agent_backup.backup_id
+ backups[backup_id] = agent_backup
+ backup_base_names[backup_id] = file.name.replace("_meta.json", "")
+ self.backup_base_names = backup_base_names
return backups
async def async_get_backup(
self,
backup_id: str,
**kwargs: Any,
- ) -> AgentBackup | None:
+ ) -> AgentBackup:
"""Return a backup."""
backups = await self._async_list_backups()
- return backups.get(backup_id)
+ if backup_id not in backups:
+ raise BackupNotFound(f"Backup {backup_id} not found")
+ return backups[backup_id]
diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py
index b9c7ff483ea..1ae5fa90760 100644
--- a/homeassistant/components/synology_dsm/binary_sensor.py
+++ b/homeassistant/components/synology_dsm/binary_sensor.py
@@ -12,20 +12,17 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DISKS, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SynoApi
-from .const import DOMAIN
-from .coordinator import SynologyDSMCentralUpdateCoordinator
+from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry
from .entity import (
SynologyDSMBaseEntity,
SynologyDSMDeviceEntity,
SynologyDSMEntityDescription,
)
-from .models import SynologyDSMData
@dataclass(frozen=True, kw_only=True)
@@ -63,10 +60,12 @@ STORAGE_DISK_BINARY_SENSORS: tuple[SynologyDSMBinarySensorEntityDescription, ...
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: SynologyDSMConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Synology NAS binary sensor."""
- data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
+ data = entry.runtime_data
api = data.api
coordinator = data.coordinator_central
assert api.storage is not None
diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py
index fccd0860036..79297b1f1b4 100644
--- a/homeassistant/components/synology_dsm/button.py
+++ b/homeassistant/components/synology_dsm/button.py
@@ -12,15 +12,14 @@ from homeassistant.components.button import (
ButtonEntity,
ButtonEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SynoApi
from .const import DOMAIN
-from .models import SynologyDSMData
+from .coordinator import SynologyDSMConfigEntry
LOGGER = logging.getLogger(__name__)
@@ -52,11 +51,11 @@ BUTTONS: Final = [
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: SynologyDSMConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set buttons for device."""
- data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
+ data = entry.runtime_data
async_add_entities(SynologyDSMButton(data.api, button) for button in BUTTONS)
diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py
index cbf17ec05b4..f393b8efb55 100644
--- a/homeassistant/components/synology_dsm/camera.py
+++ b/homeassistant/components/synology_dsm/camera.py
@@ -16,11 +16,10 @@ from homeassistant.components.camera import (
CameraEntityDescription,
CameraEntityFeature,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SynoApi
from .const import (
@@ -29,9 +28,8 @@ from .const import (
DOMAIN,
SIGNAL_CAMERA_SOURCE_CHANGED,
)
-from .coordinator import SynologyDSMCameraUpdateCoordinator
+from .coordinator import SynologyDSMCameraUpdateCoordinator, SynologyDSMConfigEntry
from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription
-from .models import SynologyDSMData
_LOGGER = logging.getLogger(__name__)
@@ -46,10 +44,12 @@ class SynologyDSMCameraEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: SynologyDSMConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Synology NAS cameras."""
- data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
+ data = entry.runtime_data
if coordinator := data.coordinator_cameras:
async_add_entities(
SynoDSMCamera(data.api, coordinator, camera_id)
diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py
index dfc372e6bde..2e80624ca5d 100644
--- a/homeassistant/components/synology_dsm/common.py
+++ b/homeassistant/components/synology_dsm/common.py
@@ -7,6 +7,7 @@ from collections.abc import Callable
from contextlib import suppress
import logging
+from awesomeversion import AwesomeVersion
from synology_dsm import SynologyDSM
from synology_dsm.api.core.security import SynoCoreSecurity
from synology_dsm.api.core.system import SynoCoreSystem
@@ -35,13 +36,17 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
+from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
+ CONF_BACKUP_PATH,
CONF_DEVICE_TOKEN,
DEFAULT_TIMEOUT,
+ DOMAIN,
EXCEPTION_DETAILS,
EXCEPTION_UNKNOWN,
+ ISSUE_MISSING_BACKUP_SETUP,
SYNOLOGY_CONNECTION_EXCEPTIONS,
)
@@ -131,6 +136,9 @@ class SynoApi:
)
await self.async_login()
+ self.information = self.dsm.information
+ await self.information.update()
+
# check if surveillance station is used
self._with_surveillance_station = bool(
self.dsm.apis.get(SynoSurveillanceStation.CAMERA_API_KEY)
@@ -161,7 +169,10 @@ class SynoApi:
LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex)
# check if file station is used and permitted
- self._with_file_station = bool(self.dsm.apis.get(SynoFileStation.LIST_API_KEY))
+ self._with_file_station = bool(
+ self.information.awesome_version >= AwesomeVersion("6.0")
+ and self.dsm.apis.get(SynoFileStation.LIST_API_KEY)
+ )
if self._with_file_station:
shares: list | None = None
with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS):
@@ -174,6 +185,19 @@ class SynoApi:
" permissions or no writable shared folders available"
)
+ if shares and not self._entry.options.get(CONF_BACKUP_PATH):
+ ir.async_create_issue(
+ self._hass,
+ DOMAIN,
+ f"{ISSUE_MISSING_BACKUP_SETUP}_{self._entry.unique_id}",
+ data={"entry_id": self._entry.entry_id},
+ is_fixable=True,
+ is_persistent=False,
+ severity=ir.IssueSeverity.WARNING,
+ translation_key=ISSUE_MISSING_BACKUP_SETUP,
+ translation_placeholders={"title": self._entry.title},
+ )
+
LOGGER.debug(
"State of File Station during setup of '%s': %s",
self._entry.unique_id,
@@ -300,7 +324,6 @@ class SynoApi:
async def _fetch_device_configuration(self) -> None:
"""Fetch initial device config."""
- self.information = self.dsm.information
self.network = self.dsm.network
await self.network.update()
diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py
index b4453366718..f0da6f8fe47 100644
--- a/homeassistant/components/synology_dsm/config_flow.py
+++ b/homeassistant/components/synology_dsm/config_flow.py
@@ -33,14 +33,12 @@ from homeassistant.const import (
CONF_NAME,
CONF_PASSWORD,
CONF_PORT,
- CONF_SCAN_INTERVAL,
CONF_SSL,
CONF_USERNAME,
CONF_VERIFY_SSL,
)
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
SelectOptionDict,
@@ -67,7 +65,6 @@ from .const import (
DEFAULT_BACKUP_PATH,
DEFAULT_PORT,
DEFAULT_PORT_SSL,
- DEFAULT_SCAN_INTERVAL,
DEFAULT_SNAPSHOT_QUALITY,
DEFAULT_TIMEOUT,
DEFAULT_USE_SSL,
@@ -75,7 +72,7 @@ from .const import (
DOMAIN,
SYNOLOGY_CONNECTION_EXCEPTIONS,
)
-from .models import SynologyDSMData
+from .coordinator import SynologyDSMConfigEntry
_LOGGER = logging.getLogger(__name__)
@@ -134,7 +131,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: SynologyDSMConfigEntry,
) -> SynologyDSMOptionsFlowHandler:
"""Get the options flow for this handler."""
return SynologyDSMOptionsFlowHandler()
@@ -447,6 +444,8 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
class SynologyDSMOptionsFlowHandler(OptionsFlow):
"""Handle a option flow."""
+ config_entry: SynologyDSMConfigEntry
+
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -454,16 +453,10 @@ class SynologyDSMOptionsFlowHandler(OptionsFlow):
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
- syno_data: SynologyDSMData = self.hass.data[DOMAIN][self.config_entry.unique_id]
+ syno_data = self.config_entry.runtime_data
data_schema = vol.Schema(
{
- vol.Required(
- CONF_SCAN_INTERVAL,
- default=self.config_entry.options.get(
- CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL
- ),
- ): cv.positive_int,
vol.Required(
CONF_SNAPSHOT_QUALITY,
default=self.config_entry.options.get(
diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py
index dbee85b99d6..758fad53970 100644
--- a/homeassistant/components/synology_dsm/const.py
+++ b/homeassistant/components/synology_dsm/const.py
@@ -35,6 +35,8 @@ PLATFORMS = [
EXCEPTION_DETAILS = "details"
EXCEPTION_UNKNOWN = "unknown"
+ISSUE_MISSING_BACKUP_SETUP = "missing_backup_setup"
+
# Configuration
CONF_SERIAL = "serial"
CONF_VOLUMES = "volumes"
@@ -48,7 +50,6 @@ DEFAULT_VERIFY_SSL = False
DEFAULT_PORT = 5000
DEFAULT_PORT_SSL = 5001
# Options
-DEFAULT_SCAN_INTERVAL = 15 # min
DEFAULT_TIMEOUT = ClientTimeout(total=60, connect=15)
DEFAULT_SNAPSHOT_QUALITY = SNAPSHOT_PROFILE_BALANCED
DEFAULT_BACKUP_PATH = "ha_backup"
diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py
index 30d1260ef32..dd97dedf65e 100644
--- a/homeassistant/components/synology_dsm/coordinator.py
+++ b/homeassistant/components/synology_dsm/coordinator.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
+from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Any, Concatenate
@@ -14,14 +15,12 @@ from synology_dsm.exceptions import (
)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_SCAN_INTERVAL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .common import SynoApi, raise_config_entry_auth_error
from .const import (
- DEFAULT_SCAN_INTERVAL,
SIGNAL_CAMERA_SOURCE_CHANGED,
SYNOLOGY_AUTH_FAILED_EXCEPTIONS,
SYNOLOGY_CONNECTION_EXCEPTIONS,
@@ -30,6 +29,20 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
+@dataclass
+class SynologyDSMData:
+ """Data for the synology_dsm integration."""
+
+ api: SynoApi
+ coordinator_central: SynologyDSMCentralUpdateCoordinator
+ coordinator_central_old_update_success: bool
+ coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None
+ coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None
+
+
+type SynologyDSMConfigEntry = ConfigEntry[SynologyDSMData]
+
+
def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R](
func: Callable[Concatenate[_T, _P], Awaitable[_R]],
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]]:
@@ -59,12 +72,12 @@ def async_re_login_on_expired[_T: SynologyDSMUpdateCoordinator[Any], **_P, _R](
class SynologyDSMUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""DataUpdateCoordinator base class for synology_dsm."""
- config_entry: ConfigEntry
+ config_entry: SynologyDSMConfigEntry
def __init__(
self,
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: SynologyDSMConfigEntry,
api: SynoApi,
update_interval: timedelta,
) -> None:
@@ -87,7 +100,7 @@ class SynologyDSMSwitchUpdateCoordinator(
def __init__(
self,
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: SynologyDSMConfigEntry,
api: SynoApi,
) -> None:
"""Initialize DataUpdateCoordinator for switch devices."""
@@ -118,18 +131,11 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator[None]):
def __init__(
self,
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: SynologyDSMConfigEntry,
api: SynoApi,
) -> None:
"""Initialize DataUpdateCoordinator for central device."""
- super().__init__(
- hass,
- entry,
- api,
- timedelta(
- minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
- ),
- )
+ super().__init__(hass, entry, api, timedelta(minutes=15))
@async_re_login_on_expired
async def _async_update_data(self) -> None:
@@ -145,7 +151,7 @@ class SynologyDSMCameraUpdateCoordinator(
def __init__(
self,
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: SynologyDSMConfigEntry,
api: SynoApi,
) -> None:
"""Initialize DataUpdateCoordinator for cameras."""
diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py
index b30955ae682..a673be23096 100644
--- a/homeassistant/components/synology_dsm/diagnostics.py
+++ b/homeassistant/components/synology_dsm/diagnostics.py
@@ -6,21 +6,20 @@ from typing import Any
from homeassistant.components.camera import diagnostics as camera_diagnostics
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from .const import CONF_DEVICE_TOKEN, DOMAIN
-from .models import SynologyDSMData
+from .const import CONF_DEVICE_TOKEN
+from .coordinator import SynologyDSMConfigEntry
TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_DEVICE_TOKEN}
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: SynologyDSMConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
+ data = entry.runtime_data
syno_api = data.api
dsm_info = syno_api.dsm.information
diff --git a/homeassistant/components/synology_dsm/manifest.json b/homeassistant/components/synology_dsm/manifest.json
index a083fa5a15f..3804de7f3f1 100644
--- a/homeassistant/components/synology_dsm/manifest.json
+++ b/homeassistant/components/synology_dsm/manifest.json
@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/synology_dsm",
"iot_class": "local_polling",
"loggers": ["synology_dsm"],
- "requirements": ["py-synologydsm-api==2.6.2"],
+ "requirements": ["py-synologydsm-api==2.7.1"],
"ssdp": [
{
"manufacturer": "Synology",
diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py
index d35b262809c..6234f5e8dd0 100644
--- a/homeassistant/components/synology_dsm/media_source.py
+++ b/homeassistant/components/synology_dsm/media_source.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from logging import getLogger
import mimetypes
from aiohttp import web
@@ -22,7 +23,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN, SHARED_SUFFIX
-from .models import SynologyDSMData
+from .coordinator import SynologyDSMConfigEntry, SynologyDSMData
+
+LOGGER = getLogger(__name__)
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
@@ -41,15 +44,13 @@ class SynologyPhotosMediaSourceIdentifier:
"""Split identifier into parts."""
parts = identifier.split("/")
- self.unique_id = None
+ self.unique_id = parts[0]
self.album_id = None
self.cache_key = None
self.file_name = None
self.is_shared = False
self.passphrase = ""
- self.unique_id = parts[0]
-
if len(parts) > 1:
album_parts = parts[1].split("_")
self.album_id = album_parts[0]
@@ -82,7 +83,7 @@ class SynologyPhotosMediaSource(MediaSource):
item: MediaSourceItem,
) -> BrowseMediaSource:
"""Return media."""
- if not self.hass.data.get(DOMAIN):
+ if not self.hass.config_entries.async_loaded_entries(DOMAIN):
raise BrowseError("Diskstation not initialized")
return BrowseMediaSource(
domain=DOMAIN,
@@ -116,7 +117,13 @@ class SynologyPhotosMediaSource(MediaSource):
for entry in self.entries
]
identifier = SynologyPhotosMediaSourceIdentifier(item.identifier)
- diskstation: SynologyDSMData = self.hass.data[DOMAIN][identifier.unique_id]
+ entry: SynologyDSMConfigEntry | None = (
+ self.hass.config_entries.async_entry_for_domain_unique_id(
+ DOMAIN, identifier.unique_id
+ )
+ )
+ assert entry
+ diskstation = entry.runtime_data
assert diskstation.api.photos is not None
if identifier.album_id is None:
@@ -244,7 +251,7 @@ class SynologyDsmMediaView(http.HomeAssistantView):
self, request: web.Request, source_dir_id: str, location: str
) -> web.Response:
"""Start a GET request."""
- if not self.hass.data.get(DOMAIN):
+ if not self.hass.config_entries.async_loaded_entries(DOMAIN):
raise web.HTTPNotFound
# location: {cache_key}/{filename}
cache_key, file_name, passphrase = location.split("/")
@@ -257,7 +264,13 @@ class SynologyDsmMediaView(http.HomeAssistantView):
if not isinstance(mime_type, str):
raise web.HTTPNotFound
- diskstation: SynologyDSMData = self.hass.data[DOMAIN][source_dir_id]
+ entry: SynologyDSMConfigEntry | None = (
+ self.hass.config_entries.async_entry_for_domain_unique_id(
+ DOMAIN, source_dir_id
+ )
+ )
+ assert entry
+ diskstation = entry.runtime_data
assert diskstation.api.photos is not None
item = SynoPhotosItem(image_id, "", "", "", cache_key, "xl", shared, passphrase)
try:
diff --git a/homeassistant/components/synology_dsm/models.py b/homeassistant/components/synology_dsm/models.py
deleted file mode 100644
index 4f51d329ded..00000000000
--- a/homeassistant/components/synology_dsm/models.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""The synology_dsm integration models."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-
-from .common import SynoApi
-from .coordinator import (
- SynologyDSMCameraUpdateCoordinator,
- SynologyDSMCentralUpdateCoordinator,
- SynologyDSMSwitchUpdateCoordinator,
-)
-
-
-@dataclass
-class SynologyDSMData:
- """Data for the synology_dsm integration."""
-
- api: SynoApi
- coordinator_central: SynologyDSMCentralUpdateCoordinator
- coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None
- coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None
diff --git a/homeassistant/components/synology_dsm/repairs.py b/homeassistant/components/synology_dsm/repairs.py
new file mode 100644
index 00000000000..8a4e47a32b5
--- /dev/null
+++ b/homeassistant/components/synology_dsm/repairs.py
@@ -0,0 +1,124 @@
+"""Repair flows for the Synology DSM integration."""
+
+from __future__ import annotations
+
+from contextlib import suppress
+import logging
+from typing import cast
+
+from synology_dsm.api.file_station.models import SynoFileSharedFolder
+import voluptuous as vol
+
+from homeassistant import data_entry_flow
+from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import issue_registry as ir
+from homeassistant.helpers.selector import (
+ SelectOptionDict,
+ SelectSelector,
+ SelectSelectorConfig,
+ SelectSelectorMode,
+)
+
+from .const import (
+ CONF_BACKUP_PATH,
+ CONF_BACKUP_SHARE,
+ DOMAIN,
+ ISSUE_MISSING_BACKUP_SETUP,
+ SYNOLOGY_CONNECTION_EXCEPTIONS,
+)
+from .coordinator import SynologyDSMConfigEntry
+
+LOGGER = logging.getLogger(__name__)
+
+
+class MissingBackupSetupRepairFlow(RepairsFlow):
+ """Handler for an issue fixing flow."""
+
+ def __init__(self, entry: SynologyDSMConfigEntry, issue_id: str) -> None:
+ """Create flow."""
+ self.entry = entry
+ self.issue_id = issue_id
+ super().__init__()
+
+ async def async_step_init(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the first step of a fix flow."""
+
+ return self.async_show_menu(
+ menu_options=["confirm", "ignore"],
+ description_placeholders={
+ "docs_url": "https://www.home-assistant.io/integrations/synology_dsm/#backup-location"
+ },
+ )
+
+ async def async_step_confirm(
+ self, user_input: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the confirm step of a fix flow."""
+
+ syno_data = self.entry.runtime_data
+
+ if user_input is not None:
+ self.hass.config_entries.async_update_entry(
+ self.entry, options={**dict(self.entry.options), **user_input}
+ )
+ return self.async_create_entry(data={})
+
+ shares: list[SynoFileSharedFolder] | None = None
+ if syno_data.api.file_station:
+ with suppress(*SYNOLOGY_CONNECTION_EXCEPTIONS):
+ shares = await syno_data.api.file_station.get_shared_folders(
+ only_writable=True
+ )
+
+ if not shares:
+ return self.async_abort(reason="no_shares")
+
+ return self.async_show_form(
+ data_schema=vol.Schema(
+ {
+ vol.Required(
+ CONF_BACKUP_SHARE,
+ default=self.entry.options[CONF_BACKUP_SHARE],
+ ): SelectSelector(
+ SelectSelectorConfig(
+ options=[
+ SelectOptionDict(value=s.path, label=s.name)
+ for s in shares
+ ],
+ mode=SelectSelectorMode.DROPDOWN,
+ ),
+ ),
+ vol.Required(
+ CONF_BACKUP_PATH,
+ default=self.entry.options[CONF_BACKUP_PATH],
+ ): str,
+ }
+ ),
+ )
+
+ async def async_step_ignore(
+ self, _: dict[str, str] | None = None
+ ) -> data_entry_flow.FlowResult:
+ """Handle the confirm step of a fix flow."""
+ ir.async_ignore_issue(self.hass, DOMAIN, self.issue_id, True)
+ return self.async_abort(reason="ignored")
+
+
+async def async_create_fix_flow(
+ hass: HomeAssistant,
+ issue_id: str,
+ data: dict[str, str | int | float | None] | None,
+) -> RepairsFlow:
+ """Create flow."""
+ entry = None
+ if data and (entry_id := data.get("entry_id")):
+ entry_id = cast(str, entry_id)
+ entry = hass.config_entries.async_get_entry(entry_id)
+
+ if entry and issue_id.startswith(ISSUE_MISSING_BACKUP_SETUP):
+ return MissingBackupSetupRepairFlow(entry, issue_id)
+
+ return ConfirmRepairFlow()
diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py
index b29a33f7253..566885e3989 100644
--- a/homeassistant/components/synology_dsm/sensor.py
+++ b/homeassistant/components/synology_dsm/sensor.py
@@ -16,7 +16,6 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_DISKS,
PERCENTAGE,
@@ -26,19 +25,18 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from . import SynoApi
-from .const import CONF_VOLUMES, DOMAIN, ENTITY_UNIT_LOAD
-from .coordinator import SynologyDSMCentralUpdateCoordinator
+from .const import CONF_VOLUMES, ENTITY_UNIT_LOAD
+from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry
from .entity import (
SynologyDSMBaseEntity,
SynologyDSMDeviceEntity,
SynologyDSMEntityDescription,
)
-from .models import SynologyDSMData
@dataclass(frozen=True, kw_only=True)
@@ -286,10 +284,12 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: SynologyDSMConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Synology NAS Sensor."""
- data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
+ data = entry.runtime_data
api = data.api
coordinator = data.coordinator_central
storage = api.storage
diff --git a/homeassistant/components/synology_dsm/service.py b/homeassistant/components/synology_dsm/service.py
index 366f7d4ba3a..40b6fd4bc30 100644
--- a/homeassistant/components/synology_dsm/service.py
+++ b/homeassistant/components/synology_dsm/service.py
@@ -3,13 +3,14 @@
from __future__ import annotations
import logging
+from typing import cast
from synology_dsm.exceptions import SynologyDSMException
from homeassistant.core import HomeAssistant, ServiceCall
from .const import CONF_SERIAL, DOMAIN, SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES
-from .models import SynologyDSMData
+from .coordinator import SynologyDSMConfigEntry
LOGGER = logging.getLogger(__name__)
@@ -19,11 +20,20 @@ async def async_setup_services(hass: HomeAssistant) -> None:
async def service_handler(call: ServiceCall) -> None:
"""Handle service call."""
- serial = call.data.get(CONF_SERIAL)
- dsm_devices = hass.data[DOMAIN]
+ serial: str | None = call.data.get(CONF_SERIAL)
+ entries: list[SynologyDSMConfigEntry] = (
+ hass.config_entries.async_loaded_entries(DOMAIN)
+ )
+ dsm_devices = {
+ cast(str, entry.unique_id): entry.runtime_data for entry in entries
+ }
if serial:
- dsm_device: SynologyDSMData = hass.data[DOMAIN][serial]
+ entry: SynologyDSMConfigEntry | None = (
+ hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial)
+ )
+ assert entry
+ dsm_device = entry.runtime_data
elif len(dsm_devices) == 1:
dsm_device = next(iter(dsm_devices.values()))
serial = next(iter(dsm_devices))
@@ -39,7 +49,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
return
if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]:
- if serial not in hass.data[DOMAIN]:
+ if serial not in dsm_devices:
LOGGER.error("DSM with specified serial %s not found", serial)
return
LOGGER.debug("%s DSM with serial %s", call.service, serial)
@@ -50,7 +60,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
),
call.service,
)
- dsm_device = hass.data[DOMAIN][serial]
+ dsm_device = dsm_devices[serial]
dsm_api = dsm_device.api
try:
await getattr(dsm_api, f"async_{call.service}")()
diff --git a/homeassistant/components/synology_dsm/strings.json b/homeassistant/components/synology_dsm/strings.json
index d6d40be3fea..f51184ef1cb 100644
--- a/homeassistant/components/synology_dsm/strings.json
+++ b/homeassistant/components/synology_dsm/strings.json
@@ -68,8 +68,6 @@
"step": {
"init": {
"data": {
- "scan_interval": "Minutes between scans",
- "timeout": "Timeout (seconds)",
"snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)",
"backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]",
"backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]"
@@ -187,6 +185,37 @@
}
}
},
+ "issues": {
+ "missing_backup_setup": {
+ "title": "Backup location not configured for {title}",
+ "fix_flow": {
+ "step": {
+ "init": {
+ "description": "The backup location for {title} is not configured. Do you want to set it up now? Details can be found in the integration documentation under [Backup Location]({docs_url})",
+ "menu_options": {
+ "confirm": "Set up the backup location now",
+ "ignore": "Don't set it up now"
+ }
+ },
+ "confirm": {
+ "title": "[%key:component::synology_dsm::config::step::backup_share::title%]",
+ "data": {
+ "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]",
+ "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]"
+ },
+ "data_description": {
+ "backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]",
+ "backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]"
+ }
+ }
+ },
+ "abort": {
+ "no_shares": "There are no shared folders available for the user.\nPlease check the documentation.",
+ "ignored": "The backup location has not been configured.\nYou can still set it up later via the integration options."
+ }
+ }
+ }
+ },
"services": {
"reboot": {
"name": "Reboot",
diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py
index facce824bda..91863ff3a26 100644
--- a/homeassistant/components/synology_dsm/switch.py
+++ b/homeassistant/components/synology_dsm/switch.py
@@ -9,16 +9,14 @@ from typing import Any
from synology_dsm.api.surveillance_station import SynoSurveillanceStation
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import SynoApi
from .const import DOMAIN
-from .coordinator import SynologyDSMSwitchUpdateCoordinator
+from .coordinator import SynologyDSMConfigEntry, SynologyDSMSwitchUpdateCoordinator
from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription
-from .models import SynologyDSMData
_LOGGER = logging.getLogger(__name__)
@@ -40,10 +38,12 @@ SURVEILLANCE_SWITCH: tuple[SynologyDSMSwitchEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: SynologyDSMConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Synology NAS switch."""
- data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
+ data = entry.runtime_data
if coordinator := data.coordinator_switches:
assert coordinator.version is not None
async_add_entities(
diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py
index ed60191f296..3048a38cb9c 100644
--- a/homeassistant/components/synology_dsm/update.py
+++ b/homeassistant/components/synology_dsm/update.py
@@ -9,15 +9,12 @@ from synology_dsm.api.core.upgrade import SynoCoreUpgrade
from yarl import URL
from homeassistant.components.update import UpdateEntity, UpdateEntityDescription
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import SynologyDSMCentralUpdateCoordinator
+from .coordinator import SynologyDSMCentralUpdateCoordinator, SynologyDSMConfigEntry
from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription
-from .models import SynologyDSMData
@dataclass(frozen=True, kw_only=True)
@@ -38,10 +35,12 @@ UPDATE_ENTITIES: Final = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: SynologyDSMConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Synology DSM update entities."""
- data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id]
+ data = entry.runtime_data
async_add_entities(
SynoDSMUpdateEntity(data.api, data.coordinator_central, description)
for description in UPDATE_ENTITIES
diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py
index 3bda29867cc..e1ee57e42b2 100644
--- a/homeassistant/components/system_bridge/__init__.py
+++ b/homeassistant/components/system_bridge/__init__.py
@@ -11,6 +11,7 @@ from systembridgeconnector.exceptions import (
AuthenticationException,
ConnectionClosedException,
ConnectionErrorException,
+ DataMissingException,
)
from systembridgeconnector.version import Version
from systembridgemodels.keyboard_key import KeyboardKey
@@ -184,7 +185,7 @@ async def async_setup_entry(
"host": entry.data[CONF_HOST],
},
) from exception
- except TimeoutError as exception:
+ except (DataMissingException, TimeoutError) as exception:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="timeout",
diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py
index 019b1df4639..0140499a75a 100644
--- a/homeassistant/components/system_bridge/binary_sensor.py
+++ b/homeassistant/components/system_bridge/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SystemBridgeDataUpdateCoordinator
@@ -65,7 +65,9 @@ BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ..
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up System Bridge binary sensor based on a config entry."""
coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py
index 32507f6d84e..235d7e6b986 100644
--- a/homeassistant/components/system_bridge/const.py
+++ b/homeassistant/components/system_bridge/const.py
@@ -18,4 +18,6 @@ MODULES: Final[list[Module]] = [
Module.SYSTEM,
]
-DATA_WAIT_TIMEOUT: Final[int] = 10
+DATA_WAIT_TIMEOUT: Final[int] = 20
+
+GET_DATA_WAIT_TIMEOUT: Final[int] = 15
diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py
index 1690bad4a4d..7e545f39e46 100644
--- a/homeassistant/components/system_bridge/coordinator.py
+++ b/homeassistant/components/system_bridge/coordinator.py
@@ -33,7 +33,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
-from .const import DOMAIN, MODULES
+from .const import DOMAIN, GET_DATA_WAIT_TIMEOUT, MODULES
from .data import SystemBridgeData
@@ -119,7 +119,10 @@ class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[SystemBridgeData])
"""Get data from WebSocket."""
await self.check_websocket_connected()
- modules_data = await self.websocket_client.get_data(GetData(modules=modules))
+ modules_data = await self.websocket_client.get_data(
+ GetData(modules=modules),
+ timeout=GET_DATA_WAIT_TIMEOUT,
+ )
# Merge new data with existing data
for module in MODULES:
diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py
index aeff3b22fb2..6d3bbd21a05 100644
--- a/homeassistant/components/system_bridge/media_player.py
+++ b/homeassistant/components/system_bridge/media_player.py
@@ -18,7 +18,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SystemBridgeDataUpdateCoordinator
@@ -66,7 +66,7 @@ MEDIA_PLAYER_DESCRIPTION: Final[MediaPlayerEntityDescription] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up System Bridge media players based on a config entry."""
coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py
index 94c73a2ac05..d9226e7de6e 100644
--- a/homeassistant/components/system_bridge/sensor.py
+++ b/homeassistant/components/system_bridge/sensor.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED, StateType
from homeassistant.util import dt as dt_util
@@ -251,6 +251,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.GIGAHERTZ,
device_class=SensorDeviceClass.FREQUENCY,
+ suggested_display_precision=2,
icon="mdi:speedometer",
value=cpu_speed,
),
@@ -261,6 +262,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ suggested_display_precision=2,
value=lambda data: data.cpu.temperature,
),
SystemBridgeSensorEntityDescription(
@@ -270,6 +272,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
+ suggested_display_precision=2,
value=lambda data: data.cpu.voltage,
),
SystemBridgeSensorEntityDescription(
@@ -284,6 +287,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
+ suggested_display_precision=2,
icon="mdi:memory",
value=memory_free,
),
@@ -291,6 +295,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
key="memory_used_percentage",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=2,
icon="mdi:memory",
value=lambda data: data.memory.virtual.percent,
),
@@ -301,6 +306,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfInformation.GIGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
+ suggested_display_precision=2,
icon="mdi:memory",
value=memory_used,
),
@@ -322,6 +328,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
translation_key="load",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=1,
icon="mdi:percent",
value=lambda data: data.cpu.usage,
),
@@ -345,6 +352,7 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.BATTERY,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=2,
value=lambda data: data.battery.percentage,
),
SystemBridgeSensorEntityDescription(
@@ -359,7 +367,7 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up System Bridge sensor based on a config entry."""
coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
@@ -381,6 +389,7 @@ async def async_setup_entry(
name=f"{partition.mount_point} space used",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=2,
icon="mdi:harddisk",
value=(
lambda data,
@@ -457,6 +466,7 @@ async def async_setup_entry(
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
+ suggested_display_precision=0,
icon="mdi:monitor",
value=lambda data, k=index: display_refresh_rate(data, k),
),
@@ -476,6 +486,7 @@ async def async_setup_entry(
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
device_class=SensorDeviceClass.FREQUENCY,
+ suggested_display_precision=0,
icon="mdi:speedometer",
value=lambda data, k=index: gpu_core_clock_speed(data, k),
),
@@ -490,6 +501,7 @@ async def async_setup_entry(
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfFrequency.MEGAHERTZ,
device_class=SensorDeviceClass.FREQUENCY,
+ suggested_display_precision=0,
icon="mdi:speedometer",
value=lambda data, k=index: gpu_memory_clock_speed(data, k),
),
@@ -503,6 +515,7 @@ async def async_setup_entry(
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
+ suggested_display_precision=0,
icon="mdi:memory",
value=lambda data, k=index: gpu_memory_free(data, k),
),
@@ -515,6 +528,7 @@ async def async_setup_entry(
name=f"{gpu.name} memory used %",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=2,
icon="mdi:memory",
value=lambda data, k=index: gpu_memory_used_percentage(data, k),
),
@@ -529,6 +543,7 @@ async def async_setup_entry(
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfInformation.MEGABYTES,
device_class=SensorDeviceClass.DATA_SIZE,
+ suggested_display_precision=0,
icon="mdi:memory",
value=lambda data, k=index: gpu_memory_used(data, k),
),
@@ -569,6 +584,7 @@ async def async_setup_entry(
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ suggested_display_precision=2,
value=lambda data, k=index: gpu_temperature(data, k),
),
entry.data[CONF_PORT],
@@ -580,6 +596,7 @@ async def async_setup_entry(
name=f"{gpu.name} usage %",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
+ suggested_display_precision=2,
icon="mdi:percent",
value=lambda data, k=index: gpu_usage_percentage(data, k),
),
@@ -601,6 +618,7 @@ async def async_setup_entry(
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
icon="mdi:percent",
+ suggested_display_precision=2,
value=lambda data, k=cpu.id: cpu_usage_per_cpu(data, k),
),
entry.data[CONF_PORT],
@@ -614,6 +632,7 @@ async def async_setup_entry(
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
icon="mdi:chip",
+ suggested_display_precision=2,
value=lambda data, k=cpu.id: cpu_power_per_cpu(data, k),
),
entry.data[CONF_PORT],
diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json
index ef7495ef74f..1c079c1ef0c 100644
--- a/homeassistant/components/system_bridge/strings.json
+++ b/homeassistant/components/system_bridge/strings.json
@@ -109,7 +109,7 @@
"message": "No data received from {host}"
},
"process_not_found": {
- "message": "Could not find process with id {id}."
+ "message": "Could not find process with ID {id}."
},
"timeout": {
"message": "A timeout occurred for {title} ({host})"
@@ -120,7 +120,7 @@
},
"issues": {
"unsupported_version": {
- "title": "System Bridge Upgrade Required",
+ "title": "System Bridge upgrade required",
"description": "Your version of System Bridge for host {host} is not supported.\n\nPlease upgrade to the latest version."
}
},
diff --git a/homeassistant/components/system_bridge/update.py b/homeassistant/components/system_bridge/update.py
index b0d341cee3b..12060c28669 100644
--- a/homeassistant/components/system_bridge/update.py
+++ b/homeassistant/components/system_bridge/update.py
@@ -6,7 +6,7 @@ from homeassistant.components.update import UpdateEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PORT
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SystemBridgeDataUpdateCoordinator
@@ -16,7 +16,7 @@ from .entity import SystemBridgeEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up System Bridge update based on a config entry."""
coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/systemmonitor/binary_sensor.py b/homeassistant/components/systemmonitor/binary_sensor.py
index aecd30765ff..3968e94ec03 100644
--- a/homeassistant/components/systemmonitor/binary_sensor.py
+++ b/homeassistant/components/systemmonitor/binary_sensor.py
@@ -20,7 +20,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
@@ -91,7 +91,7 @@ SENSOR_TYPES: tuple[SysMonitorBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: SystemMonitorConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up System Monitor binary sensors based on a config entry."""
coordinator = entry.runtime_data.coordinator
diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json
index bd16464b290..9302746aa17 100644
--- a/homeassistant/components/systemmonitor/manifest.json
+++ b/homeassistant/components/systemmonitor/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/systemmonitor",
"iot_class": "local_push",
"loggers": ["psutil"],
- "requirements": ["psutil-home-assistant==0.0.1", "psutil==6.1.1"],
+ "requirements": ["psutil-home-assistant==0.0.1", "psutil==7.0.0"],
"single_config_entry": true
}
diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py
index 048d7cefd6c..e70bccf0833 100644
--- a/homeassistant/components/systemmonitor/sensor.py
+++ b/homeassistant/components/systemmonitor/sensor.py
@@ -31,7 +31,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
@@ -397,7 +397,7 @@ IF_ADDRS_FAMILY = {"ipv4_address": socket.AF_INET, "ipv6_address": socket.AF_INE
async def async_setup_entry(
hass: HomeAssistant,
entry: SystemMonitorConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up System Monitor sensors based on a config entry."""
entities: list[SystemMonitorSensor] = []
diff --git a/homeassistant/components/systemmonitor/strings.json b/homeassistant/components/systemmonitor/strings.json
index fb8a318ff45..134fe390357 100644
--- a/homeassistant/components/systemmonitor/strings.json
+++ b/homeassistant/components/systemmonitor/strings.json
@@ -48,13 +48,13 @@
"name": "Last boot"
},
"load_15m": {
- "name": "Load (15m)"
+ "name": "Load (15 min)"
},
"load_1m": {
- "name": "Load (1m)"
+ "name": "Load (1 min)"
},
"load_5m": {
- "name": "Load (5m)"
+ "name": "Load (5 min)"
},
"memory_free": {
"name": "Memory free"
diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py
index 4087183bfe5..d1994075f12 100644
--- a/homeassistant/components/tado/__init__.py
+++ b/homeassistant/components/tado/__init__.py
@@ -10,12 +10,17 @@ from PyTado.interface import Tado
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant, callback
-from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
+from homeassistant.exceptions import (
+ ConfigEntryAuthFailed,
+ ConfigEntryError,
+ ConfigEntryNotReady,
+)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import (
CONF_FALLBACK,
+ CONF_REFRESH_TOKEN,
CONST_OVERLAY_MANUAL,
CONST_OVERLAY_TADO_DEFAULT,
CONST_OVERLAY_TADO_MODE,
@@ -31,6 +36,7 @@ PLATFORMS = [
Platform.CLIMATE,
Platform.DEVICE_TRACKER,
Platform.SENSOR,
+ Platform.SWITCH,
Platform.WATER_HEATER,
]
@@ -55,23 +61,34 @@ type TadoConfigEntry = ConfigEntry[TadoData]
async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool:
"""Set up Tado from a config entry."""
+ if CONF_REFRESH_TOKEN not in entry.data:
+ raise ConfigEntryAuthFailed
_async_import_options_from_data_if_missing(hass, entry)
_LOGGER.debug("Setting up Tado connection")
+ _LOGGER.debug(
+ "Creating tado instance with refresh token: %s",
+ entry.data[CONF_REFRESH_TOKEN],
+ )
+
+ def create_tado_instance() -> tuple[Tado, str]:
+ """Create a Tado instance, this time with a previously obtained refresh token."""
+ tado = Tado(saved_refresh_token=entry.data[CONF_REFRESH_TOKEN])
+ return tado, tado.device_activation_status()
+
try:
- tado = await hass.async_add_executor_job(
- Tado,
- entry.data[CONF_USERNAME],
- entry.data[CONF_PASSWORD],
- )
+ tado, device_status = await hass.async_add_executor_job(create_tado_instance)
except PyTado.exceptions.TadoWrongCredentialsException as err:
raise ConfigEntryError(f"Invalid Tado credentials. Error: {err}") from err
except PyTado.exceptions.TadoException as err:
raise ConfigEntryNotReady(f"Error during Tado setup: {err}") from err
- _LOGGER.debug(
- "Tado connection established for username: %s", entry.data[CONF_USERNAME]
- )
+ if device_status != "COMPLETED":
+ raise ConfigEntryAuthFailed(
+ f"Device login flow status is {device_status}. Starting re-authentication."
+ )
+
+ _LOGGER.debug("Tado connection established")
coordinator = TadoDataUpdateCoordinator(hass, entry, tado)
await coordinator.async_config_entry_first_refresh()
@@ -81,11 +98,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool
entry.runtime_data = TadoData(coordinator, mobile_coordinator)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(entry.add_update_listener(update_listener))
return True
+async def async_migrate_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool:
+ """Migrate old entry."""
+
+ if entry.version < 2:
+ _LOGGER.debug("Migrating Tado entry to version 2. Current data: %s", entry.data)
+ data = dict(entry.data)
+ data.pop(CONF_USERNAME, None)
+ data.pop(CONF_PASSWORD, None)
+ hass.config_entries.async_update_entry(entry=entry, data=data, version=2)
+ _LOGGER.debug("Migration to version 2 successful")
+ return True
+
+
@callback
def _async_import_options_from_data_if_missing(
hass: HomeAssistant, entry: TadoConfigEntry
@@ -105,11 +134,6 @@ def _async_import_options_from_data_if_missing(
hass.config_entries.async_update_entry(entry, options=options)
-async def update_listener(hass: HomeAssistant, entry: TadoConfigEntry):
- """Handle options update."""
- await hass.config_entries.async_reload(entry.entry_id)
-
-
async def async_unload_entry(hass: HomeAssistant, entry: TadoConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/tado/binary_sensor.py b/homeassistant/components/tado/binary_sensor.py
index c969ea34f42..8cec32e20f0 100644
--- a/homeassistant/components/tado/binary_sensor.py
+++ b/homeassistant/components/tado/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import TadoConfigEntry
@@ -115,7 +115,9 @@ ZONE_SENSORS = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TadoConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tado sensor platform."""
diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py
index db7b1823bd9..e6ae623d1fc 100644
--- a/homeassistant/components/tado/climate.py
+++ b/homeassistant/components/tado/climate.py
@@ -26,7 +26,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import TadoConfigEntry
@@ -100,7 +100,9 @@ CLIMATE_TEMP_OFFSET_SCHEMA: VolDictType = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TadoConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tado climate platform."""
@@ -155,8 +157,8 @@ async def create_climate_entity(
TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF],
TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_SMART_SCHEDULE],
]
- supported_fan_modes = None
- supported_swing_modes = None
+ supported_fan_modes: list[str] | None = None
+ supported_swing_modes: list[str] | None = None
heat_temperatures = None
cool_temperatures = None
@@ -475,11 +477,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity):
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
- # If the target temperature will be None
- # if the device is performing an action
- # that does not affect the temperature or
- # the device is switching states
- return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp
+ if self._current_tado_hvac_mode == CONST_MODE_OFF:
+ return TADO_DEFAULT_MIN_TEMP
+ return self._tado_zone_data.target_temp
async def set_timer(
self,
diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py
index f251a292800..48c3d30cb2b 100644
--- a/homeassistant/components/tado/config_flow.py
+++ b/homeassistant/components/tado/config_flow.py
@@ -2,160 +2,176 @@
from __future__ import annotations
+import asyncio
+from collections.abc import Mapping
import logging
from typing import Any
-import PyTado
+from PyTado.exceptions import TadoException
+from PyTado.http import DeviceActivationStatus
from PyTado.interface import Tado
-import requests.exceptions
import voluptuous as vol
+from yarl import URL
from homeassistant.config_entries import (
+ SOURCE_REAUTH,
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
OptionsFlow,
)
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
-from homeassistant.core import HomeAssistant, callback
+from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.service_info.zeroconf import (
- ATTR_PROPERTIES_ID,
- ZeroconfServiceInfo,
-)
+from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_FALLBACK,
+ CONF_REFRESH_TOKEN,
CONST_OVERLAY_TADO_DEFAULT,
CONST_OVERLAY_TADO_OPTIONS,
DOMAIN,
- UNIQUE_ID,
)
_LOGGER = logging.getLogger(__name__)
-DATA_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_USERNAME): str,
- vol.Required(CONF_PASSWORD): str,
- }
-)
-
-
-async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, Any]:
- """Validate the user input allows us to connect.
-
- Data has the keys from DATA_SCHEMA with values provided by the user.
- """
-
- try:
- tado = await hass.async_add_executor_job(
- Tado, data[CONF_USERNAME], data[CONF_PASSWORD]
- )
- tado_me = await hass.async_add_executor_job(tado.get_me)
- except KeyError as ex:
- raise InvalidAuth from ex
- except RuntimeError as ex:
- raise CannotConnect from ex
- except requests.exceptions.HTTPError as ex:
- if ex.response.status_code > 400 and ex.response.status_code < 500:
- raise InvalidAuth from ex
- raise CannotConnect from ex
-
- if "homes" not in tado_me or len(tado_me["homes"]) == 0:
- raise NoHomes
-
- home = tado_me["homes"][0]
- unique_id = str(home["id"])
- name = home["name"]
-
- return {"title": name, UNIQUE_ID: unique_id}
-
class TadoConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Tado."""
- VERSION = 1
+ VERSION = 2
+ login_task: asyncio.Task | None = None
+ refresh_token: str | None = None
+ tado: Tado | None = None
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle reauth on credential failure."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Prepare reauth."""
+ if user_input is None:
+ return self.async_show_form(step_id="reauth_confirm")
+
+ return await self.async_step_user()
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle the initial step."""
- errors = {}
- if user_input is not None:
+ """Handle users reauth credentials."""
+
+ if self.tado is None:
+ _LOGGER.debug("Initiating device activation")
try:
- validated = await validate_input(self.hass, user_input)
- except CannotConnect:
- errors["base"] = "cannot_connect"
- except InvalidAuth:
- errors["base"] = "invalid_auth"
- except NoHomes:
- errors["base"] = "no_homes"
- except Exception:
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
+ self.tado = await self.hass.async_add_executor_job(Tado)
+ except TadoException:
+ _LOGGER.exception("Error while initiating Tado")
+ return self.async_abort(reason="cannot_connect")
+ assert self.tado is not None
+ tado_device_url = self.tado.device_verification_url()
+ user_code = URL(tado_device_url).query["user_code"]
- if "base" not in errors:
- await self.async_set_unique_id(validated[UNIQUE_ID])
- self._abort_if_unique_id_configured()
- return self.async_create_entry(
- title=validated["title"], data=user_input
- )
+ async def _wait_for_login() -> None:
+ """Wait for the user to login."""
+ assert self.tado is not None
+ _LOGGER.debug("Waiting for device activation")
+ try:
+ await self.hass.async_add_executor_job(self.tado.device_activation)
+ except Exception as ex:
+ _LOGGER.exception("Error while waiting for device activation")
+ raise CannotConnect from ex
- return self.async_show_form(
- step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ if (
+ self.tado.device_activation_status()
+ is not DeviceActivationStatus.COMPLETED
+ ):
+ raise CannotConnect
+
+ _LOGGER.debug("Checking login task")
+ if self.login_task is None:
+ _LOGGER.debug("Creating task for device activation")
+ self.login_task = self.hass.async_create_task(_wait_for_login())
+
+ if self.login_task.done():
+ _LOGGER.debug("Login task is done, checking results")
+ if self.login_task.exception():
+ return self.async_show_progress_done(next_step_id="timeout")
+ self.refresh_token = await self.hass.async_add_executor_job(
+ self.tado.get_refresh_token
+ )
+ return self.async_show_progress_done(next_step_id="finish_login")
+
+ return self.async_show_progress(
+ step_id="user",
+ progress_action="wait_for_device",
+ description_placeholders={
+ "url": tado_device_url,
+ "code": user_code,
+ },
+ progress_task=self.login_task,
)
+ async def async_step_finish_login(
+ self,
+ user_input: dict[str, Any] | None = None,
+ ) -> ConfigFlowResult:
+ """Handle the finalization of reauth."""
+ _LOGGER.debug("Finalizing reauth")
+ assert self.tado is not None
+ tado_me = await self.hass.async_add_executor_job(self.tado.get_me)
+
+ if "homes" not in tado_me or len(tado_me["homes"]) == 0:
+ return self.async_abort(reason="no_homes")
+
+ home = tado_me["homes"][0]
+ unique_id = str(home["id"])
+ name = home["name"]
+
+ if self.source != SOURCE_REAUTH:
+ await self.async_set_unique_id(unique_id)
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(
+ title=name,
+ data={CONF_REFRESH_TOKEN: self.refresh_token},
+ )
+
+ self._abort_if_unique_id_mismatch(reason="reauth_account_mismatch")
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(),
+ data={CONF_REFRESH_TOKEN: self.refresh_token},
+ )
+
+ async def async_step_timeout(
+ self,
+ user_input: dict[str, Any] | None = None,
+ ) -> ConfigFlowResult:
+ """Handle issues that need transition await from progress step."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="timeout",
+ )
+ del self.login_task
+ return await self.async_step_user()
+
async def async_step_homekit(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle HomeKit discovery."""
- self._async_abort_entries_match()
- properties = {
- key.lower(): value for (key, value) in discovery_info.properties.items()
- }
- await self.async_set_unique_id(properties[ATTR_PROPERTIES_ID])
- self._abort_if_unique_id_configured()
- return await self.async_step_user()
+ await self._async_handle_discovery_without_unique_id()
+ return await self.async_step_homekit_confirm()
- async def async_step_reconfigure(
+ async def async_step_homekit_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
- """Handle a reconfiguration flow initialized by the user."""
- errors: dict[str, str] = {}
- reconfigure_entry = self._get_reconfigure_entry()
+ """Prepare for Homekit."""
+ if user_input is None:
+ return self.async_show_form(step_id="homekit_confirm")
- if user_input is not None:
- user_input[CONF_USERNAME] = reconfigure_entry.data[CONF_USERNAME]
- try:
- await validate_input(self.hass, user_input)
- except CannotConnect:
- errors["base"] = "cannot_connect"
- except PyTado.exceptions.TadoWrongCredentialsException:
- errors["base"] = "invalid_auth"
- except NoHomes:
- errors["base"] = "no_homes"
- except Exception: # pylint: disable=broad-except
- _LOGGER.exception("Unexpected exception")
- errors["base"] = "unknown"
-
- if not errors:
- return self.async_update_reload_and_abort(
- reconfigure_entry, data_updates=user_input
- )
-
- return self.async_show_form(
- step_id="reconfigure",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_PASSWORD): str,
- }
- ),
- errors=errors,
- description_placeholders={
- CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME]
- },
- )
+ return await self.async_step_user()
@staticmethod
@callback
@@ -173,8 +189,10 @@ class OptionsFlowHandler(OptionsFlow):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle options flow."""
- if user_input is not None:
- return self.async_create_entry(data=user_input)
+ if user_input:
+ result = self.async_create_entry(data=user_input)
+ await self.hass.config_entries.async_reload(self.config_entry.entry_id)
+ return result
data_schema = vol.Schema(
{
@@ -191,11 +209,3 @@ class OptionsFlowHandler(OptionsFlow):
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
-
-
-class InvalidAuth(HomeAssistantError):
- """Error to indicate there is invalid auth."""
-
-
-class NoHomes(HomeAssistantError):
- """Error to indicate the account has no homes."""
diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py
index bdc4bff1943..7720ff09110 100644
--- a/homeassistant/components/tado/const.py
+++ b/homeassistant/components/tado/const.py
@@ -37,6 +37,7 @@ TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = {
# Configuration
CONF_FALLBACK = "fallback"
CONF_HOME_ID = "home_id"
+CONF_REFRESH_TOKEN = "refresh_token"
DATA = "data"
# Weather
diff --git a/homeassistant/components/tado/coordinator.py b/homeassistant/components/tado/coordinator.py
index 6e932c8ccfc..5f3aa1de1e4 100644
--- a/homeassistant/components/tado/coordinator.py
+++ b/homeassistant/components/tado/coordinator.py
@@ -10,7 +10,6 @@ from PyTado.interface import Tado
from requests import RequestException
from homeassistant.components.climate import PRESET_AWAY, PRESET_HOME
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -20,6 +19,7 @@ if TYPE_CHECKING:
from .const import (
CONF_FALLBACK,
+ CONF_REFRESH_TOKEN,
CONST_OVERLAY_TADO_DEFAULT,
DOMAIN,
INSIDE_TEMPERATURE_MEASUREMENT,
@@ -58,8 +58,7 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
update_interval=SCAN_INTERVAL,
)
self._tado = tado
- self._username = config_entry.data[CONF_USERNAME]
- self._password = config_entry.data[CONF_PASSWORD]
+ self._refresh_token = config_entry.data[CONF_REFRESH_TOKEN]
self._fallback = config_entry.options.get(
CONF_FALLBACK, CONST_OVERLAY_TADO_DEFAULT
)
@@ -108,6 +107,18 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
self.data["weather"] = home["weather"]
self.data["geofence"] = home["geofence"]
+ refresh_token = await self.hass.async_add_executor_job(
+ self._tado.get_refresh_token
+ )
+
+ if refresh_token != self._refresh_token:
+ _LOGGER.debug("New refresh token obtained from Tado: %s", refresh_token)
+ self._refresh_token = refresh_token
+ self.hass.config_entries.async_update_entry(
+ self.config_entry,
+ data={**self.config_entry.data, CONF_REFRESH_TOKEN: refresh_token},
+ )
+
return self.data
async def _async_update_devices(self) -> dict[str, dict]:
@@ -342,6 +353,17 @@ class TadoDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
except RequestException as err:
raise UpdateFailed(f"Error setting Tado meter reading: {err}") from err
+ async def set_child_lock(self, device_id: str, enabled: bool) -> None:
+ """Set child lock of device."""
+ try:
+ await self.hass.async_add_executor_job(
+ self._tado.set_child_lock,
+ device_id,
+ enabled,
+ )
+ except RequestException as exc:
+ raise HomeAssistantError(f"Error setting Tado child lock: {exc}") from exc
+
class TadoMobileDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, dict]]):
"""Class to manage the mobile devices from Tado via PyTado."""
diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py
index a9be560f434..34aca2dd833 100644
--- a/homeassistant/components/tado/device_tracker.py
+++ b/homeassistant/components/tado/device_tracker.py
@@ -11,7 +11,7 @@ from homeassistant.components.device_tracker import (
from homeassistant.const import STATE_HOME, STATE_NOT_HOME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: TadoConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tado device scannery entity."""
_LOGGER.debug("Setting up Tado device scanner entity")
@@ -57,7 +57,7 @@ async def async_setup_entry(
def add_tracked_entities(
hass: HomeAssistant,
coordinator: TadoMobileDeviceUpdateCoordinator,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
tracked: set[str],
) -> None:
"""Add new tracker entities from Tado."""
diff --git a/homeassistant/components/tado/diagnostics.py b/homeassistant/components/tado/diagnostics.py
new file mode 100644
index 00000000000..0426707c6a9
--- /dev/null
+++ b/homeassistant/components/tado/diagnostics.py
@@ -0,0 +1,20 @@
+"""Provides diagnostics for Tado."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from homeassistant.core import HomeAssistant
+
+from . import TadoConfigEntry
+
+
+async def async_get_config_entry_diagnostics(
+ hass: HomeAssistant, config_entry: TadoConfigEntry
+) -> dict[str, Any]:
+ """Return diagnostics for a Tado config entry."""
+
+ return {
+ "data": config_entry.runtime_data.coordinator.data,
+ "mobile_devices": config_entry.runtime_data.mobile_coordinator.data,
+ }
diff --git a/homeassistant/components/tado/helper.py b/homeassistant/components/tado/helper.py
index 571a757a3e8..5c515e00cf0 100644
--- a/homeassistant/components/tado/helper.py
+++ b/homeassistant/components/tado/helper.py
@@ -53,13 +53,13 @@ def decide_duration(
return duration
-def generate_supported_fanmodes(tado_to_ha_mapping: dict[str, str], options: list[str]):
+def generate_supported_fanmodes(
+ tado_to_ha_mapping: dict[str, str], options: list[str]
+) -> list[str] | None:
"""Return correct list of fan modes or None."""
supported_fanmodes = [
- tado_to_ha_mapping.get(option)
- for option in options
- if tado_to_ha_mapping.get(option) is not None
+ val for option in options if (val := tado_to_ha_mapping.get(option)) is not None
]
if not supported_fanmodes:
return None
diff --git a/homeassistant/components/tado/icons.json b/homeassistant/components/tado/icons.json
index c799bef0260..65b86359950 100644
--- a/homeassistant/components/tado/icons.json
+++ b/homeassistant/components/tado/icons.json
@@ -1,4 +1,14 @@
{
+ "entity": {
+ "switch": {
+ "child_lock": {
+ "default": "mdi:lock-open-variant",
+ "state": {
+ "on": "mdi:lock"
+ }
+ }
+ }
+ },
"services": {
"set_climate_timer": {
"service": "mdi:timer"
diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json
index b83e2695137..eba13d469f3 100644
--- a/homeassistant/components/tado/manifest.json
+++ b/homeassistant/components/tado/manifest.json
@@ -14,5 +14,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["PyTado"],
- "requirements": ["python-tado==0.18.6"]
+ "requirements": ["python-tado==0.18.11"]
}
diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py
index 037b33574e7..d0d54e79670 100644
--- a/homeassistant/components/tado/sensor.py
+++ b/homeassistant/components/tado/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import TadoConfigEntry
@@ -191,7 +191,9 @@ ZONE_SENSORS = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TadoConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tado sensor platform."""
diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json
index f1550517457..5d9c4237be8 100644
--- a/homeassistant/components/tado/strings.json
+++ b/homeassistant/components/tado/strings.json
@@ -1,33 +1,28 @@
{
"config": {
+ "progress": {
+ "wait_for_device": "To authenticate, open the following URL and login at Tado:\n{url}\nIf the code is not automatically copied, paste the following code to authorize the integration:\n\n```{code}```\n\n\nThe login attempt will time out after five minutes."
+ },
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
- "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
+ "could_not_authenticate": "Could not authenticate with Tado.",
+ "no_homes": "There are no homes linked to this Tado account.",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"step": {
- "user": {
- "data": {
- "password": "[%key:common::config_flow::data::password%]",
- "username": "[%key:common::config_flow::data::username%]"
- },
- "title": "Connect to your Tado account"
+ "reauth_confirm": {
+ "title": "Authenticate with Tado",
+ "description": "You need to reauthenticate with Tado. Press `Submit` to start the authentication process."
},
- "reconfigure": {
- "title": "Reconfigure your Tado",
- "description": "Reconfigure the entry for your account: `{username}`.",
- "data": {
- "password": "[%key:common::config_flow::data::password%]"
- },
- "data_description": {
- "password": "Enter the (new) password for Tado."
- }
+ "homekit": {
+ "title": "Authenticate with Tado",
+ "description": "Your device has been discovered and needs to authenticate with Tado. Press `Submit` to start the authentication process."
+ },
+ "timeout": {
+ "description": "The authentication process timed out. Please try again."
}
- },
- "error": {
- "unknown": "[%key:common::config_flow::error::unknown%]",
- "no_homes": "There are no homes linked to this Tado account.",
- "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}
},
"options": {
@@ -58,12 +53,17 @@
"state_attributes": {
"preset_mode": {
"state": {
- "auto": "Auto"
+ "auto": "[%key:common::state::auto%]"
}
}
}
}
},
+ "switch": {
+ "child_lock": {
+ "name": "Child lock"
+ }
+ },
"sensor": {
"outdoor_temperature": {
"name": "Outdoor temperature"
@@ -139,7 +139,7 @@
"description": "Adds a meter reading to Tado Energy IQ.",
"fields": {
"config_entry": {
- "name": "Config Entry",
+ "name": "Config entry",
"description": "Config entry to add meter reading to."
},
"reading": {
diff --git a/homeassistant/components/tado/switch.py b/homeassistant/components/tado/switch.py
new file mode 100644
index 00000000000..b3f355462b8
--- /dev/null
+++ b/homeassistant/components/tado/switch.py
@@ -0,0 +1,88 @@
+"""Module for Tado child lock switch entity."""
+
+import logging
+from typing import Any
+
+from homeassistant.components.switch import SwitchEntity
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import TadoConfigEntry
+from .entity import TadoDataUpdateCoordinator, TadoZoneEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: TadoConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the Tado switch platform."""
+
+ tado = entry.runtime_data.coordinator
+ entities: list[TadoChildLockSwitchEntity] = []
+ for zone in tado.zones:
+ zoneChildLockSupported = (
+ len(zone["devices"]) > 0 and "childLockEnabled" in zone["devices"][0]
+ )
+
+ if not zoneChildLockSupported:
+ continue
+
+ entities.append(
+ TadoChildLockSwitchEntity(
+ tado, zone["name"], zone["id"], zone["devices"][0]
+ )
+ )
+ async_add_entities(entities, True)
+
+
+class TadoChildLockSwitchEntity(TadoZoneEntity, SwitchEntity):
+ """Representation of a Tado child lock switch entity."""
+
+ _attr_translation_key = "child_lock"
+
+ def __init__(
+ self,
+ coordinator: TadoDataUpdateCoordinator,
+ zone_name: str,
+ zone_id: int,
+ device_info: dict[str, Any],
+ ) -> None:
+ """Initialize the Tado child lock switch entity."""
+ super().__init__(zone_name, coordinator.home_id, zone_id, coordinator)
+
+ self._device_info = device_info
+ self._device_id = self._device_info["shortSerialNo"]
+ self._attr_unique_id = f"{zone_id} {coordinator.home_id} child-lock"
+
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn the entity on."""
+ await self.coordinator.set_child_lock(self._device_id, True)
+ await self.coordinator.async_request_refresh()
+
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn the entity off."""
+ await self.coordinator.set_child_lock(self._device_id, False)
+ await self.coordinator.async_request_refresh()
+
+ @callback
+ def _handle_coordinator_update(self) -> None:
+ """Handle updated data from the coordinator."""
+ self._async_update_callback()
+ super()._handle_coordinator_update()
+
+ @callback
+ def _async_update_callback(self) -> None:
+ """Handle update callbacks."""
+ try:
+ self._device_info = self.coordinator.data["device"][self._device_id]
+ except KeyError:
+ _LOGGER.error(
+ "Could not update child lock info for device %s in zone %s",
+ self._device_id,
+ self.zone_name,
+ )
+ else:
+ self._attr_is_on = self._device_info.get("childLockEnabled", False) is True
diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py
index 02fbb3f5e23..3d8825b264f 100644
--- a/homeassistant/components/tado/water_heater.py
+++ b/homeassistant/components/tado/water_heater.py
@@ -12,7 +12,7 @@ from homeassistant.components.water_heater import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import TadoConfigEntry
@@ -61,7 +61,9 @@ WATER_HEATER_TIMER_SCHEMA: VolDictType = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TadoConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TadoConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tado water heater platform."""
diff --git a/homeassistant/components/tailscale/binary_sensor.py b/homeassistant/components/tailscale/binary_sensor.py
index 981f871de09..6569b40ada2 100644
--- a/homeassistant/components/tailscale/binary_sensor.py
+++ b/homeassistant/components/tailscale/binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import TailscaleEntity
@@ -84,7 +84,7 @@ BINARY_SENSORS: tuple[TailscaleBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Tailscale binary sensors based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tailscale/sensor.py b/homeassistant/components/tailscale/sensor.py
index fa4c966a7d7..cf944aa73ef 100644
--- a/homeassistant/components/tailscale/sensor.py
+++ b/homeassistant/components/tailscale/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import TailscaleEntity
@@ -55,7 +55,7 @@ SENSORS: tuple[TailscaleSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Tailscale sensors based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tailwind/binary_sensor.py b/homeassistant/components/tailwind/binary_sensor.py
index d2f8e1e2ced..4d927b0769e 100644
--- a/homeassistant/components/tailwind/binary_sensor.py
+++ b/homeassistant/components/tailwind/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TailwindConfigEntry
from .entity import TailwindDoorEntity
@@ -41,7 +41,7 @@ DESCRIPTIONS: tuple[TailwindDoorBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TailwindConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tailwind binary sensor based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/tailwind/button.py b/homeassistant/components/tailwind/button.py
index edff3434866..380eb7ccd7e 100644
--- a/homeassistant/components/tailwind/button.py
+++ b/homeassistant/components/tailwind/button.py
@@ -16,7 +16,7 @@ from homeassistant.components.button import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import TailwindConfigEntry
@@ -43,7 +43,7 @@ DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: TailwindConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tailwind button based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/tailwind/cover.py b/homeassistant/components/tailwind/cover.py
index 8ea1c7d4f6d..84f38c7d579 100644
--- a/homeassistant/components/tailwind/cover.py
+++ b/homeassistant/components/tailwind/cover.py
@@ -20,7 +20,7 @@ from homeassistant.components.cover import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, LOGGER
from .coordinator import TailwindConfigEntry
@@ -30,7 +30,7 @@ from .entity import TailwindDoorEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: TailwindConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tailwind cover based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/tailwind/number.py b/homeassistant/components/tailwind/number.py
index b67df9a6a25..ca6b610c351 100644
--- a/homeassistant/components/tailwind/number.py
+++ b/homeassistant/components/tailwind/number.py
@@ -12,7 +12,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import TailwindConfigEntry
@@ -47,7 +47,7 @@ DESCRIPTIONS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: TailwindConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tailwind number based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/tami4/button.py b/homeassistant/components/tami4/button.py
index 11377a2dcfb..a1b8db79674 100644
--- a/homeassistant/components/tami4/button.py
+++ b/homeassistant/components/tami4/button.py
@@ -11,7 +11,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import API, DOMAIN
from .entity import Tami4EdgeBaseEntity
@@ -41,7 +41,9 @@ BOIL_WATER_BUTTON = Tami4EdgeButtonEntityDescription(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Tami4Edge."""
diff --git a/homeassistant/components/tami4/sensor.py b/homeassistant/components/tami4/sensor.py
index 888acda9372..2bfd3079c19 100644
--- a/homeassistant/components/tami4/sensor.py
+++ b/homeassistant/components/tami4/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfVolume
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import API, COORDINATOR, DOMAIN
@@ -52,7 +52,9 @@ ENTITY_DESCRIPTIONS = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Tami4Edge."""
data = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py
index 774262a8854..a38266e57e8 100644
--- a/homeassistant/components/tankerkoenig/binary_sensor.py
+++ b/homeassistant/components/tankerkoenig/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator
from .entity import TankerkoenigCoordinatorEntity
@@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: TankerkoenigConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the tankerkoenig binary sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py
index 8796ae46ab7..b269eaaaf55 100644
--- a/homeassistant/components/tankerkoenig/config_flow.py
+++ b/homeassistant/components/tankerkoenig/config_flow.py
@@ -39,7 +39,7 @@ from homeassistant.helpers.selector import (
NumberSelectorConfig,
)
-from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_TYPES
+from .const import CONF_STATIONS, DEFAULT_RADIUS, DOMAIN
async def async_get_nearby_stations(
@@ -175,10 +175,6 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
vol.Required(
CONF_API_KEY, default=user_input.get(CONF_API_KEY, "")
): cv.string,
- vol.Required(
- CONF_FUEL_TYPES,
- default=user_input.get(CONF_FUEL_TYPES, list(FUEL_TYPES)),
- ): cv.multi_select(FUEL_TYPES),
vol.Required(
CONF_LOCATION,
default=user_input.get(
diff --git a/homeassistant/components/tankerkoenig/const.py b/homeassistant/components/tankerkoenig/const.py
index c2a1dba9b6a..6761d20f4ce 100644
--- a/homeassistant/components/tankerkoenig/const.py
+++ b/homeassistant/components/tankerkoenig/const.py
@@ -3,14 +3,11 @@
DOMAIN = "tankerkoenig"
NAME = "tankerkoenig"
-CONF_FUEL_TYPES = "fuel_types"
CONF_STATIONS = "stations"
DEFAULT_RADIUS = 2
DEFAULT_SCAN_INTERVAL = 30
-FUEL_TYPES = {"e5": "Super", "e10": "Super E10", "diesel": "Diesel"}
-
ATTR_BRAND = "brand"
ATTR_CITY = "city"
ATTR_FUEL_TYPE = "fuel_type"
diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py
index 1f73d0577b3..f1e6bc8c865 100644
--- a/homeassistant/components/tankerkoenig/coordinator.py
+++ b/homeassistant/components/tankerkoenig/coordinator.py
@@ -24,7 +24,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN
+from .const import CONF_STATIONS, DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -54,7 +54,6 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInf
self._selected_stations: list[str] = self.config_entry.data[CONF_STATIONS]
self.stations: dict[str, Station] = {}
- self.fuel_types: list[str] = self.config_entry.data[CONF_FUEL_TYPES]
self.show_on_map: bool = self.config_entry.options[CONF_SHOW_ON_MAP]
self._tankerkoenig = Tankerkoenig(
diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py
index 5970f3d3b24..b1646489d96 100644
--- a/homeassistant/components/tankerkoenig/sensor.py
+++ b/homeassistant/components/tankerkoenig/sensor.py
@@ -9,7 +9,7 @@ from aiotankerkoenig import GasType, Station
from homeassistant.components.sensor import SensorEntity, SensorStateClass
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_BRAND,
@@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: TankerkoenigConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the tankerkoenig sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json
index 29f4f439dd5..db620b2b11c 100644
--- a/homeassistant/components/tankerkoenig/strings.json
+++ b/homeassistant/components/tankerkoenig/strings.json
@@ -5,7 +5,6 @@
"data": {
"name": "Region name",
"api_key": "[%key:common::config_flow::data::api_key%]",
- "fuel_types": "Fuel types",
"location": "[%key:common::config_flow::data::location%]",
"stations": "Additional fuel stations",
"radius": "Search radius"
diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py
index 22cdf1a5ff0..3b2e640b807 100644
--- a/homeassistant/components/tasmota/binary_sensor.py
+++ b/homeassistant/components/tasmota/binary_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import event as evt
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_REMOVE_DISCOVER_COMPONENT
from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW
@@ -26,7 +26,7 @@ from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tasmota binary sensor dynamically through discovery."""
diff --git a/homeassistant/components/tasmota/cover.py b/homeassistant/components/tasmota/cover.py
index 2cb3cfeea25..1d7aa8316b6 100644
--- a/homeassistant/components/tasmota/cover.py
+++ b/homeassistant/components/tasmota/cover.py
@@ -18,7 +18,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_REMOVE_DISCOVER_COMPONENT
from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW
@@ -28,7 +28,7 @@ from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tasmota cover dynamically through discovery."""
diff --git a/homeassistant/components/tasmota/fan.py b/homeassistant/components/tasmota/fan.py
index e927bd6ad72..c89b36577be 100644
--- a/homeassistant/components/tasmota/fan.py
+++ b/homeassistant/components/tasmota/fan.py
@@ -16,7 +16,7 @@ from homeassistant.components.fan import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
@@ -36,7 +36,7 @@ ORDERED_NAMED_FAN_SPEEDS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tasmota fan dynamically through discovery."""
diff --git a/homeassistant/components/tasmota/light.py b/homeassistant/components/tasmota/light.py
index a06e77eceb1..ed66fa128dc 100644
--- a/homeassistant/components/tasmota/light.py
+++ b/homeassistant/components/tasmota/light.py
@@ -31,7 +31,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .const import DATA_REMOVE_DISCOVER_COMPONENT
@@ -45,7 +45,7 @@ TASMOTA_BRIGHTNESS_MAX = 100
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tasmota light dynamically through discovery."""
diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json
index 783483c6ffd..2e0d8af2338 100644
--- a/homeassistant/components/tasmota/manifest.json
+++ b/homeassistant/components/tasmota/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["hatasmota"],
"mqtt": ["tasmota/discovery/#"],
- "requirements": ["HATasmota==0.9.2"]
+ "requirements": ["HATasmota==0.10.0"]
}
diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py
index 8cc538e706a..ec20e1c0348 100644
--- a/homeassistant/components/tasmota/sensor.py
+++ b/homeassistant/components/tasmota/sensor.py
@@ -40,7 +40,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_REMOVE_DISCOVER_COMPONENT
from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW
@@ -243,7 +243,7 @@ SENSOR_UNIT_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tasmota sensor dynamically through discovery."""
diff --git a/homeassistant/components/tasmota/strings.json b/homeassistant/components/tasmota/strings.json
index 22af3304297..13edee55110 100644
--- a/homeassistant/components/tasmota/strings.json
+++ b/homeassistant/components/tasmota/strings.json
@@ -20,11 +20,11 @@
"issues": {
"topic_duplicated": {
"title": "Several Tasmota devices are sharing the same topic",
- "description": "Several Tasmota devices are sharing the topic {topic}.\n\n Tasmota devices with this problem: {offenders}."
+ "description": "Several Tasmota devices are sharing the topic {topic}.\n\nTasmota devices with this problem: {offenders}."
},
"topic_no_prefix": {
"title": "Tasmota device {name} has an invalid MQTT topic",
- "description": "Tasmota device {name} with IP {ip} does not include `%prefix%` in its fulltopic.\n\nEntities for this devices are disabled until the configuration has been corrected."
+ "description": "Tasmota device {name} with IP {ip} does not include `%prefix%` in its FullTopic.\n\nEntities for this device are disabled until the configuration has been corrected."
}
}
}
diff --git a/homeassistant/components/tasmota/switch.py b/homeassistant/components/tasmota/switch.py
index b5c19fc2431..03e594b125c 100644
--- a/homeassistant/components/tasmota/switch.py
+++ b/homeassistant/components/tasmota/switch.py
@@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_REMOVE_DISCOVER_COMPONENT
from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW
@@ -21,7 +21,7 @@ from .entity import TasmotaAvailability, TasmotaDiscoveryUpdate, TasmotaOnOffEnt
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tasmota switch dynamically through discovery."""
diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py
index ee186a29225..c8d35623c21 100644
--- a/homeassistant/components/tautulli/sensor.py
+++ b/homeassistant/components/tautulli/sensor.py
@@ -23,7 +23,10 @@ from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
from .const import ATTR_TOP_USER, DOMAIN
@@ -212,7 +215,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
entry: TautulliConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tautulli sensor."""
data = entry.runtime_data
diff --git a/homeassistant/components/technove/binary_sensor.py b/homeassistant/components/technove/binary_sensor.py
index 4c0e1111e9a..ac52a19884e 100644
--- a/homeassistant/components/technove/binary_sensor.py
+++ b/homeassistant/components/technove/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator
from .entity import TechnoVEEntity
@@ -65,7 +65,7 @@ BINARY_SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: TechnoVEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary sensor platform."""
async_add_entities(
diff --git a/homeassistant/components/technove/manifest.json b/homeassistant/components/technove/manifest.json
index 722aa4004e1..746c2280aaa 100644
--- a/homeassistant/components/technove/manifest.json
+++ b/homeassistant/components/technove/manifest.json
@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/technove",
"integration_type": "device",
"iot_class": "local_polling",
- "requirements": ["python-technove==1.3.1"],
+ "requirements": ["python-technove==2.0.0"],
"zeroconf": ["_technove-stations._tcp.local."]
}
diff --git a/homeassistant/components/technove/number.py b/homeassistant/components/technove/number.py
index 529ce407c79..11d8f281276 100644
--- a/homeassistant/components/technove/number.py
+++ b/homeassistant/components/technove/number.py
@@ -17,7 +17,7 @@ from homeassistant.components.number import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator
@@ -65,7 +65,7 @@ NUMBERS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: TechnoVEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TechnoVE number entity based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/technove/sensor.py b/homeassistant/components/technove/sensor.py
index ad80f5f419e..398c1911cd4 100644
--- a/homeassistant/components/technove/sensor.py
+++ b/homeassistant/components/technove/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfEnergy,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator
@@ -121,7 +121,7 @@ SENSORS: tuple[TechnoVESensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TechnoVEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
async_add_entities(
diff --git a/homeassistant/components/technove/strings.json b/homeassistant/components/technove/strings.json
index 9976f0b3c59..05260845a03 100644
--- a/homeassistant/components/technove/strings.json
+++ b/homeassistant/components/technove/strings.json
@@ -70,7 +70,7 @@
"plugged_waiting": "Plugged, waiting",
"plugged_charging": "Plugged, charging",
"out_of_activation_period": "Out of activation period",
- "high_charge_period": "High charge period"
+ "high_tariff_period": "High tariff period"
}
}
},
diff --git a/homeassistant/components/technove/switch.py b/homeassistant/components/technove/switch.py
index 943cd62f86e..19688075b35 100644
--- a/homeassistant/components/technove/switch.py
+++ b/homeassistant/components/technove/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import TechnoVEConfigEntry, TechnoVEDataUpdateCoordinator
@@ -79,7 +79,7 @@ SWITCHES = [
async def async_setup_entry(
hass: HomeAssistant,
entry: TechnoVEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TechnoVE switch based on a config entry."""
diff --git a/homeassistant/components/tedee/binary_sensor.py b/homeassistant/components/tedee/binary_sensor.py
index 4f167619f04..6570d9c5428 100644
--- a/homeassistant/components/tedee/binary_sensor.py
+++ b/homeassistant/components/tedee/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TedeeConfigEntry
from .entity import TedeeDescriptionEntity
@@ -41,7 +41,7 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = (
TedeeBinarySensorEntityDescription(
key="semi_locked",
translation_key="semi_locked",
- is_on_fn=lambda lock: lock.state == TedeeLockState.HALF_OPEN,
+ is_on_fn=lambda lock: lock.state is TedeeLockState.HALF_OPEN,
entity_category=EntityCategory.DIAGNOSTIC,
),
TedeeBinarySensorEntityDescription(
@@ -53,7 +53,10 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = (
TedeeBinarySensorEntityDescription(
key="uncalibrated",
translation_key="uncalibrated",
- is_on_fn=lambda lock: lock.state == TedeeLockState.UNCALIBRATED,
+ is_on_fn=(
+ lambda lock: lock.state is TedeeLockState.UNCALIBRATED
+ or lock.state is TedeeLockState.UNKNOWN
+ ),
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -64,7 +67,7 @@ ENTITIES: tuple[TedeeBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TedeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tedee sensor entity."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/tedee/lock.py b/homeassistant/components/tedee/lock.py
index 482cd039a98..da6db242db3 100644
--- a/homeassistant/components/tedee/lock.py
+++ b/homeassistant/components/tedee/lock.py
@@ -7,7 +7,7 @@ from aiotedee import TedeeClientException, TedeeLock, TedeeLockState
from homeassistant.components.lock import LockEntity, LockEntityFeature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import TedeeApiCoordinator, TedeeConfigEntry
@@ -19,7 +19,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: TedeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tedee lock entity."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/tedee/sensor.py b/homeassistant/components/tedee/sensor.py
index 828793b4458..a697d36be50 100644
--- a/homeassistant/components/tedee/sensor.py
+++ b/homeassistant/components/tedee/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TedeeConfigEntry
from .entity import TedeeDescriptionEntity
@@ -53,7 +53,7 @@ ENTITIES: tuple[TedeeSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TedeeConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tedee sensor entity."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py
index fa3ec1dc4f7..15e1f7d4f0e 100644
--- a/homeassistant/components/telegram_bot/__init__.py
+++ b/homeassistant/components/telegram_bot/__init__.py
@@ -548,7 +548,7 @@ class TelegramNotificationService:
"""Initialize the service."""
self.allowed_chat_ids = allowed_chat_ids
self._default_user = self.allowed_chat_ids[0]
- self._last_message_id = {user: None for user in self.allowed_chat_ids}
+ self._last_message_id = dict.fromkeys(self.allowed_chat_ids)
self._parsers = {
PARSER_HTML: ParseMode.HTML,
PARSER_MD: ParseMode.MARKDOWN,
@@ -756,7 +756,8 @@ class TelegramNotificationService:
message_thread_id=params[ATTR_MESSAGE_THREAD_ID],
context=context,
)
- msg_ids[chat_id] = msg.id
+ if msg is not None:
+ msg_ids[chat_id] = msg.id
return msg_ids
async def delete_message(self, chat_id=None, context=None, **kwargs):
diff --git a/homeassistant/components/telegram_bot/strings.json b/homeassistant/components/telegram_bot/strings.json
index 714e7b74db0..8f4894f42a7 100644
--- a/homeassistant/components/telegram_bot/strings.json
+++ b/homeassistant/components/telegram_bot/strings.json
@@ -96,7 +96,7 @@
},
"verify_ssl": {
"name": "Verify SSL",
- "description": "Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server."
+ "description": "Enable or disable SSL certificate verification. Disable if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server."
},
"timeout": {
"name": "Read timeout",
@@ -530,11 +530,11 @@
},
"is_anonymous": {
"name": "Is anonymous",
- "description": "If the poll needs to be anonymous, defaults to True."
+ "description": "If the poll needs to be anonymous. This is the default."
},
"allows_multiple_answers": {
"name": "Allow multiple answers",
- "description": "If the poll allows multiple answers, defaults to False."
+ "description": "If the poll allows multiple answers."
},
"open_period": {
"name": "Open period",
diff --git a/homeassistant/components/tellduslive/binary_sensor.py b/homeassistant/components/tellduslive/binary_sensor.py
index 33f936beb54..65301708646 100644
--- a/homeassistant/components/tellduslive/binary_sensor.py
+++ b/homeassistant/components/tellduslive/binary_sensor.py
@@ -5,7 +5,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, TELLDUS_DISCOVERY_NEW
from .entity import TelldusLiveEntity
@@ -14,7 +14,7 @@ from .entity import TelldusLiveEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tellduslive sensors dynamically."""
diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py
index d55a72cd633..2554acc428c 100644
--- a/homeassistant/components/tellduslive/cover.py
+++ b/homeassistant/components/tellduslive/cover.py
@@ -7,7 +7,7 @@ from homeassistant.components.cover import CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TelldusLiveClient
from .const import DOMAIN, TELLDUS_DISCOVERY_NEW
@@ -17,7 +17,7 @@ from .entity import TelldusLiveEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tellduslive sensors dynamically."""
diff --git a/homeassistant/components/tellduslive/entity.py b/homeassistant/components/tellduslive/entity.py
index a71fcb685c0..5366e4c27df 100644
--- a/homeassistant/components/tellduslive/entity.py
+++ b/homeassistant/components/tellduslive/entity.py
@@ -33,7 +33,7 @@ class TelldusLiveEntity(Entity):
self._id = device_id
self._client = client
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
_LOGGER.debug("Created device %s", self)
self.async_on_remove(
@@ -58,12 +58,12 @@ class TelldusLiveEntity(Entity):
return self.device.state
@property
- def assumed_state(self):
+ def assumed_state(self) -> bool:
"""Return true if unable to access real state of entity."""
return True
@property
- def available(self):
+ def available(self) -> bool:
"""Return true if device is not offline."""
return self._client.is_available(self.device_id)
diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py
index 005bf97d8c0..9f291bb845a 100644
--- a/homeassistant/components/tellduslive/light.py
+++ b/homeassistant/components/tellduslive/light.py
@@ -8,7 +8,7 @@ from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEnti
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, TELLDUS_DISCOVERY_NEW
from .entity import TelldusLiveEntity
@@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tellduslive sensors dynamically."""
diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py
index 9bd2b1fe599..782f240cc41 100644
--- a/homeassistant/components/tellduslive/sensor.py
+++ b/homeassistant/components/tellduslive/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, TELLDUS_DISCOVERY_NEW
from .entity import TelldusLiveEntity
@@ -121,7 +121,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tellduslive sensors dynamically."""
diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py
index bd770ab08f5..3ca2ba066ab 100644
--- a/homeassistant/components/tellduslive/switch.py
+++ b/homeassistant/components/tellduslive/switch.py
@@ -7,7 +7,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, TELLDUS_DISCOVERY_NEW
from .entity import TelldusLiveEntity
@@ -16,7 +16,7 @@ from .entity import TelldusLiveEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tellduslive sensors dynamically."""
diff --git a/homeassistant/components/tellstick/entity.py b/homeassistant/components/tellstick/entity.py
index 746c7f4dd4d..5be3d1f48f4 100644
--- a/homeassistant/components/tellstick/entity.py
+++ b/homeassistant/components/tellstick/entity.py
@@ -40,7 +40,7 @@ class TellstickDevice(Entity):
self._attr_name = tellcore_device.name
self._attr_unique_id = tellcore_device.id
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self.async_on_remove(
async_dispatcher_connect(
@@ -146,6 +146,6 @@ class TellstickDevice(Entity):
except TelldusError as err:
_LOGGER.error(err)
- def update(self):
+ def update(self) -> None:
"""Poll the current state of the device."""
self._update_from_tellcore()
diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py
index a67e2969f9a..208077a4153 100644
--- a/homeassistant/components/template/alarm_control_panel.py
+++ b/homeassistant/components/template/alarm_control_panel.py
@@ -31,9 +31,11 @@ from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.restore_state import RestoreEntity
-from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify
@@ -146,7 +148,7 @@ async def _async_create_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
_options = dict(config_entry.options)
@@ -196,70 +198,32 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore
name = self._attr_name
assert name is not None
self._template = config.get(CONF_VALUE_TEMPLATE)
- self._disarm_script = None
self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED]
self._attr_code_format = config[CONF_CODE_FORMAT].value
- if (disarm_action := config.get(CONF_DISARM_ACTION)) is not None:
- self._disarm_script = Script(hass, disarm_action, name, DOMAIN)
- self._arm_away_script = None
- if (arm_away_action := config.get(CONF_ARM_AWAY_ACTION)) is not None:
- self._arm_away_script = Script(hass, arm_away_action, name, DOMAIN)
- self._arm_home_script = None
- if (arm_home_action := config.get(CONF_ARM_HOME_ACTION)) is not None:
- self._arm_home_script = Script(hass, arm_home_action, name, DOMAIN)
- self._arm_night_script = None
- if (arm_night_action := config.get(CONF_ARM_NIGHT_ACTION)) is not None:
- self._arm_night_script = Script(hass, arm_night_action, name, DOMAIN)
- self._arm_vacation_script = None
- if (arm_vacation_action := config.get(CONF_ARM_VACATION_ACTION)) is not None:
- self._arm_vacation_script = Script(hass, arm_vacation_action, name, DOMAIN)
- self._arm_custom_bypass_script = None
- if (
- arm_custom_bypass_action := config.get(CONF_ARM_CUSTOM_BYPASS_ACTION)
- ) is not None:
- self._arm_custom_bypass_script = Script(
- hass, arm_custom_bypass_action, name, DOMAIN
- )
- self._trigger_script = None
- if (trigger_action := config.get(CONF_TRIGGER_ACTION)) is not None:
- self._trigger_script = Script(hass, trigger_action, name, DOMAIN)
+
+ self._attr_supported_features = AlarmControlPanelEntityFeature(0)
+ for action_id, supported_feature in (
+ (CONF_DISARM_ACTION, 0),
+ (CONF_ARM_AWAY_ACTION, AlarmControlPanelEntityFeature.ARM_AWAY),
+ (CONF_ARM_HOME_ACTION, AlarmControlPanelEntityFeature.ARM_HOME),
+ (CONF_ARM_NIGHT_ACTION, AlarmControlPanelEntityFeature.ARM_NIGHT),
+ (CONF_ARM_VACATION_ACTION, AlarmControlPanelEntityFeature.ARM_VACATION),
+ (
+ CONF_ARM_CUSTOM_BYPASS_ACTION,
+ AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS,
+ ),
+ (CONF_TRIGGER_ACTION, AlarmControlPanelEntityFeature.TRIGGER),
+ ):
+ # Scripts can be an empty list, therefore we need to check for None
+ if (action_config := config.get(action_id)) is not None:
+ self.add_script(action_id, action_config, name, DOMAIN)
+ self._attr_supported_features |= supported_feature
self._state: AlarmControlPanelState | None = None
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
- supported_features = AlarmControlPanelEntityFeature(0)
- if self._arm_night_script is not None:
- supported_features = (
- supported_features | AlarmControlPanelEntityFeature.ARM_NIGHT
- )
-
- if self._arm_home_script is not None:
- supported_features = (
- supported_features | AlarmControlPanelEntityFeature.ARM_HOME
- )
-
- if self._arm_away_script is not None:
- supported_features = (
- supported_features | AlarmControlPanelEntityFeature.ARM_AWAY
- )
-
- if self._arm_vacation_script is not None:
- supported_features = (
- supported_features | AlarmControlPanelEntityFeature.ARM_VACATION
- )
-
- if self._arm_custom_bypass_script is not None:
- supported_features = (
- supported_features | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS
- )
-
- if self._trigger_script is not None:
- supported_features = (
- supported_features | AlarmControlPanelEntityFeature.TRIGGER
- )
- self._attr_supported_features = supported_features
async def async_added_to_hass(self) -> None:
"""Restore last state."""
@@ -327,7 +291,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore
"""Arm the panel to Away."""
await self._async_alarm_arm(
AlarmControlPanelState.ARMED_AWAY,
- script=self._arm_away_script,
+ script=self._action_scripts.get(CONF_ARM_AWAY_ACTION),
code=code,
)
@@ -335,7 +299,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore
"""Arm the panel to Home."""
await self._async_alarm_arm(
AlarmControlPanelState.ARMED_HOME,
- script=self._arm_home_script,
+ script=self._action_scripts.get(CONF_ARM_HOME_ACTION),
code=code,
)
@@ -343,7 +307,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore
"""Arm the panel to Night."""
await self._async_alarm_arm(
AlarmControlPanelState.ARMED_NIGHT,
- script=self._arm_night_script,
+ script=self._action_scripts.get(CONF_ARM_NIGHT_ACTION),
code=code,
)
@@ -351,7 +315,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore
"""Arm the panel to Vacation."""
await self._async_alarm_arm(
AlarmControlPanelState.ARMED_VACATION,
- script=self._arm_vacation_script,
+ script=self._action_scripts.get(CONF_ARM_VACATION_ACTION),
code=code,
)
@@ -359,20 +323,22 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity, Restore
"""Arm the panel to Custom Bypass."""
await self._async_alarm_arm(
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
- script=self._arm_custom_bypass_script,
+ script=self._action_scripts.get(CONF_ARM_CUSTOM_BYPASS_ACTION),
code=code,
)
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Disarm the panel."""
await self._async_alarm_arm(
- AlarmControlPanelState.DISARMED, script=self._disarm_script, code=code
+ AlarmControlPanelState.DISARMED,
+ script=self._action_scripts.get(CONF_DISARM_ACTION),
+ code=code,
)
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Trigger the panel."""
await self._async_alarm_arm(
AlarmControlPanelState.TRIGGERED,
- script=self._trigger_script,
+ script=self._action_scripts.get(CONF_TRIGGER_ACTION),
code=code,
)
diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py
index 3c6e4899502..7ef64e8077b 100644
--- a/homeassistant/components/template/binary_sensor.py
+++ b/homeassistant/components/template/binary_sensor.py
@@ -43,7 +43,10 @@ from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, selector, template
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import async_call_later, async_track_point_in_utc_time
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -150,7 +153,7 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
@callback
def _async_create_template_tracking_entities(
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
@@ -209,7 +212,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
_options = dict(config_entry.options)
diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py
index 67ce7e7a16b..4ee8844d6e7 100644
--- a/homeassistant/components/template/button.py
+++ b/homeassistant/components/template/button.py
@@ -19,8 +19,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.script import Script
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_PRESS, DOMAIN
@@ -93,7 +95,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
_options = dict(config_entry.options)
@@ -118,11 +120,9 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity):
"""Initialize the button."""
super().__init__(hass, config=config, unique_id=unique_id)
assert self._attr_name is not None
- self._command_press = (
- Script(hass, config.get(CONF_PRESS), self._attr_name, DOMAIN)
- if config.get(CONF_PRESS, None) is not None
- else None
- )
+ # Scripts can be an empty list, therefore we need to check for None
+ if (action := config.get(CONF_PRESS)) is not None:
+ self.add_script(CONF_PRESS, action, self._attr_name, DOMAIN)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_state = None
self._attr_device_info = async_device_info_to_link_from_device_id(
@@ -132,5 +132,5 @@ class TemplateButtonEntity(TemplateEntity, ButtonEntity):
async def async_press(self) -> None:
"""Press the button."""
- if self._command_press:
- await self.async_run_script(self._command_press, context=self._context)
+ if script := self._action_scripts.get(CONF_PRESS):
+ await self.async_run_script(script, context=self._context)
diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py
index e0c5514def9..4e07d67f6e9 100644
--- a/homeassistant/components/template/config.py
+++ b/homeassistant/components/template/config.py
@@ -1,5 +1,6 @@
"""Template config validator."""
+from collections.abc import Callable
from contextlib import suppress
import logging
@@ -12,9 +13,11 @@ from homeassistant.components.blueprint import (
)
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN
from homeassistant.components.image import DOMAIN as IMAGE_DOMAIN
+from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
from homeassistant.config import async_log_schema_error, config_without_domain
from homeassistant.const import (
@@ -35,9 +38,11 @@ from . import (
binary_sensor as binary_sensor_platform,
button as button_platform,
image as image_platform,
+ light as light_platform,
number as number_platform,
select as select_platform,
sensor as sensor_platform,
+ switch as switch_platform,
weather as weather_platform,
)
from .const import (
@@ -52,41 +57,71 @@ from .helpers import async_get_blueprints
PACKAGE_MERGE_HINT = "list"
+
+def ensure_domains_do_not_have_trigger_or_action(*keys: str) -> Callable[[dict], dict]:
+ """Validate that config does not contain trigger and action."""
+ domains = set(keys)
+
+ def validate(obj: dict):
+ options = set(obj.keys())
+ if found_domains := domains.intersection(options):
+ invalid = {CONF_TRIGGER, CONF_ACTION}
+ if found_invalid := invalid.intersection(set(obj.keys())):
+ raise vol.Invalid(
+ f"Unsupported option(s) found for domain {found_domains.pop()}, please remove ({', '.join(found_invalid)}) from your configuration",
+ )
+
+ return obj
+
+ return validate
+
+
CONFIG_SECTION_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_UNIQUE_ID): cv.string,
- vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA,
- vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA,
- vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
- vol.Optional(NUMBER_DOMAIN): vol.All(
- cv.ensure_list, [number_platform.NUMBER_SCHEMA]
+ vol.All(
+ {
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA,
+ vol.Optional(CONF_CONDITION): cv.CONDITIONS_SCHEMA,
+ vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
+ vol.Optional(NUMBER_DOMAIN): vol.All(
+ cv.ensure_list, [number_platform.NUMBER_SCHEMA]
+ ),
+ vol.Optional(SENSOR_DOMAIN): vol.All(
+ cv.ensure_list, [sensor_platform.SENSOR_SCHEMA]
+ ),
+ vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys(
+ sensor_platform.LEGACY_SENSOR_SCHEMA
+ ),
+ vol.Optional(BINARY_SENSOR_DOMAIN): vol.All(
+ cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA]
+ ),
+ vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys(
+ binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA
+ ),
+ vol.Optional(SELECT_DOMAIN): vol.All(
+ cv.ensure_list, [select_platform.SELECT_SCHEMA]
+ ),
+ vol.Optional(BUTTON_DOMAIN): vol.All(
+ cv.ensure_list, [button_platform.BUTTON_SCHEMA]
+ ),
+ vol.Optional(IMAGE_DOMAIN): vol.All(
+ cv.ensure_list, [image_platform.IMAGE_SCHEMA]
+ ),
+ vol.Optional(LIGHT_DOMAIN): vol.All(
+ cv.ensure_list, [light_platform.LIGHT_SCHEMA]
+ ),
+ vol.Optional(WEATHER_DOMAIN): vol.All(
+ cv.ensure_list, [weather_platform.WEATHER_SCHEMA]
+ ),
+ vol.Optional(SWITCH_DOMAIN): vol.All(
+ cv.ensure_list, [switch_platform.SWITCH_SCHEMA]
+ ),
+ },
+ ensure_domains_do_not_have_trigger_or_action(
+ BUTTON_DOMAIN, LIGHT_DOMAIN, SWITCH_DOMAIN
),
- vol.Optional(SENSOR_DOMAIN): vol.All(
- cv.ensure_list, [sensor_platform.SENSOR_SCHEMA]
- ),
- vol.Optional(CONF_SENSORS): cv.schema_with_slug_keys(
- sensor_platform.LEGACY_SENSOR_SCHEMA
- ),
- vol.Optional(BINARY_SENSOR_DOMAIN): vol.All(
- cv.ensure_list, [binary_sensor_platform.BINARY_SENSOR_SCHEMA]
- ),
- vol.Optional(CONF_BINARY_SENSORS): cv.schema_with_slug_keys(
- binary_sensor_platform.LEGACY_BINARY_SENSOR_SCHEMA
- ),
- vol.Optional(SELECT_DOMAIN): vol.All(
- cv.ensure_list, [select_platform.SELECT_SCHEMA]
- ),
- vol.Optional(BUTTON_DOMAIN): vol.All(
- cv.ensure_list, [button_platform.BUTTON_SCHEMA]
- ),
- vol.Optional(IMAGE_DOMAIN): vol.All(
- cv.ensure_list, [image_platform.IMAGE_SCHEMA]
- ),
- vol.Optional(WEATHER_DOMAIN): vol.All(
- cv.ensure_list, [weather_platform.WEATHER_SCHEMA]
- ),
- },
+ )
)
TEMPLATE_BLUEPRINT_INSTANCE_SCHEMA = vol.Schema(
@@ -122,9 +157,15 @@ async def _async_resolve_blueprints(
raise vol.Invalid("more than one platform defined per blueprint")
if len(platforms) == 1:
platform = platforms.pop()
- for prop in (CONF_NAME, CONF_UNIQUE_ID, CONF_VARIABLES):
+ for prop in (CONF_NAME, CONF_UNIQUE_ID):
if prop in config:
config[platform][prop] = config.pop(prop)
+ # For regular template entities, CONF_VARIABLES should be removed because they just
+ # house input results for template entities. For Trigger based template entities
+ # CONF_VARIABLES should not be removed because the variables are always
+ # executed between the trigger and action.
+ if CONF_TRIGGER not in config and CONF_VARIABLES in config:
+ config[platform][CONF_VARIABLES] = config.pop(CONF_VARIABLES)
raw_config = dict(config)
template_config = TemplateConfig(CONFIG_SECTION_SCHEMA(config))
diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py
index 4d8fe78f2b5..c11e9b6101b 100644
--- a/homeassistant/components/template/coordinator.py
+++ b/homeassistant/components/template/coordinator.py
@@ -2,12 +2,14 @@
from collections.abc import Callable, Mapping
import logging
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, Any, cast
-from homeassistant.const import EVENT_HOMEASSISTANT_START
+from homeassistant.components.blueprint import CONF_USE_BLUEPRINT
+from homeassistant.const import CONF_PATH, CONF_VARIABLES, EVENT_HOMEASSISTANT_START
from homeassistant.core import Context, CoreState, Event, HomeAssistant, callback
from homeassistant.helpers import condition, discovery, trigger as trigger_helper
from homeassistant.helpers.script import Script
+from homeassistant.helpers.script_variables import ScriptVariables
from homeassistant.helpers.trace import trace_get
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@@ -22,7 +24,7 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
REMOVE_TRIGGER = object()
- def __init__(self, hass: HomeAssistant, config: dict[str, Any]) -> None:
+ def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
"""Instantiate trigger data."""
super().__init__(
hass, _LOGGER, config_entry=None, name="Trigger Update Coordinator"
@@ -32,6 +34,18 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
self._unsub_start: Callable[[], None] | None = None
self._unsub_trigger: Callable[[], None] | None = None
self._script: Script | None = None
+ self._run_variables: ScriptVariables | None = None
+ self._blueprint_inputs: dict | None = None
+ if config is not None:
+ self._run_variables = config.get(CONF_VARIABLES)
+ self._blueprint_inputs = getattr(config, "raw_blueprint_inputs", None)
+
+ @property
+ def referenced_blueprint(self) -> str | None:
+ """Return referenced blueprint or None."""
+ if self._blueprint_inputs is None:
+ return None
+ return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH])
@property
def unique_id(self) -> str | None:
@@ -104,6 +118,10 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
async def _handle_triggered_with_script(
self, run_variables: TemplateVarsType, context: Context | None = None
) -> None:
+ # Render run variables after the trigger, before checking conditions.
+ if self._run_variables:
+ run_variables = self._run_variables.async_render(self.hass, run_variables)
+
if not self._check_condition(run_variables):
return
# Create a context referring to the trigger context.
@@ -119,6 +137,9 @@ class TriggerUpdateCoordinator(DataUpdateCoordinator):
async def _handle_triggered(
self, run_variables: TemplateVarsType, context: Context | None = None
) -> None:
+ if self._run_variables:
+ run_variables = self._run_variables.async_render(self.hass, run_variables)
+
if not self._check_condition(run_variables):
return
self._execute_update(run_variables, context)
diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py
index 306b4405c6a..7c9c0ea9d53 100644
--- a/homeassistant/components/template/cover.py
+++ b/homeassistant/components/template/cover.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -30,7 +30,6 @@ from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
@@ -103,7 +102,7 @@ PLATFORM_SCHEMA = COVER_PLATFORM_SCHEMA.extend(
)
-async def _async_create_entities(hass, config):
+async def _async_create_entities(hass: HomeAssistant, config):
"""Create the Template cover."""
covers = []
@@ -141,11 +140,11 @@ class CoverTemplate(TemplateEntity, CoverEntity):
def __init__(
self,
- hass,
+ hass: HomeAssistant,
object_id,
- config,
+ config: dict[str, Any],
unique_id,
- ):
+ ) -> None:
"""Initialize the Template cover."""
super().__init__(
hass, config=config, fallback_name=object_id, unique_id=unique_id
@@ -153,45 +152,41 @@ class CoverTemplate(TemplateEntity, CoverEntity):
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
- friendly_name = self._attr_name
+ name = self._attr_name
+ if TYPE_CHECKING:
+ assert name is not None
self._template = config.get(CONF_VALUE_TEMPLATE)
self._position_template = config.get(CONF_POSITION_TEMPLATE)
self._tilt_template = config.get(CONF_TILT_TEMPLATE)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
- self._open_script = None
- if (open_action := config.get(OPEN_ACTION)) is not None:
- self._open_script = Script(hass, open_action, friendly_name, DOMAIN)
- self._close_script = None
- if (close_action := config.get(CLOSE_ACTION)) is not None:
- self._close_script = Script(hass, close_action, friendly_name, DOMAIN)
- self._stop_script = None
- if (stop_action := config.get(STOP_ACTION)) is not None:
- self._stop_script = Script(hass, stop_action, friendly_name, DOMAIN)
- self._position_script = None
- if (position_action := config.get(POSITION_ACTION)) is not None:
- self._position_script = Script(hass, position_action, friendly_name, DOMAIN)
- self._tilt_script = None
- if (tilt_action := config.get(TILT_ACTION)) is not None:
- self._tilt_script = Script(hass, tilt_action, friendly_name, DOMAIN)
+
+ # The config requires (open and close scripts) or a set position script,
+ # therefore the base supported features will always include them.
+ self._attr_supported_features = (
+ CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
+ )
+ for action_id, supported_feature in (
+ (OPEN_ACTION, 0),
+ (CLOSE_ACTION, 0),
+ (STOP_ACTION, CoverEntityFeature.STOP),
+ (POSITION_ACTION, CoverEntityFeature.SET_POSITION),
+ (TILT_ACTION, TILT_FEATURES),
+ ):
+ # Scripts can be an empty list, therefore we need to check for None
+ if (action_config := config.get(action_id)) is not None:
+ self.add_script(action_id, action_config, name, DOMAIN)
+ self._attr_supported_features |= supported_feature
+
optimistic = config.get(CONF_OPTIMISTIC)
self._optimistic = optimistic or (
optimistic is None and not self._template and not self._position_template
)
tilt_optimistic = config.get(CONF_TILT_OPTIMISTIC)
self._tilt_optimistic = tilt_optimistic or not self._tilt_template
- self._position = None
+ self._position: int | None = None
self._is_opening = False
self._is_closing = False
- self._tilt_value = None
-
- supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE
- if self._stop_script is not None:
- supported_features |= CoverEntityFeature.STOP
- if self._position_script is not None:
- supported_features |= CoverEntityFeature.SET_POSITION
- if self._tilt_script is not None:
- supported_features |= TILT_FEATURES
- self._attr_supported_features = supported_features
+ self._tilt_value: int | None = None
@callback
def _async_setup_templates(self) -> None:
@@ -317,7 +312,7 @@ class CoverTemplate(TemplateEntity, CoverEntity):
None is unknown, 0 is closed, 100 is fully open.
"""
- if self._position_template or self._position_script:
+ if self._position_template or self._action_scripts.get(POSITION_ACTION):
return self._position
return None
@@ -331,11 +326,11 @@ class CoverTemplate(TemplateEntity, CoverEntity):
async def async_open_cover(self, **kwargs: Any) -> None:
"""Move the cover up."""
- if self._open_script:
- await self.async_run_script(self._open_script, context=self._context)
- elif self._position_script:
+ if open_script := self._action_scripts.get(OPEN_ACTION):
+ await self.async_run_script(open_script, context=self._context)
+ elif position_script := self._action_scripts.get(POSITION_ACTION):
await self.async_run_script(
- self._position_script,
+ position_script,
run_variables={"position": 100},
context=self._context,
)
@@ -345,11 +340,11 @@ class CoverTemplate(TemplateEntity, CoverEntity):
async def async_close_cover(self, **kwargs: Any) -> None:
"""Move the cover down."""
- if self._close_script:
- await self.async_run_script(self._close_script, context=self._context)
- elif self._position_script:
+ if close_script := self._action_scripts.get(CLOSE_ACTION):
+ await self.async_run_script(close_script, context=self._context)
+ elif position_script := self._action_scripts.get(POSITION_ACTION):
await self.async_run_script(
- self._position_script,
+ position_script,
run_variables={"position": 0},
context=self._context,
)
@@ -359,14 +354,14 @@ class CoverTemplate(TemplateEntity, CoverEntity):
async def async_stop_cover(self, **kwargs: Any) -> None:
"""Fire the stop action."""
- if self._stop_script:
- await self.async_run_script(self._stop_script, context=self._context)
+ if stop_script := self._action_scripts.get(STOP_ACTION):
+ await self.async_run_script(stop_script, context=self._context)
async def async_set_cover_position(self, **kwargs: Any) -> None:
"""Set cover position."""
self._position = kwargs[ATTR_POSITION]
await self.async_run_script(
- self._position_script,
+ self._action_scripts[POSITION_ACTION],
run_variables={"position": self._position},
context=self._context,
)
@@ -377,7 +372,7 @@ class CoverTemplate(TemplateEntity, CoverEntity):
"""Tilt the cover open."""
self._tilt_value = 100
await self.async_run_script(
- self._tilt_script,
+ self._action_scripts[TILT_ACTION],
run_variables={"tilt": self._tilt_value},
context=self._context,
)
@@ -388,7 +383,7 @@ class CoverTemplate(TemplateEntity, CoverEntity):
"""Tilt the cover closed."""
self._tilt_value = 0
await self.async_run_script(
- self._tilt_script,
+ self._action_scripts[TILT_ACTION],
run_variables={"tilt": self._tilt_value},
context=self._context,
)
@@ -399,7 +394,7 @@ class CoverTemplate(TemplateEntity, CoverEntity):
"""Move the cover tilt to a specific position."""
self._tilt_value = kwargs[ATTR_TILT_POSITION]
await self.async_run_script(
- self._tilt_script,
+ self._action_scripts[TILT_ACTION],
run_variables={"tilt": self._tilt_value},
context=self._context,
)
diff --git a/homeassistant/components/template/entity.py b/homeassistant/components/template/entity.py
new file mode 100644
index 00000000000..3617d9acdee
--- /dev/null
+++ b/homeassistant/components/template/entity.py
@@ -0,0 +1,64 @@
+"""Template entity base class."""
+
+from collections.abc import Sequence
+from typing import Any
+
+from homeassistant.core import Context, HomeAssistant, callback
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.script import Script, _VarsType
+from homeassistant.helpers.template import TemplateStateFromEntityId
+
+
+class AbstractTemplateEntity(Entity):
+ """Actions linked to a template entity."""
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the entity."""
+
+ self.hass = hass
+ self._action_scripts: dict[str, Script] = {}
+
+ @property
+ def referenced_blueprint(self) -> str | None:
+ """Return referenced blueprint or None."""
+ raise NotImplementedError
+
+ @callback
+ def _render_script_variables(self) -> dict:
+ """Render configured variables."""
+ raise NotImplementedError
+
+ def add_script(
+ self,
+ script_id: str,
+ config: Sequence[dict[str, Any]],
+ name: str,
+ domain: str,
+ ):
+ """Add an action script."""
+
+ self._action_scripts[script_id] = Script(
+ self.hass,
+ config,
+ f"{name} {script_id}",
+ domain,
+ )
+
+ async def async_run_script(
+ self,
+ script: Script,
+ *,
+ run_variables: _VarsType | None = None,
+ context: Context | None = None,
+ ) -> None:
+ """Run an action script."""
+ if run_variables is None:
+ run_variables = {}
+ await script.async_run(
+ run_variables={
+ "this": TemplateStateFromEntityId(self.hass, self.entity_id),
+ **self._render_script_variables(),
+ **run_variables,
+ },
+ context=context,
+ )
diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py
index 6ed525fd45f..7ec62891784 100644
--- a/homeassistant/components/template/fan.py
+++ b/homeassistant/components/template/fan.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -32,7 +32,6 @@ from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
@@ -89,7 +88,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
)
-async def _async_create_entities(hass, config):
+async def _async_create_entities(hass: HomeAssistant, config):
"""Create the Template Fans."""
fans = []
@@ -127,11 +126,11 @@ class TemplateFan(TemplateEntity, FanEntity):
def __init__(
self,
- hass,
+ hass: HomeAssistant,
object_id,
- config,
+ config: dict[str, Any],
unique_id,
- ):
+ ) -> None:
"""Initialize the fan."""
super().__init__(
hass, config=config, fallback_name=object_id, unique_id=unique_id
@@ -140,7 +139,9 @@ class TemplateFan(TemplateEntity, FanEntity):
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
- friendly_name = self._attr_name
+ name = self._attr_name
+ if TYPE_CHECKING:
+ assert name is not None
self._template = config.get(CONF_VALUE_TEMPLATE)
self._percentage_template = config.get(CONF_PERCENTAGE_TEMPLATE)
@@ -148,57 +149,33 @@ class TemplateFan(TemplateEntity, FanEntity):
self._oscillating_template = config.get(CONF_OSCILLATING_TEMPLATE)
self._direction_template = config.get(CONF_DIRECTION_TEMPLATE)
- self._on_script = Script(hass, config[CONF_ON_ACTION], friendly_name, DOMAIN)
- self._off_script = Script(hass, config[CONF_OFF_ACTION], friendly_name, DOMAIN)
-
- self._set_percentage_script = None
- if set_percentage_action := config.get(CONF_SET_PERCENTAGE_ACTION):
- self._set_percentage_script = Script(
- hass, set_percentage_action, friendly_name, DOMAIN
- )
-
- self._set_preset_mode_script = None
- if set_preset_mode_action := config.get(CONF_SET_PRESET_MODE_ACTION):
- self._set_preset_mode_script = Script(
- hass, set_preset_mode_action, friendly_name, DOMAIN
- )
-
- self._set_oscillating_script = None
- if set_oscillating_action := config.get(CONF_SET_OSCILLATING_ACTION):
- self._set_oscillating_script = Script(
- hass, set_oscillating_action, friendly_name, DOMAIN
- )
-
- self._set_direction_script = None
- if set_direction_action := config.get(CONF_SET_DIRECTION_ACTION):
- self._set_direction_script = Script(
- hass, set_direction_action, friendly_name, DOMAIN
- )
+ self._attr_supported_features |= (
+ FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
+ )
+ for action_id, supported_feature in (
+ (CONF_ON_ACTION, 0),
+ (CONF_OFF_ACTION, 0),
+ (CONF_SET_PERCENTAGE_ACTION, FanEntityFeature.SET_SPEED),
+ (CONF_SET_PRESET_MODE_ACTION, FanEntityFeature.PRESET_MODE),
+ (CONF_SET_OSCILLATING_ACTION, FanEntityFeature.OSCILLATE),
+ (CONF_SET_DIRECTION_ACTION, FanEntityFeature.DIRECTION),
+ ):
+ # Scripts can be an empty list, therefore we need to check for None
+ if (action_config := config.get(action_id)) is not None:
+ self.add_script(action_id, action_config, name, DOMAIN)
+ self._attr_supported_features |= supported_feature
self._state: bool | None = False
- self._percentage = None
- self._preset_mode = None
- self._oscillating = None
- self._direction = None
+ self._percentage: int | None = None
+ self._preset_mode: str | None = None
+ self._oscillating: bool | None = None
+ self._direction: str | None = None
# Number of valid speeds
self._speed_count = config.get(CONF_SPEED_COUNT)
# List of valid preset modes
- self._preset_modes = config.get(CONF_PRESET_MODES)
-
- if self._percentage_template:
- self._attr_supported_features |= FanEntityFeature.SET_SPEED
- if self._preset_mode_template and self._preset_modes:
- self._attr_supported_features |= FanEntityFeature.PRESET_MODE
- if self._oscillating_template:
- self._attr_supported_features |= FanEntityFeature.OSCILLATE
- if self._direction_template:
- self._attr_supported_features |= FanEntityFeature.DIRECTION
- self._attr_supported_features |= (
- FanEntityFeature.TURN_OFF | FanEntityFeature.TURN_ON
- )
-
+ self._preset_modes: list[str] | None = config.get(CONF_PRESET_MODES)
self._attr_assumed_state = self._template is None
@property
@@ -207,7 +184,7 @@ class TemplateFan(TemplateEntity, FanEntity):
return self._speed_count or 100
@property
- def preset_modes(self) -> list[str]:
+ def preset_modes(self) -> list[str] | None:
"""Get the list of available preset modes."""
return self._preset_modes
@@ -244,7 +221,7 @@ class TemplateFan(TemplateEntity, FanEntity):
) -> None:
"""Turn on the fan."""
await self.async_run_script(
- self._on_script,
+ self._action_scripts[CONF_ON_ACTION],
run_variables={
ATTR_PERCENTAGE: percentage,
ATTR_PRESET_MODE: preset_mode,
@@ -263,7 +240,9 @@ class TemplateFan(TemplateEntity, FanEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
- await self.async_run_script(self._off_script, context=self._context)
+ await self.async_run_script(
+ self._action_scripts[CONF_OFF_ACTION], context=self._context
+ )
if self._template is None:
self._state = False
@@ -273,56 +252,65 @@ class TemplateFan(TemplateEntity, FanEntity):
"""Set the percentage speed of the fan."""
self._percentage = percentage
- if self._set_percentage_script:
+ if script := self._action_scripts.get(CONF_SET_PERCENTAGE_ACTION):
await self.async_run_script(
- self._set_percentage_script,
+ script,
run_variables={ATTR_PERCENTAGE: self._percentage},
context=self._context,
)
if self._template is None:
self._state = percentage != 0
+
+ if self._template is None or self._percentage_template is None:
self.async_write_ha_state()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset_mode of the fan."""
self._preset_mode = preset_mode
- if self._set_preset_mode_script:
+ if script := self._action_scripts.get(CONF_SET_PRESET_MODE_ACTION):
await self.async_run_script(
- self._set_preset_mode_script,
+ script,
run_variables={ATTR_PRESET_MODE: self._preset_mode},
context=self._context,
)
if self._template is None:
self._state = True
+
+ if self._template is None or self._preset_mode_template is None:
self.async_write_ha_state()
async def async_oscillate(self, oscillating: bool) -> None:
"""Set oscillation of the fan."""
- if self._set_oscillating_script is None:
- return
-
self._oscillating = oscillating
- await self.async_run_script(
- self._set_oscillating_script,
- run_variables={ATTR_OSCILLATING: self.oscillating},
- context=self._context,
- )
+ if (
+ script := self._action_scripts.get(CONF_SET_OSCILLATING_ACTION)
+ ) is not None:
+ await self.async_run_script(
+ script,
+ run_variables={ATTR_OSCILLATING: self.oscillating},
+ context=self._context,
+ )
+
+ if self._oscillating_template is None:
+ self.async_write_ha_state()
async def async_set_direction(self, direction: str) -> None:
"""Set the direction of the fan."""
- if self._set_direction_script is None:
- return
-
if direction in _VALID_DIRECTIONS:
self._direction = direction
- await self.async_run_script(
- self._set_direction_script,
- run_variables={ATTR_DIRECTION: direction},
- context=self._context,
- )
+ if (
+ script := self._action_scripts.get(CONF_SET_DIRECTION_ACTION)
+ ) is not None:
+ await self.async_run_script(
+ script,
+ run_variables={ATTR_DIRECTION: direction},
+ context=self._context,
+ )
+ if self._direction_template is None:
+ self.async_write_ha_state()
else:
_LOGGER.error(
"Received invalid direction: %s for entity %s. Expected: %s",
diff --git a/homeassistant/components/template/helpers.py b/homeassistant/components/template/helpers.py
index b320f2128cd..d74a4a4ed00 100644
--- a/homeassistant/components/template/helpers.py
+++ b/homeassistant/components/template/helpers.py
@@ -9,7 +9,7 @@ from homeassistant.helpers.entity_platform import async_get_platforms
from homeassistant.helpers.singleton import singleton
from .const import DOMAIN, TEMPLATE_BLUEPRINT_SCHEMA
-from .template_entity import TemplateEntity
+from .entity import AbstractTemplateEntity
DATA_BLUEPRINTS = "template_blueprints"
@@ -23,7 +23,7 @@ def templates_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[s
entity_id
for platform in async_get_platforms(hass, DOMAIN)
for entity_id, template_entity in platform.entities.items()
- if isinstance(template_entity, TemplateEntity)
+ if isinstance(template_entity, AbstractTemplateEntity)
and template_entity.referenced_blueprint == blueprint_path
]
@@ -33,7 +33,8 @@ def blueprint_in_template(hass: HomeAssistant, entity_id: str) -> str | None:
"""Return the blueprint the template entity is based on or None."""
for platform in async_get_platforms(hass, DOMAIN):
if isinstance(
- (template_entity := platform.entities.get(entity_id)), TemplateEntity
+ (template_entity := platform.entities.get(entity_id)),
+ AbstractTemplateEntity,
):
return template_entity.referenced_blueprint
return None
diff --git a/homeassistant/components/template/image.py b/homeassistant/components/template/image.py
index ba85418c339..5afbca55cbb 100644
--- a/homeassistant/components/template/image.py
+++ b/homeassistant/components/template/image.py
@@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -96,7 +99,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
_options = dict(config_entry.options)
diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py
index 9391e368e2b..c58709eba5e 100644
--- a/homeassistant/components/template/light.py
+++ b/homeassistant/components/template/light.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -26,9 +26,13 @@ from homeassistant.components.light import (
filter_supported_color_modes,
)
from homeassistant.const import (
+ CONF_EFFECT,
CONF_ENTITY_ID,
CONF_FRIENDLY_NAME,
CONF_LIGHTS,
+ CONF_NAME,
+ CONF_RGB,
+ CONF_STATE,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
STATE_OFF,
@@ -36,16 +40,18 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, template
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import color as color_util
-from .const import DOMAIN
+from .const import CONF_OBJECT_ID, CONF_PICTURE, DOMAIN
from .template_entity import (
+ LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
+ TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
+ TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
rewrite_common_legacy_to_modern_conf,
)
@@ -57,33 +63,96 @@ _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"]
CONF_COLOR_ACTION = "set_color"
CONF_COLOR_TEMPLATE = "color_template"
+CONF_HS = "hs"
CONF_HS_ACTION = "set_hs"
CONF_HS_TEMPLATE = "hs_template"
CONF_RGB_ACTION = "set_rgb"
CONF_RGB_TEMPLATE = "rgb_template"
+CONF_RGBW = "rgbw"
CONF_RGBW_ACTION = "set_rgbw"
CONF_RGBW_TEMPLATE = "rgbw_template"
+CONF_RGBWW = "rgbww"
CONF_RGBWW_ACTION = "set_rgbww"
CONF_RGBWW_TEMPLATE = "rgbww_template"
CONF_EFFECT_ACTION = "set_effect"
+CONF_EFFECT_LIST = "effect_list"
CONF_EFFECT_LIST_TEMPLATE = "effect_list_template"
CONF_EFFECT_TEMPLATE = "effect_template"
+CONF_LEVEL = "level"
CONF_LEVEL_ACTION = "set_level"
CONF_LEVEL_TEMPLATE = "level_template"
+CONF_MAX_MIREDS = "max_mireds"
CONF_MAX_MIREDS_TEMPLATE = "max_mireds_template"
+CONF_MIN_MIREDS = "min_mireds"
CONF_MIN_MIREDS_TEMPLATE = "min_mireds_template"
CONF_OFF_ACTION = "turn_off"
CONF_ON_ACTION = "turn_on"
-CONF_SUPPORTS_TRANSITION = "supports_transition_template"
+CONF_SUPPORTS_TRANSITION = "supports_transition"
+CONF_SUPPORTS_TRANSITION_TEMPLATE = "supports_transition_template"
CONF_TEMPERATURE_ACTION = "set_temperature"
+CONF_TEMPERATURE = "temperature"
CONF_TEMPERATURE_TEMPLATE = "temperature_template"
CONF_WHITE_VALUE_ACTION = "set_white_value"
+CONF_WHITE_VALUE = "white_value"
CONF_WHITE_VALUE_TEMPLATE = "white_value_template"
DEFAULT_MIN_MIREDS = 153
DEFAULT_MAX_MIREDS = 500
-LIGHT_SCHEMA = vol.All(
+LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | {
+ CONF_COLOR_ACTION: CONF_HS_ACTION,
+ CONF_COLOR_TEMPLATE: CONF_HS,
+ CONF_EFFECT_LIST_TEMPLATE: CONF_EFFECT_LIST,
+ CONF_EFFECT_TEMPLATE: CONF_EFFECT,
+ CONF_HS_TEMPLATE: CONF_HS,
+ CONF_LEVEL_TEMPLATE: CONF_LEVEL,
+ CONF_MAX_MIREDS_TEMPLATE: CONF_MAX_MIREDS,
+ CONF_MIN_MIREDS_TEMPLATE: CONF_MIN_MIREDS,
+ CONF_RGB_TEMPLATE: CONF_RGB,
+ CONF_RGBW_TEMPLATE: CONF_RGBW,
+ CONF_RGBWW_TEMPLATE: CONF_RGBWW,
+ CONF_SUPPORTS_TRANSITION_TEMPLATE: CONF_SUPPORTS_TRANSITION,
+ CONF_TEMPERATURE_TEMPLATE: CONF_TEMPERATURE,
+ CONF_VALUE_TEMPLATE: CONF_STATE,
+ CONF_WHITE_VALUE_TEMPLATE: CONF_WHITE_VALUE,
+}
+
+DEFAULT_NAME = "Template Light"
+
+LIGHT_SCHEMA = (
+ vol.Schema(
+ {
+ vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA,
+ vol.Inclusive(CONF_EFFECT_LIST, "effect"): cv.template,
+ vol.Inclusive(CONF_EFFECT, "effect"): cv.template,
+ vol.Optional(CONF_HS_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_HS): cv.template,
+ vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_LEVEL): cv.template,
+ vol.Optional(CONF_MAX_MIREDS): cv.template,
+ vol.Optional(CONF_MIN_MIREDS): cv.template,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template,
+ vol.Optional(CONF_PICTURE): cv.template,
+ vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_RGB): cv.template,
+ vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_RGBW): cv.template,
+ vol.Optional(CONF_RGBWW_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_RGBWW): cv.template,
+ vol.Optional(CONF_STATE): cv.template,
+ vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template,
+ vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_TEMPERATURE): cv.template,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
+ }
+ )
+ .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
+ .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema)
+)
+
+LEGACY_LIGHT_SCHEMA = vol.All(
cv.deprecated(CONF_ENTITY_ID),
vol.Schema(
{
@@ -108,7 +177,7 @@ LIGHT_SCHEMA = vol.All(
vol.Optional(CONF_MIN_MIREDS_TEMPLATE): cv.template,
vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_SUPPORTS_TRANSITION): cv.template,
+ vol.Optional(CONF_SUPPORTS_TRANSITION_TEMPLATE): cv.template,
vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_TEMPERATURE_TEMPLATE): cv.template,
vol.Optional(CONF_UNIQUE_ID): cv.string,
@@ -122,29 +191,50 @@ PLATFORM_SCHEMA = vol.All(
cv.removed(CONF_WHITE_VALUE_ACTION),
cv.removed(CONF_WHITE_VALUE_TEMPLATE),
LIGHT_PLATFORM_SCHEMA.extend(
- {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_SCHEMA)}
+ {vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LEGACY_LIGHT_SCHEMA)}
),
)
-async def _async_create_entities(hass, config):
+def rewrite_legacy_to_modern_conf(
+ hass: HomeAssistant, config: dict[str, dict]
+) -> list[dict]:
+ """Rewrite legacy switch configuration definitions to modern ones."""
+ lights = []
+ for object_id, entity_conf in config.items():
+ entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id}
+
+ entity_conf = rewrite_common_legacy_to_modern_conf(
+ hass, entity_conf, LEGACY_FIELDS
+ )
+
+ if CONF_NAME not in entity_conf:
+ entity_conf[CONF_NAME] = template.Template(object_id, hass)
+
+ lights.append(entity_conf)
+
+ return lights
+
+
+@callback
+def _async_create_template_tracking_entities(
+ async_add_entities: AddEntitiesCallback,
+ hass: HomeAssistant,
+ definitions: list[dict],
+ unique_id_prefix: str | None,
+) -> None:
"""Create the Template Lights."""
lights = []
- for object_id, entity_config in config[CONF_LIGHTS].items():
- entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config)
- unique_id = entity_config.get(CONF_UNIQUE_ID)
+ for entity_conf in definitions:
+ unique_id = entity_conf.get(CONF_UNIQUE_ID)
- lights.append(
- LightTemplate(
- hass,
- object_id,
- entity_config,
- unique_id,
- )
- )
+ if unique_id and unique_id_prefix:
+ unique_id = f"{unique_id_prefix}-{unique_id}"
- return lights
+ lights.append(LightTemplate(hass, entity_conf, unique_id))
+
+ async_add_entities(lights)
async def async_setup_platform(
@@ -154,7 +244,21 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template lights."""
- async_add_entities(await _async_create_entities(hass, config))
+ if discovery_info is None:
+ _async_create_template_tracking_entities(
+ async_add_entities,
+ hass,
+ rewrite_legacy_to_modern_conf(hass, config[CONF_LIGHTS]),
+ None,
+ )
+ return
+
+ _async_create_template_tracking_entities(
+ async_add_entities,
+ hass,
+ discovery_info["entities"],
+ discovery_info["unique_id"],
+ )
class LightTemplate(TemplateEntity, LightEntity):
@@ -164,64 +268,41 @@ class LightTemplate(TemplateEntity, LightEntity):
def __init__(
self,
- hass,
- object_id,
- config,
- unique_id,
- ):
+ hass: HomeAssistant,
+ config: dict[str, Any],
+ unique_id: str | None,
+ ) -> None:
"""Initialize the light."""
- super().__init__(
- hass, config=config, fallback_name=object_id, unique_id=unique_id
- )
- self.entity_id = async_generate_entity_id(
- ENTITY_ID_FORMAT, object_id, hass=hass
- )
- friendly_name = self._attr_name
- self._template = config.get(CONF_VALUE_TEMPLATE)
- self._on_script = Script(hass, config[CONF_ON_ACTION], friendly_name, DOMAIN)
- self._off_script = Script(hass, config[CONF_OFF_ACTION], friendly_name, DOMAIN)
- self._level_script = None
- if (level_action := config.get(CONF_LEVEL_ACTION)) is not None:
- self._level_script = Script(hass, level_action, friendly_name, DOMAIN)
- self._level_template = config.get(CONF_LEVEL_TEMPLATE)
- self._temperature_script = None
- if (temperature_action := config.get(CONF_TEMPERATURE_ACTION)) is not None:
- self._temperature_script = Script(
- hass, temperature_action, friendly_name, DOMAIN
+ super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id)
+ if (object_id := config.get(CONF_OBJECT_ID)) is not None:
+ self.entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, object_id, hass=hass
)
- self._temperature_template = config.get(CONF_TEMPERATURE_TEMPLATE)
- self._color_script = None
- if (color_action := config.get(CONF_COLOR_ACTION)) is not None:
- self._color_script = Script(hass, color_action, friendly_name, DOMAIN)
- self._color_template = config.get(CONF_COLOR_TEMPLATE)
- self._hs_script = None
- if (hs_action := config.get(CONF_HS_ACTION)) is not None:
- self._hs_script = Script(hass, hs_action, friendly_name, DOMAIN)
- self._hs_template = config.get(CONF_HS_TEMPLATE)
- self._rgb_script = None
- if (rgb_action := config.get(CONF_RGB_ACTION)) is not None:
- self._rgb_script = Script(hass, rgb_action, friendly_name, DOMAIN)
- self._rgb_template = config.get(CONF_RGB_TEMPLATE)
- self._rgbw_script = None
- if (rgbw_action := config.get(CONF_RGBW_ACTION)) is not None:
- self._rgbw_script = Script(hass, rgbw_action, friendly_name, DOMAIN)
- self._rgbw_template = config.get(CONF_RGBW_TEMPLATE)
- self._rgbww_script = None
- if (rgbww_action := config.get(CONF_RGBWW_ACTION)) is not None:
- self._rgbww_script = Script(hass, rgbww_action, friendly_name, DOMAIN)
- self._rgbww_template = config.get(CONF_RGBWW_TEMPLATE)
- self._effect_script = None
- if (effect_action := config.get(CONF_EFFECT_ACTION)) is not None:
- self._effect_script = Script(hass, effect_action, friendly_name, DOMAIN)
- self._effect_list_template = config.get(CONF_EFFECT_LIST_TEMPLATE)
- self._effect_template = config.get(CONF_EFFECT_TEMPLATE)
- self._max_mireds_template = config.get(CONF_MAX_MIREDS_TEMPLATE)
- self._min_mireds_template = config.get(CONF_MIN_MIREDS_TEMPLATE)
+ name = self._attr_name
+ if TYPE_CHECKING:
+ assert name is not None
+
+ self._template = config.get(CONF_STATE)
+ self._level_template = config.get(CONF_LEVEL)
+ self._temperature_template = config.get(CONF_TEMPERATURE)
+ self._hs_template = config.get(CONF_HS)
+ self._rgb_template = config.get(CONF_RGB)
+ self._rgbw_template = config.get(CONF_RGBW)
+ self._rgbww_template = config.get(CONF_RGBWW)
+ self._effect_list_template = config.get(CONF_EFFECT_LIST)
+ self._effect_template = config.get(CONF_EFFECT)
+ self._max_mireds_template = config.get(CONF_MAX_MIREDS)
+ self._min_mireds_template = config.get(CONF_MIN_MIREDS)
self._supports_transition_template = config.get(CONF_SUPPORTS_TRANSITION)
+ for action_id in (CONF_ON_ACTION, CONF_OFF_ACTION, CONF_EFFECT_ACTION):
+ # Scripts can be an empty list, therefore we need to check for None
+ if (action_config := config.get(action_id)) is not None:
+ self.add_script(action_id, action_config, name, DOMAIN)
+
self._state = False
self._brightness = None
- self._temperature = None
+ self._temperature: int | None = None
self._hs_color = None
self._rgb_color = None
self._rgbw_color = None
@@ -235,21 +316,18 @@ class LightTemplate(TemplateEntity, LightEntity):
self._supported_color_modes = None
color_modes = {ColorMode.ONOFF}
- if self._level_script is not None:
- color_modes.add(ColorMode.BRIGHTNESS)
- if self._temperature_script is not None:
- color_modes.add(ColorMode.COLOR_TEMP)
- if self._hs_script is not None:
- color_modes.add(ColorMode.HS)
- if self._color_script is not None:
- color_modes.add(ColorMode.HS)
- if self._rgb_script is not None:
- color_modes.add(ColorMode.RGB)
- if self._rgbw_script is not None:
- color_modes.add(ColorMode.RGBW)
- if self._rgbww_script is not None:
- color_modes.add(ColorMode.RGBWW)
-
+ for action_id, color_mode in (
+ (CONF_TEMPERATURE_ACTION, ColorMode.COLOR_TEMP),
+ (CONF_LEVEL_ACTION, ColorMode.BRIGHTNESS),
+ (CONF_HS_ACTION, ColorMode.HS),
+ (CONF_RGB_ACTION, ColorMode.RGB),
+ (CONF_RGBW_ACTION, ColorMode.RGBW),
+ (CONF_RGBWW_ACTION, ColorMode.RGBWW),
+ ):
+ # Scripts can be an empty list, therefore we need to check for None
+ if (action_config := config.get(action_id)) is not None:
+ self.add_script(action_id, action_config, name, DOMAIN)
+ color_modes.add(color_mode)
self._supported_color_modes = filter_supported_color_modes(color_modes)
if len(self._supported_color_modes) > 1:
self._color_mode = ColorMode.UNKNOWN
@@ -257,7 +335,7 @@ class LightTemplate(TemplateEntity, LightEntity):
self._color_mode = next(iter(self._supported_color_modes))
self._attr_supported_features = LightEntityFeature(0)
- if self._effect_script is not None:
+ if (self._action_scripts.get(CONF_EFFECT_ACTION)) is not None:
self._attr_supported_features |= LightEntityFeature.EFFECT
if self._supports_transition is True:
self._attr_supported_features |= LightEntityFeature.TRANSITION
@@ -321,12 +399,12 @@ class LightTemplate(TemplateEntity, LightEntity):
return self._effect_list
@property
- def color_mode(self):
+ def color_mode(self) -> ColorMode | None:
"""Return current color mode."""
return self._color_mode
@property
- def supported_color_modes(self):
+ def supported_color_modes(self) -> set[ColorMode] | None:
"""Flag supported color modes."""
return self._supported_color_modes
@@ -374,14 +452,6 @@ class LightTemplate(TemplateEntity, LightEntity):
self._update_temperature,
none_on_template_error=True,
)
- if self._color_template:
- self.add_template_attribute(
- "_hs_color",
- self._color_template,
- None,
- self._update_hs,
- none_on_template_error=True,
- )
if self._hs_template:
self.add_template_attribute(
"_hs_color",
@@ -465,7 +535,7 @@ class LightTemplate(TemplateEntity, LightEntity):
)
self._color_mode = ColorMode.COLOR_TEMP
self._temperature = color_temp
- if self._hs_template is None and self._color_template is None:
+ if self._hs_template is None:
self._hs_color = None
if self._rgb_template is None:
self._rgb_color = None
@@ -475,11 +545,7 @@ class LightTemplate(TemplateEntity, LightEntity):
self._rgbww_color = None
optimistic_set = True
- if (
- self._hs_template is None
- and self._color_template is None
- and ATTR_HS_COLOR in kwargs
- ):
+ if self._hs_template is None and ATTR_HS_COLOR in kwargs:
_LOGGER.debug(
"Optimistically setting hs color to %s",
kwargs[ATTR_HS_COLOR],
@@ -505,7 +571,7 @@ class LightTemplate(TemplateEntity, LightEntity):
self._rgb_color = kwargs[ATTR_RGB_COLOR]
if self._temperature_template is None:
self._temperature = None
- if self._hs_template is None and self._color_template is None:
+ if self._hs_template is None:
self._hs_color = None
if self._rgbw_template is None:
self._rgbw_color = None
@@ -522,7 +588,7 @@ class LightTemplate(TemplateEntity, LightEntity):
self._rgbw_color = kwargs[ATTR_RGBW_COLOR]
if self._temperature_template is None:
self._temperature = None
- if self._hs_template is None and self._color_template is None:
+ if self._hs_template is None:
self._hs_color = None
if self._rgb_template is None:
self._rgb_color = None
@@ -539,7 +605,7 @@ class LightTemplate(TemplateEntity, LightEntity):
self._rgbww_color = kwargs[ATTR_RGBWW_COLOR]
if self._temperature_template is None:
self._temperature = None
- if self._hs_template is None and self._color_template is None:
+ if self._hs_template is None:
self._hs_color = None
if self._rgb_template is None:
self._rgb_color = None
@@ -555,17 +621,22 @@ class LightTemplate(TemplateEntity, LightEntity):
if ATTR_TRANSITION in kwargs and self._supports_transition is True:
common_params["transition"] = kwargs[ATTR_TRANSITION]
- if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temperature_script:
+ if ATTR_COLOR_TEMP_KELVIN in kwargs and (
+ temperature_script := self._action_scripts.get(CONF_TEMPERATURE_ACTION)
+ ):
common_params["color_temp"] = color_util.color_temperature_kelvin_to_mired(
kwargs[ATTR_COLOR_TEMP_KELVIN]
)
await self.async_run_script(
- self._temperature_script,
+ temperature_script,
run_variables=common_params,
context=self._context,
)
- elif ATTR_EFFECT in kwargs and self._effect_script:
+ elif ATTR_EFFECT in kwargs and (
+ effect_script := self._action_scripts.get(CONF_EFFECT_ACTION)
+ ):
+ assert self._effect_list is not None
effect = kwargs[ATTR_EFFECT]
if effect not in self._effect_list:
_LOGGER.error(
@@ -579,27 +650,22 @@ class LightTemplate(TemplateEntity, LightEntity):
common_params["effect"] = effect
await self.async_run_script(
- self._effect_script, run_variables=common_params, context=self._context
+ effect_script, run_variables=common_params, context=self._context
)
- elif ATTR_HS_COLOR in kwargs and self._color_script:
+ elif ATTR_HS_COLOR in kwargs and (
+ hs_script := self._action_scripts.get(CONF_HS_ACTION)
+ ):
hs_value = kwargs[ATTR_HS_COLOR]
common_params["hs"] = hs_value
common_params["h"] = int(hs_value[0])
common_params["s"] = int(hs_value[1])
await self.async_run_script(
- self._color_script, run_variables=common_params, context=self._context
+ hs_script, run_variables=common_params, context=self._context
)
- elif ATTR_HS_COLOR in kwargs and self._hs_script:
- hs_value = kwargs[ATTR_HS_COLOR]
- common_params["hs"] = hs_value
- common_params["h"] = int(hs_value[0])
- common_params["s"] = int(hs_value[1])
-
- await self.async_run_script(
- self._hs_script, run_variables=common_params, context=self._context
- )
- elif ATTR_RGBWW_COLOR in kwargs and self._rgbww_script:
+ elif ATTR_RGBWW_COLOR in kwargs and (
+ rgbww_script := self._action_scripts.get(CONF_RGBWW_ACTION)
+ ):
rgbww_value = kwargs[ATTR_RGBWW_COLOR]
common_params["rgbww"] = rgbww_value
common_params["rgb"] = (
@@ -614,9 +680,11 @@ class LightTemplate(TemplateEntity, LightEntity):
common_params["ww"] = int(rgbww_value[4])
await self.async_run_script(
- self._rgbww_script, run_variables=common_params, context=self._context
+ rgbww_script, run_variables=common_params, context=self._context
)
- elif ATTR_RGBW_COLOR in kwargs and self._rgbw_script:
+ elif ATTR_RGBW_COLOR in kwargs and (
+ rgbw_script := self._action_scripts.get(CONF_RGBW_ACTION)
+ ):
rgbw_value = kwargs[ATTR_RGBW_COLOR]
common_params["rgbw"] = rgbw_value
common_params["rgb"] = (
@@ -630,9 +698,11 @@ class LightTemplate(TemplateEntity, LightEntity):
common_params["w"] = int(rgbw_value[3])
await self.async_run_script(
- self._rgbw_script, run_variables=common_params, context=self._context
+ rgbw_script, run_variables=common_params, context=self._context
)
- elif ATTR_RGB_COLOR in kwargs and self._rgb_script:
+ elif ATTR_RGB_COLOR in kwargs and (
+ rgb_script := self._action_scripts.get(CONF_RGB_ACTION)
+ ):
rgb_value = kwargs[ATTR_RGB_COLOR]
common_params["rgb"] = rgb_value
common_params["r"] = int(rgb_value[0])
@@ -640,15 +710,19 @@ class LightTemplate(TemplateEntity, LightEntity):
common_params["b"] = int(rgb_value[2])
await self.async_run_script(
- self._rgb_script, run_variables=common_params, context=self._context
+ rgb_script, run_variables=common_params, context=self._context
)
- elif ATTR_BRIGHTNESS in kwargs and self._level_script:
+ elif ATTR_BRIGHTNESS in kwargs and (
+ level_script := self._action_scripts.get(CONF_LEVEL_ACTION)
+ ):
await self.async_run_script(
- self._level_script, run_variables=common_params, context=self._context
+ level_script, run_variables=common_params, context=self._context
)
else:
await self.async_run_script(
- self._on_script, run_variables=common_params, context=self._context
+ self._action_scripts[CONF_ON_ACTION],
+ run_variables=common_params,
+ context=self._context,
)
if optimistic_set:
@@ -656,14 +730,15 @@ class LightTemplate(TemplateEntity, LightEntity):
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
+ off_script = self._action_scripts[CONF_OFF_ACTION]
if ATTR_TRANSITION in kwargs and self._supports_transition is True:
await self.async_run_script(
- self._off_script,
+ off_script,
run_variables={"transition": kwargs[ATTR_TRANSITION]},
context=self._context,
)
else:
- await self.async_run_script(self._off_script, context=self._context)
+ await self.async_run_script(off_script, context=self._context)
if self._template is None:
self._state = False
self.async_write_ha_state()
@@ -1013,7 +1088,7 @@ class LightTemplate(TemplateEntity, LightEntity):
if render in (None, "None", ""):
self._supports_transition = False
return
- self._attr_supported_features &= LightEntityFeature.EFFECT
+ self._attr_supported_features &= ~LightEntityFeature.TRANSITION
self._supports_transition = bool(render)
if self._supports_transition:
self._attr_supported_features |= LightEntityFeature.TRANSITION
diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py
index 0804f92e46d..12a3e66cb5e 100644
--- a/homeassistant/components/template/lock.py
+++ b/homeassistant/components/template/lock.py
@@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError, TemplateError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
@@ -90,13 +89,19 @@ class TemplateLock(TemplateEntity, LockEntity):
)
self._state: LockState | None = None
name = self._attr_name
- assert name
+ if TYPE_CHECKING:
+ assert name is not None
+
self._state_template = config.get(CONF_VALUE_TEMPLATE)
- self._command_lock = Script(hass, config[CONF_LOCK], name, DOMAIN)
- self._command_unlock = Script(hass, config[CONF_UNLOCK], name, DOMAIN)
- if CONF_OPEN in config:
- self._command_open = Script(hass, config[CONF_OPEN], name, DOMAIN)
- self._attr_supported_features |= LockEntityFeature.OPEN
+ for action_id, supported_feature in (
+ (CONF_LOCK, 0),
+ (CONF_UNLOCK, 0),
+ (CONF_OPEN, LockEntityFeature.OPEN),
+ ):
+ # Scripts can be an empty list, therefore we need to check for None
+ if (action_config := config.get(action_id)) is not None:
+ self.add_script(action_id, action_config, name, DOMAIN)
+ self._attr_supported_features |= supported_feature
self._code_format_template = config.get(CONF_CODE_FORMAT_TEMPLATE)
self._code_format: str | None = None
self._code_format_template_error: TemplateError | None = None
@@ -210,7 +215,9 @@ class TemplateLock(TemplateEntity, LockEntity):
tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None}
await self.async_run_script(
- self._command_lock, run_variables=tpl_vars, context=self._context
+ self._action_scripts[CONF_LOCK],
+ run_variables=tpl_vars,
+ context=self._context,
)
async def async_unlock(self, **kwargs: Any) -> None:
@@ -226,7 +233,9 @@ class TemplateLock(TemplateEntity, LockEntity):
tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None}
await self.async_run_script(
- self._command_unlock, run_variables=tpl_vars, context=self._context
+ self._action_scripts[CONF_UNLOCK],
+ run_variables=tpl_vars,
+ context=self._context,
)
async def async_open(self, **kwargs: Any) -> None:
@@ -242,7 +251,9 @@ class TemplateLock(TemplateEntity, LockEntity):
tpl_vars = {ATTR_CODE: kwargs.get(ATTR_CODE) if kwargs else None}
await self.async_run_script(
- self._command_open, run_variables=tpl_vars, context=self._context
+ self._action_scripts[CONF_OPEN],
+ run_variables=tpl_vars,
+ context=self._context,
)
def _raise_template_error_if_available(self):
diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json
index f1225f74f06..32bfd8ce02e 100644
--- a/homeassistant/components/template/manifest.json
+++ b/homeassistant/components/template/manifest.json
@@ -2,7 +2,7 @@
"domain": "template",
"name": "Template",
"after_dependencies": ["group"],
- "codeowners": ["@PhracturedBlue", "@home-assistant/core"],
+ "codeowners": ["@Petro31", "@PhracturedBlue", "@home-assistant/core"],
"config_flow": true,
"dependencies": ["blueprint"],
"documentation": "https://www.home-assistant.io/integrations/template",
diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py
index 90dd555ca42..3ecf1db565a 100644
--- a/homeassistant/components/template/number.py
+++ b/homeassistant/components/template/number.py
@@ -27,8 +27,10 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.script import Script
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
@@ -121,7 +123,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
_options = dict(config_entry.options)
@@ -154,9 +156,7 @@ class TemplateNumber(TemplateEntity, NumberEntity):
super().__init__(hass, config=config, unique_id=unique_id)
assert self._attr_name is not None
self._value_template = config[CONF_STATE]
- self._command_set_value = Script(
- hass, config[CONF_SET_VALUE], self._attr_name, DOMAIN
- )
+ self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], self._attr_name, DOMAIN)
self._step_template = config[CONF_STEP]
self._min_value_template = config[CONF_MIN]
@@ -207,9 +207,9 @@ class TemplateNumber(TemplateEntity, NumberEntity):
if self._optimistic:
self._attr_native_value = value
self.async_write_ha_state()
- if self._command_set_value:
+ if set_value := self._action_scripts.get(CONF_SET_VALUE):
await self.async_run_script(
- self._command_set_value,
+ set_value,
run_variables={ATTR_VALUE: value},
context=self._context,
)
@@ -235,12 +235,8 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity):
"""Initialize the entity."""
super().__init__(hass, coordinator, config)
- self._command_set_value = Script(
- hass,
- config[CONF_SET_VALUE],
- self._rendered.get(CONF_NAME, DEFAULT_NAME),
- DOMAIN,
- )
+ name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
+ self.add_script(CONF_SET_VALUE, config[CONF_SET_VALUE], name, DOMAIN)
self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
@@ -275,6 +271,9 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity):
if self._config[CONF_OPTIMISTIC]:
self._attr_native_value = value
self.async_write_ha_state()
- await self._command_set_value.async_run(
- {ATTR_VALUE: value}, context=self._context
- )
+ if set_value := self._action_scripts.get(CONF_SET_VALUE):
+ await self.async_run_script(
+ set_value,
+ run_variables={ATTR_VALUE: value},
+ context=self._context,
+ )
diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py
index bd37ca1015c..74d88ee96c4 100644
--- a/homeassistant/components/template/select.py
+++ b/homeassistant/components/template/select.py
@@ -24,8 +24,10 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, selector
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.script import Script
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from . import TriggerUpdateCoordinator
@@ -115,7 +117,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
_options = dict(config_entry.options)
@@ -139,10 +141,9 @@ class TemplateSelect(TemplateEntity, SelectEntity):
super().__init__(hass, config=config, unique_id=unique_id)
assert self._attr_name is not None
self._value_template = config[CONF_STATE]
- if (selection_option := config.get(CONF_SELECT_OPTION)) is not None:
- self._command_select_option = Script(
- hass, selection_option, self._attr_name, DOMAIN
- )
+ # Scripts can be an empty list, therefore we need to check for None
+ if (select_option := config.get(CONF_SELECT_OPTION)) is not None:
+ self.add_script(CONF_SELECT_OPTION, select_option, self._attr_name, DOMAIN)
self._options_template = config[ATTR_OPTIONS]
self._attr_assumed_state = self._optimistic = config.get(CONF_OPTIMISTIC, False)
self._attr_options = []
@@ -174,9 +175,9 @@ class TemplateSelect(TemplateEntity, SelectEntity):
if self._optimistic:
self._attr_current_option = option
self.async_write_ha_state()
- if self._command_select_option:
+ if select_option := self._action_scripts.get(CONF_SELECT_OPTION):
await self.async_run_script(
- self._command_select_option,
+ select_option,
run_variables={ATTR_OPTION: option},
context=self._context,
)
@@ -197,12 +198,14 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity):
) -> None:
"""Initialize the entity."""
super().__init__(hass, coordinator, config)
- self._command_select_option = Script(
- hass,
- config[CONF_SELECT_OPTION],
- self._rendered.get(CONF_NAME, DEFAULT_NAME),
- DOMAIN,
- )
+ # Scripts can be an empty list, therefore we need to check for None
+ if (select_option := config.get(CONF_SELECT_OPTION)) is not None:
+ self.add_script(
+ CONF_SELECT_OPTION,
+ select_option,
+ self._rendered.get(CONF_NAME, DEFAULT_NAME),
+ DOMAIN,
+ )
@property
def current_option(self) -> str | None:
@@ -219,6 +222,9 @@ class TriggerSelectEntity(TriggerEntity, SelectEntity):
if self._config[CONF_OPTIMISTIC]:
self._attr_current_option = option
self.async_write_ha_state()
- await self._command_select_option.async_run(
- {ATTR_OPTION: option}, context=self._context
- )
+ if select_option := self._action_scripts.get(CONF_SELECT_OPTION):
+ await self.async_run_script(
+ select_option,
+ run_variables={ATTR_OPTION: option},
+ context=self._context,
+ )
diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py
index ee24407699d..ca3736ebf76 100644
--- a/homeassistant/components/template/sensor.py
+++ b/homeassistant/components/template/sensor.py
@@ -44,7 +44,10 @@ from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv, selector, template
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -178,7 +181,7 @@ _LOGGER = logging.getLogger(__name__)
@callback
def _async_create_template_tracking_entities(
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddEntitiesCallback | AddConfigEntryEntitiesCallback,
hass: HomeAssistant,
definitions: list[dict],
unique_id_prefix: str | None,
@@ -237,7 +240,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
_options = dict(config_entry.options)
diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py
index bddb51e5e67..1d18ea9d5ca 100644
--- a/homeassistant/components/template/switch.py
+++ b/homeassistant/components/template/switch.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Any
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -17,6 +17,7 @@ from homeassistant.const import (
ATTR_FRIENDLY_NAME,
CONF_DEVICE_ID,
CONF_NAME,
+ CONF_STATE,
CONF_SWITCHES,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
@@ -25,24 +26,51 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import config_validation as cv, selector
+from homeassistant.helpers import config_validation as cv, selector, template
from homeassistant.helpers.device import async_device_info_to_link_from_device_id
from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.restore_state import RestoreEntity
-from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from .const import CONF_TURN_OFF, CONF_TURN_ON, DOMAIN
+from .const import CONF_OBJECT_ID, CONF_PICTURE, CONF_TURN_OFF, CONF_TURN_ON, DOMAIN
from .template_entity import (
+ LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
+ TEMPLATE_ENTITY_AVAILABILITY_SCHEMA,
TEMPLATE_ENTITY_COMMON_SCHEMA_LEGACY,
+ TEMPLATE_ENTITY_ICON_SCHEMA,
TemplateEntity,
rewrite_common_legacy_to_modern_conf,
)
_VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"]
-SWITCH_SCHEMA = vol.All(
+LEGACY_FIELDS = TEMPLATE_ENTITY_LEGACY_FIELDS | {
+ CONF_VALUE_TEMPLATE: CONF_STATE,
+}
+
+DEFAULT_NAME = "Template Switch"
+
+
+SWITCH_SCHEMA = (
+ vol.Schema(
+ {
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.template,
+ vol.Optional(CONF_STATE): cv.template,
+ vol.Required(CONF_TURN_ON): cv.SCRIPT_SCHEMA,
+ vol.Required(CONF_TURN_OFF): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_PICTURE): cv.template,
+ }
+ )
+ .extend(TEMPLATE_ENTITY_AVAILABILITY_SCHEMA.schema)
+ .extend(TEMPLATE_ENTITY_ICON_SCHEMA.schema)
+)
+
+LEGACY_SWITCH_SCHEMA = vol.All(
cv.deprecated(ATTR_ENTITY_ID),
vol.Schema(
{
@@ -57,13 +85,13 @@ SWITCH_SCHEMA = vol.All(
)
PLATFORM_SCHEMA = SWITCH_PLATFORM_SCHEMA.extend(
- {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA)}
+ {vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(LEGACY_SWITCH_SCHEMA)}
)
SWITCH_CONFIG_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.template,
- vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_STATE): cv.template,
vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(),
@@ -71,24 +99,62 @@ SWITCH_CONFIG_SCHEMA = vol.Schema(
)
-async def _async_create_entities(hass, config):
- """Create the Template switches."""
+def rewrite_legacy_to_modern_conf(
+ hass: HomeAssistant, config: dict[str, dict]
+) -> list[dict]:
+ """Rewrite legacy switch configuration definitions to modern ones."""
switches = []
- for object_id, entity_config in config[CONF_SWITCHES].items():
- entity_config = rewrite_common_legacy_to_modern_conf(hass, entity_config)
- unique_id = entity_config.get(CONF_UNIQUE_ID)
+ for object_id, entity_conf in config.items():
+ entity_conf = {**entity_conf, CONF_OBJECT_ID: object_id}
+
+ entity_conf = rewrite_common_legacy_to_modern_conf(
+ hass, entity_conf, LEGACY_FIELDS
+ )
+
+ if CONF_NAME not in entity_conf:
+ entity_conf[CONF_NAME] = template.Template(object_id, hass)
+
+ switches.append(entity_conf)
+
+ return switches
+
+
+def rewrite_options_to_modern_conf(option_config: dict[str, dict]) -> dict[str, dict]:
+ """Rewrite option configuration to modern configuration."""
+ option_config = {**option_config}
+
+ if CONF_VALUE_TEMPLATE in option_config:
+ option_config[CONF_STATE] = option_config.pop(CONF_VALUE_TEMPLATE)
+
+ return option_config
+
+
+@callback
+def _async_create_template_tracking_entities(
+ async_add_entities: AddEntitiesCallback,
+ hass: HomeAssistant,
+ definitions: list[dict],
+ unique_id_prefix: str | None,
+) -> None:
+ """Create the template switches."""
+ switches = []
+
+ for entity_conf in definitions:
+ unique_id = entity_conf.get(CONF_UNIQUE_ID)
+
+ if unique_id and unique_id_prefix:
+ unique_id = f"{unique_id_prefix}-{unique_id}"
switches.append(
SwitchTemplate(
hass,
- object_id,
- entity_config,
+ entity_conf,
unique_id,
)
)
- return switches
+ async_add_entities(switches)
async def async_setup_platform(
@@ -98,21 +164,34 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the template switches."""
- async_add_entities(await _async_create_entities(hass, config))
+ if discovery_info is None:
+ _async_create_template_tracking_entities(
+ async_add_entities,
+ hass,
+ rewrite_legacy_to_modern_conf(hass, config[CONF_SWITCHES]),
+ None,
+ )
+ return
+
+ _async_create_template_tracking_entities(
+ async_add_entities,
+ hass,
+ discovery_info["entities"],
+ discovery_info["unique_id"],
+ )
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize config entry."""
_options = dict(config_entry.options)
_options.pop("template_type")
+ _options = rewrite_options_to_modern_conf(_options)
validated_config = SWITCH_CONFIG_SCHEMA(_options)
- async_add_entities(
- [SwitchTemplate(hass, None, validated_config, config_entry.entry_id)]
- )
+ async_add_entities([SwitchTemplate(hass, validated_config, config_entry.entry_id)])
@callback
@@ -120,8 +199,9 @@ def async_create_preview_switch(
hass: HomeAssistant, name: str, config: dict[str, Any]
) -> SwitchTemplate:
"""Create a preview switch."""
- validated_config = SWITCH_CONFIG_SCHEMA(config | {CONF_NAME: name})
- return SwitchTemplate(hass, None, validated_config, None)
+ updated_config = rewrite_options_to_modern_conf(config)
+ validated_config = SWITCH_CONFIG_SCHEMA(updated_config | {CONF_NAME: name})
+ return SwitchTemplate(hass, validated_config, None)
class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity):
@@ -131,31 +211,27 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity):
def __init__(
self,
- hass,
- object_id,
- config,
- unique_id,
- ):
+ hass: HomeAssistant,
+ config: ConfigType,
+ unique_id: str | None,
+ ) -> None:
"""Initialize the Template switch."""
- super().__init__(
- hass, config=config, fallback_name=object_id, unique_id=unique_id
- )
- if object_id is not None:
+ super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id)
+ if (object_id := config.get(CONF_OBJECT_ID)) is not None:
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
- friendly_name = self._attr_name
- self._template = config.get(CONF_VALUE_TEMPLATE)
- self._on_script = (
- Script(hass, config.get(CONF_TURN_ON), friendly_name, DOMAIN)
- if config.get(CONF_TURN_ON) is not None
- else None
- )
- self._off_script = (
- Script(hass, config.get(CONF_TURN_OFF), friendly_name, DOMAIN)
- if config.get(CONF_TURN_OFF) is not None
- else None
- )
+ name = self._attr_name
+ if TYPE_CHECKING:
+ assert name is not None
+ self._template = config.get(CONF_STATE)
+
+ # Scripts can be an empty list, therefore we need to check for None
+ if (on_action := config.get(CONF_TURN_ON)) is not None:
+ self.add_script(CONF_TURN_ON, on_action, name, DOMAIN)
+ if (off_action := config.get(CONF_TURN_OFF)) is not None:
+ self.add_script(CONF_TURN_OFF, off_action, name, DOMAIN)
+
self._state: bool | None = False
self._attr_assumed_state = self._template is None
self._attr_device_info = async_device_info_to_link_from_device_id(
@@ -206,16 +282,16 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Fire the on action."""
- if self._on_script:
- await self.async_run_script(self._on_script, context=self._context)
+ if on_script := self._action_scripts.get(CONF_TURN_ON):
+ await self.async_run_script(on_script, context=self._context)
if self._template is None:
self._state = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Fire the off action."""
- if self._off_script:
- await self.async_run_script(self._off_script, context=self._context)
+ if off_script := self._action_scripts.get(CONF_TURN_OFF):
+ await self.async_run_script(off_script, context=self._context)
if self._template is None:
self._state = False
self.async_write_ha_state()
diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py
index 8f9edca5976..88708278758 100644
--- a/homeassistant/components/template/template_entity.py
+++ b/homeassistant/components/template/template_entity.py
@@ -24,7 +24,6 @@ from homeassistant.const import (
)
from homeassistant.core import (
CALLBACK_TYPE,
- Context,
Event,
EventStateChangedData,
HomeAssistant,
@@ -41,7 +40,7 @@ from homeassistant.helpers.event import (
TrackTemplateResultInfo,
async_track_template_result,
)
-from homeassistant.helpers.script import Script, _VarsType
+from homeassistant.helpers.script_variables import ScriptVariables
from homeassistant.helpers.start import async_at_start
from homeassistant.helpers.template import (
Template,
@@ -61,6 +60,7 @@ from .const import (
CONF_AVAILABILITY_TEMPLATE,
CONF_PICTURE,
)
+from .entity import AbstractTemplateEntity
_LOGGER = logging.getLogger(__name__)
@@ -248,7 +248,7 @@ class _TemplateAttribute:
return
-class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module
+class TemplateEntity(AbstractTemplateEntity):
"""Entity that uses templates to calculate attributes."""
_attr_available = True
@@ -268,6 +268,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module
unique_id: str | None = None,
) -> None:
"""Template Entity."""
+ super().__init__(hass)
self._template_attrs: dict[Template, list[_TemplateAttribute]] = {}
self._template_result_info: TrackTemplateResultInfo | None = None
self._attr_extra_state_attributes = {}
@@ -285,6 +286,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module
]
| None
) = None
+ self._run_variables: ScriptVariables | dict
if config is None:
self._attribute_templates = attribute_templates
self._availability_template = availability_template
@@ -339,18 +341,6 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module
variables=variables, parse_result=False
)
- @callback
- def _render_variables(self) -> dict:
- if isinstance(self._run_variables, dict):
- return self._run_variables
-
- return self._run_variables.async_render(
- self.hass,
- {
- "this": TemplateStateFromEntityId(self.hass, self.entity_id),
- },
- )
-
@callback
def _update_available(self, result: str | TemplateError) -> None:
if isinstance(result, TemplateError):
@@ -387,6 +377,18 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module
return None
return cast(str, self._blueprint_inputs[CONF_USE_BLUEPRINT][CONF_PATH])
+ def _render_script_variables(self) -> dict[str, Any]:
+ """Render configured variables."""
+ if isinstance(self._run_variables, dict):
+ return self._run_variables
+
+ return self._run_variables.async_render(
+ self.hass,
+ {
+ "this": TemplateStateFromEntityId(self.hass, self.entity_id),
+ },
+ )
+
def add_template_attribute(
self,
attribute: str,
@@ -488,7 +490,7 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module
variables = {
"this": TemplateStateFromEntityId(self.hass, self.entity_id),
- **self._render_variables(),
+ **self._render_script_variables(),
}
for template, attributes in self._template_attrs.items():
@@ -581,22 +583,3 @@ class TemplateEntity(Entity): # pylint: disable=hass-enforce-class-module
"""Call for forced update."""
assert self._template_result_info
self._template_result_info.async_refresh()
-
- async def async_run_script(
- self,
- script: Script,
- *,
- run_variables: _VarsType | None = None,
- context: Context | None = None,
- ) -> None:
- """Run an action script."""
- if run_variables is None:
- run_variables = {}
- await script.async_run(
- run_variables={
- "this": TemplateStateFromEntityId(self.hass, self.entity_id),
- **self._render_variables(),
- **run_variables,
- },
- context=context,
- )
diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py
index 5130f332d5b..87c93b6143b 100644
--- a/homeassistant/components/template/trigger_entity.py
+++ b/homeassistant/components/template/trigger_entity.py
@@ -8,10 +8,13 @@ from homeassistant.helpers.trigger_template_entity import TriggerBaseEntity
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import TriggerUpdateCoordinator
+from .entity import AbstractTemplateEntity
class TriggerEntity( # pylint: disable=hass-enforce-class-module
- TriggerBaseEntity, CoordinatorEntity[TriggerUpdateCoordinator]
+ TriggerBaseEntity,
+ CoordinatorEntity[TriggerUpdateCoordinator],
+ AbstractTemplateEntity,
):
"""Template entity based on trigger data."""
@@ -24,6 +27,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
"""Initialize the entity."""
CoordinatorEntity.__init__(self, coordinator)
TriggerBaseEntity.__init__(self, hass, config)
+ AbstractTemplateEntity.__init__(self, hass)
async def async_added_to_hass(self) -> None:
"""Handle being added to Home Assistant."""
@@ -38,6 +42,16 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
else:
self._unique_id = unique_id
+ @property
+ def referenced_blueprint(self) -> str | None:
+ """Return referenced blueprint or None."""
+ return self.coordinator.referenced_blueprint
+
+ @callback
+ def _render_script_variables(self) -> dict:
+ """Render configured variables."""
+ return self.coordinator.data["run_variables"]
+
@callback
def _process_data(self) -> None:
"""Process new data."""
diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py
index b977f4e659a..1e18b06436a 100644
--- a/homeassistant/components/template/vacuum.py
+++ b/homeassistant/components/template/vacuum.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
-from typing import Any
+from typing import TYPE_CHECKING, Any
import voluptuous as vol
@@ -33,7 +33,6 @@ from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.helpers.entity_platform import AddEntitiesCallback
-from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
@@ -90,7 +89,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
)
-async def _async_create_entities(hass, config):
+async def _async_create_entities(hass: HomeAssistant, config: ConfigType):
"""Create the Template Vacuums."""
vacuums = []
@@ -127,11 +126,11 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity):
def __init__(
self,
- hass,
+ hass: HomeAssistant,
object_id,
- config,
+ config: ConfigType,
unique_id,
- ):
+ ) -> None:
"""Initialize the vacuum."""
super().__init__(
hass, config=config, fallback_name=object_id, unique_id=unique_id
@@ -139,7 +138,9 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity):
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, object_id, hass=hass
)
- friendly_name = self._attr_name
+ name = self._attr_name
+ if TYPE_CHECKING:
+ assert name is not None
self._template = config.get(CONF_VALUE_TEMPLATE)
self._battery_level_template = config.get(CONF_BATTERY_LEVEL_TEMPLATE)
@@ -148,43 +149,19 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity):
VacuumEntityFeature.START | VacuumEntityFeature.STATE
)
- self._start_script = Script(hass, config[SERVICE_START], friendly_name, DOMAIN)
-
- self._pause_script = None
- if pause_action := config.get(SERVICE_PAUSE):
- self._pause_script = Script(hass, pause_action, friendly_name, DOMAIN)
- self._attr_supported_features |= VacuumEntityFeature.PAUSE
-
- self._stop_script = None
- if stop_action := config.get(SERVICE_STOP):
- self._stop_script = Script(hass, stop_action, friendly_name, DOMAIN)
- self._attr_supported_features |= VacuumEntityFeature.STOP
-
- self._return_to_base_script = None
- if return_to_base_action := config.get(SERVICE_RETURN_TO_BASE):
- self._return_to_base_script = Script(
- hass, return_to_base_action, friendly_name, DOMAIN
- )
- self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME
-
- self._clean_spot_script = None
- if clean_spot_action := config.get(SERVICE_CLEAN_SPOT):
- self._clean_spot_script = Script(
- hass, clean_spot_action, friendly_name, DOMAIN
- )
- self._attr_supported_features |= VacuumEntityFeature.CLEAN_SPOT
-
- self._locate_script = None
- if locate_action := config.get(SERVICE_LOCATE):
- self._locate_script = Script(hass, locate_action, friendly_name, DOMAIN)
- self._attr_supported_features |= VacuumEntityFeature.LOCATE
-
- self._set_fan_speed_script = None
- if set_fan_speed_action := config.get(SERVICE_SET_FAN_SPEED):
- self._set_fan_speed_script = Script(
- hass, set_fan_speed_action, friendly_name, DOMAIN
- )
- self._attr_supported_features |= VacuumEntityFeature.FAN_SPEED
+ for action_id, supported_feature in (
+ (SERVICE_START, 0),
+ (SERVICE_PAUSE, VacuumEntityFeature.PAUSE),
+ (SERVICE_STOP, VacuumEntityFeature.STOP),
+ (SERVICE_RETURN_TO_BASE, VacuumEntityFeature.RETURN_HOME),
+ (SERVICE_CLEAN_SPOT, VacuumEntityFeature.CLEAN_SPOT),
+ (SERVICE_LOCATE, VacuumEntityFeature.LOCATE),
+ (SERVICE_SET_FAN_SPEED, VacuumEntityFeature.FAN_SPEED),
+ ):
+ # Scripts can be an empty list, therefore we need to check for None
+ if (action_config := config.get(action_id)) is not None:
+ self.add_script(action_id, action_config, name, DOMAIN)
+ self._attr_supported_features |= supported_feature
self._state = None
self._battery_level = None
@@ -203,62 +180,50 @@ class TemplateVacuum(TemplateEntity, StateVacuumEntity):
async def async_start(self) -> None:
"""Start or resume the cleaning task."""
- await self.async_run_script(self._start_script, context=self._context)
+ await self.async_run_script(
+ self._action_scripts[SERVICE_START], context=self._context
+ )
async def async_pause(self) -> None:
"""Pause the cleaning task."""
- if self._pause_script is None:
- return
-
- await self.async_run_script(self._pause_script, context=self._context)
+ if script := self._action_scripts.get(SERVICE_PAUSE):
+ await self.async_run_script(script, context=self._context)
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the cleaning task."""
- if self._stop_script is None:
- return
-
- await self.async_run_script(self._stop_script, context=self._context)
+ if script := self._action_scripts.get(SERVICE_STOP):
+ await self.async_run_script(script, context=self._context)
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Set the vacuum cleaner to return to the dock."""
- if self._return_to_base_script is None:
- return
-
- await self.async_run_script(self._return_to_base_script, context=self._context)
+ if script := self._action_scripts.get(SERVICE_RETURN_TO_BASE):
+ await self.async_run_script(script, context=self._context)
async def async_clean_spot(self, **kwargs: Any) -> None:
"""Perform a spot clean-up."""
- if self._clean_spot_script is None:
- return
-
- await self.async_run_script(self._clean_spot_script, context=self._context)
+ if script := self._action_scripts.get(SERVICE_CLEAN_SPOT):
+ await self.async_run_script(script, context=self._context)
async def async_locate(self, **kwargs: Any) -> None:
"""Locate the vacuum cleaner."""
- if self._locate_script is None:
- return
-
- await self.async_run_script(self._locate_script, context=self._context)
+ if script := self._action_scripts.get(SERVICE_LOCATE):
+ await self.async_run_script(script, context=self._context)
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set fan speed."""
- if self._set_fan_speed_script is None:
- return
-
- if fan_speed in self._attr_fan_speed_list:
- self._attr_fan_speed = fan_speed
- await self.async_run_script(
- self._set_fan_speed_script,
- run_variables={ATTR_FAN_SPEED: fan_speed},
- context=self._context,
- )
- else:
+ if fan_speed not in self._attr_fan_speed_list:
_LOGGER.error(
"Received invalid fan speed: %s for entity %s. Expected: %s",
fan_speed,
self.entity_id,
self._attr_fan_speed_list,
)
+ return
+
+ if script := self._action_scripts.get(SERVICE_SET_FAN_SPEED):
+ await self.async_run_script(
+ script, run_variables={ATTR_FAN_SPEED: fan_speed}, context=self._context
+ )
@callback
def _async_setup_templates(self) -> None:
diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py
index 7f597f1d9a8..86bab6f5ad1 100644
--- a/homeassistant/components/template/weather.py
+++ b/homeassistant/components/template/weather.py
@@ -135,6 +135,33 @@ WEATHER_SCHEMA = vol.Schema(
PLATFORM_SCHEMA = WEATHER_PLATFORM_SCHEMA.extend(WEATHER_SCHEMA.schema)
+@callback
+def _async_create_template_tracking_entities(
+ async_add_entities: AddEntitiesCallback,
+ hass: HomeAssistant,
+ definitions: list[dict],
+ unique_id_prefix: str | None,
+) -> None:
+ """Create the weather entities."""
+ entities = []
+
+ for entity_conf in definitions:
+ unique_id = entity_conf.get(CONF_UNIQUE_ID)
+
+ if unique_id and unique_id_prefix:
+ unique_id = f"{unique_id_prefix}-{unique_id}"
+
+ entities.append(
+ WeatherTemplate(
+ hass,
+ entity_conf,
+ unique_id,
+ )
+ )
+
+ async_add_entities(entities)
+
+
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@@ -142,24 +169,32 @@ async def async_setup_platform(
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Template weather."""
- if discovery_info and "coordinator" in discovery_info:
+ if discovery_info is None:
+ config = rewrite_common_legacy_to_modern_conf(hass, config)
+ unique_id = config.get(CONF_UNIQUE_ID)
+ async_add_entities(
+ [
+ WeatherTemplate(
+ hass,
+ config,
+ unique_id,
+ )
+ ]
+ )
+ return
+
+ if "coordinator" in discovery_info:
async_add_entities(
TriggerWeatherEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
- config = rewrite_common_legacy_to_modern_conf(hass, config)
- unique_id = config.get(CONF_UNIQUE_ID)
-
- async_add_entities(
- [
- WeatherTemplate(
- hass,
- config,
- unique_id,
- )
- ]
+ _async_create_template_tracking_entities(
+ async_add_entities,
+ hass,
+ discovery_info["entities"],
+ discovery_info["unique_id"],
)
diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json
index 81705e326f7..11e1b1d3485 100644
--- a/homeassistant/components/tensorflow/manifest.json
+++ b/homeassistant/components/tensorflow/manifest.json
@@ -11,6 +11,6 @@
"tf-models-official==2.5.0",
"pycocotools==2.0.6",
"numpy==2.2.2",
- "Pillow==11.1.0"
+ "Pillow==11.2.1"
]
}
diff --git a/homeassistant/components/tesla_fleet/__init__.py b/homeassistant/components/tesla_fleet/__init__.py
index 27bfb9134ab..2642bd2f7d5 100644
--- a/homeassistant/components/tesla_fleet/__init__.py
+++ b/homeassistant/components/tesla_fleet/__init__.py
@@ -5,12 +5,7 @@ from typing import Final
from aiohttp.client_exceptions import ClientResponseError
import jwt
-from tesla_fleet_api import (
- EnergySpecific,
- TeslaFleetApi,
- VehicleSigned,
- VehicleSpecific,
-)
+from tesla_fleet_api import TeslaFleetApi
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
InvalidRegion,
@@ -128,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
vehicles: list[TeslaFleetVehicleData] = []
energysites: list[TeslaFleetEnergyData] = []
for product in products:
- if "vin" in product and hasattr(tesla, "vehicle"):
+ if "vin" in product and Scope.VEHICLE_DEVICE_DATA in scopes:
# Remove the protobuff 'cached_data' that we do not use to save memory
product.pop("cached_data", None)
vin = product["vin"]
@@ -136,9 +131,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
if signing:
if not tesla.private_key:
await tesla.get_private_key(hass.config.path("tesla_fleet.key"))
- api = VehicleSigned(tesla.vehicle, vin)
+ api = tesla.vehicles.createSigned(vin)
else:
- api = VehicleSpecific(tesla.vehicle, vin)
+ api = tesla.vehicles.createFleet(vin)
coordinator = TeslaFleetVehicleDataCoordinator(hass, entry, api, product)
await coordinator.async_config_entry_first_refresh()
@@ -160,7 +155,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
signing=signing,
)
)
- elif "energy_site_id" in product and hasattr(tesla, "energy"):
+ elif "energy_site_id" in product and Scope.ENERGY_DEVICE_DATA in scopes:
site_id = product["energy_site_id"]
if not (
product["components"]["battery"]
@@ -173,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
)
continue
- api = EnergySpecific(tesla.energy, site_id)
+ api = tesla.energySites.create(site_id)
live_coordinator = TeslaFleetEnergySiteLiveCoordinator(hass, entry, api)
history_coordinator = TeslaFleetEnergySiteHistoryCoordinator(
@@ -227,7 +222,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
# Setup Platforms
entry.runtime_data = TeslaFleetData(vehicles, energysites, scopes)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
-
return True
diff --git a/homeassistant/components/tesla_fleet/binary_sensor.py b/homeassistant/components/tesla_fleet/binary_sensor.py
index b92ef9233d1..886fe304c91 100644
--- a/homeassistant/components/tesla_fleet/binary_sensor.py
+++ b/homeassistant/components/tesla_fleet/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import TeslaFleetConfigEntry
@@ -179,7 +179,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslaFleetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tesla Fleet binary sensor platform from a config entry."""
diff --git a/homeassistant/components/tesla_fleet/button.py b/homeassistant/components/tesla_fleet/button.py
index aea0f91a97c..2ddce2d517b 100644
--- a/homeassistant/components/tesla_fleet/button.py
+++ b/homeassistant/components/tesla_fleet/button.py
@@ -10,7 +10,7 @@ from tesla_fleet_api.const import Scope
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TeslaFleetConfigEntry
from .entity import TeslaFleetVehicleEntity
@@ -61,7 +61,7 @@ DESCRIPTIONS: tuple[TeslaFleetButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslaFleetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the TeslaFleet Button platform from a config entry."""
diff --git a/homeassistant/components/tesla_fleet/climate.py b/homeassistant/components/tesla_fleet/climate.py
index 06e9c9d7c64..f752509ee17 100644
--- a/homeassistant/components/tesla_fleet/climate.py
+++ b/homeassistant/components/tesla_fleet/climate.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TeslaFleetConfigEntry
from .const import DOMAIN, TeslaFleetClimateSide
@@ -38,7 +38,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslaFleetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tesla Fleet Climate platform from a config entry."""
diff --git a/homeassistant/components/tesla_fleet/coordinator.py b/homeassistant/components/tesla_fleet/coordinator.py
index 129f460ff90..20d2d70b5dc 100644
--- a/homeassistant/components/tesla_fleet/coordinator.py
+++ b/homeassistant/components/tesla_fleet/coordinator.py
@@ -7,7 +7,6 @@ from random import randint
from time import time
from typing import TYPE_CHECKING, Any
-from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint
from tesla_fleet_api.exceptions import (
InvalidToken,
@@ -17,7 +16,7 @@ from tesla_fleet_api.exceptions import (
TeslaFleetError,
VehicleOffline,
)
-from tesla_fleet_api.ratecalculator import RateCalculator
+from tesla_fleet_api.tesla import EnergySite, VehicleFleet
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -28,7 +27,7 @@ if TYPE_CHECKING:
from .const import ENERGY_HISTORY_FIELDS, LOGGER, TeslaFleetState
-VEHICLE_INTERVAL_SECONDS = 300
+VEHICLE_INTERVAL_SECONDS = 600
VEHICLE_INTERVAL = timedelta(seconds=VEHICLE_INTERVAL_SECONDS)
VEHICLE_WAIT = timedelta(minutes=15)
@@ -66,13 +65,12 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
updated_once: bool
pre2021: bool
last_active: datetime
- rate: RateCalculator
def __init__(
self,
hass: HomeAssistant,
config_entry: TeslaFleetConfigEntry,
- api: VehicleSpecific,
+ api: VehicleFleet,
product: dict,
) -> None:
"""Initialize TeslaFleet Vehicle Update Coordinator."""
@@ -87,44 +85,36 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.data = flatten(product)
self.updated_once = False
self.last_active = datetime.now()
- self.rate = RateCalculator(100, 86400, VEHICLE_INTERVAL_SECONDS, 3600, 5)
async def _async_update_data(self) -> dict[str, Any]:
"""Update vehicle data using TeslaFleet API."""
try:
- # Check if the vehicle is awake using a non-rate limited API call
- if self.data["state"] != TeslaFleetState.ONLINE:
- response = await self.api.vehicle()
- self.data["state"] = response["response"]["state"]
+ # Check if the vehicle is awake using a free API call
+ response = await self.api.vehicle()
+ self.data["state"] = response["response"]["state"]
if self.data["state"] != TeslaFleetState.ONLINE:
return self.data
- # This is a rated limited API call
- self.rate.consume()
response = await self.api.vehicle_data(endpoints=ENDPOINTS)
data = response["response"]
except VehicleOffline:
self.data["state"] = TeslaFleetState.ASLEEP
return self.data
- except RateLimited as e:
+ except RateLimited:
LOGGER.warning(
- "%s rate limited, will retry in %s seconds",
+ "%s rate limited, will skip refresh",
self.name,
- e.data.get("after"),
)
- if "after" in e.data:
- self.update_interval = timedelta(seconds=int(e.data["after"]))
return self.data
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
- # Calculate ideal refresh interval
- self.update_interval = timedelta(seconds=self.rate.calculate())
+ self.update_interval = VEHICLE_INTERVAL
self.updated_once = True
@@ -159,7 +149,7 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
self,
hass: HomeAssistant,
config_entry: TeslaFleetConfigEntry,
- api: EnergySpecific,
+ api: EnergySite,
) -> None:
"""Initialize TeslaFleet Energy Site Live coordinator."""
super().__init__(
@@ -212,7 +202,7 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any
self,
hass: HomeAssistant,
config_entry: TeslaFleetConfigEntry,
- api: EnergySpecific,
+ api: EnergySite,
) -> None:
"""Initialize Tesla Fleet Energy Site History coordinator."""
super().__init__(
@@ -258,7 +248,7 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any
self.updated_once = True
# Add all time periods together
- output = {key: 0 for key in ENERGY_HISTORY_FIELDS}
+ output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0)
for period in data.get("time_series", []):
for key in ENERGY_HISTORY_FIELDS:
output[key] += period.get(key, 0)
@@ -276,7 +266,7 @@ class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
self,
hass: HomeAssistant,
config_entry: TeslaFleetConfigEntry,
- api: EnergySpecific,
+ api: EnergySite,
product: dict,
) -> None:
"""Initialize TeslaFleet Energy Info coordinator."""
diff --git a/homeassistant/components/tesla_fleet/cover.py b/homeassistant/components/tesla_fleet/cover.py
index f270734424f..701b107f9f9 100644
--- a/homeassistant/components/tesla_fleet/cover.py
+++ b/homeassistant/components/tesla_fleet/cover.py
@@ -12,7 +12,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TeslaFleetConfigEntry
from .entity import TeslaFleetVehicleEntity
@@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslaFleetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the TeslaFleet cover platform from a config entry."""
diff --git a/homeassistant/components/tesla_fleet/device_tracker.py b/homeassistant/components/tesla_fleet/device_tracker.py
index d6dcef895a6..19bf353c62d 100644
--- a/homeassistant/components/tesla_fleet/device_tracker.py
+++ b/homeassistant/components/tesla_fleet/device_tracker.py
@@ -6,7 +6,7 @@ from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .entity import TeslaFleetVehicleEntity
@@ -14,7 +14,9 @@ from .models import TeslaFleetVehicleData
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tesla Fleet device tracker platform from a config entry."""
diff --git a/homeassistant/components/tesla_fleet/entity.py b/homeassistant/components/tesla_fleet/entity.py
index 0260acf368e..583e92595d0 100644
--- a/homeassistant/components/tesla_fleet/entity.py
+++ b/homeassistant/components/tesla_fleet/entity.py
@@ -3,8 +3,9 @@
from abc import abstractmethod
from typing import Any
-from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import Scope
+from tesla_fleet_api.tesla.energysite import EnergySite
+from tesla_fleet_api.tesla.vehicle.fleet import VehicleFleet
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -41,7 +42,7 @@ class TeslaFleetEntity(
| TeslaFleetEnergySiteLiveCoordinator
| TeslaFleetEnergySiteHistoryCoordinator
| TeslaFleetEnergySiteInfoCoordinator,
- api: VehicleSpecific | EnergySpecific,
+ api: VehicleFleet | EnergySite,
key: str,
) -> None:
"""Initialize common aspects of a TeslaFleet entity."""
diff --git a/homeassistant/components/tesla_fleet/lock.py b/homeassistant/components/tesla_fleet/lock.py
index 32998d409be..cdb1d4b066b 100644
--- a/homeassistant/components/tesla_fleet/lock.py
+++ b/homeassistant/components/tesla_fleet/lock.py
@@ -9,7 +9,7 @@ from tesla_fleet_api.const import Scope
from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TeslaFleetConfigEntry
from .const import DOMAIN
@@ -25,7 +25,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslaFleetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the TeslaFleet lock platform from a config entry."""
diff --git a/homeassistant/components/tesla_fleet/manifest.json b/homeassistant/components/tesla_fleet/manifest.json
index 330745316d7..53c8e7d554c 100644
--- a/homeassistant/components/tesla_fleet/manifest.json
+++ b/homeassistant/components/tesla_fleet/manifest.json
@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
- "requirements": ["tesla-fleet-api==0.9.8"]
+ "requirements": ["tesla-fleet-api==1.0.17"]
}
diff --git a/homeassistant/components/tesla_fleet/media_player.py b/homeassistant/components/tesla_fleet/media_player.py
index 455c990077d..89f0768f082 100644
--- a/homeassistant/components/tesla_fleet/media_player.py
+++ b/homeassistant/components/tesla_fleet/media_player.py
@@ -11,7 +11,7 @@ from homeassistant.components.media_player import (
MediaPlayerState,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TeslaFleetConfigEntry
from .entity import TeslaFleetVehicleEntity
@@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslaFleetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tesla Fleet Media platform from a config entry."""
diff --git a/homeassistant/components/tesla_fleet/models.py b/homeassistant/components/tesla_fleet/models.py
index 469ebdca914..17a2bf50ed1 100644
--- a/homeassistant/components/tesla_fleet/models.py
+++ b/homeassistant/components/tesla_fleet/models.py
@@ -5,8 +5,8 @@ from __future__ import annotations
import asyncio
from dataclasses import dataclass
-from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import Scope
+from tesla_fleet_api.tesla import EnergySite, VehicleFleet
from homeassistant.helpers.device_registry import DeviceInfo
@@ -31,7 +31,7 @@ class TeslaFleetData:
class TeslaFleetVehicleData:
"""Data for a vehicle in the TeslaFleet integration."""
- api: VehicleSpecific
+ api: VehicleFleet
coordinator: TeslaFleetVehicleDataCoordinator
vin: str
device: DeviceInfo
@@ -43,7 +43,7 @@ class TeslaFleetVehicleData:
class TeslaFleetEnergyData:
"""Data for a vehicle in the TeslaFleet integration."""
- api: EnergySpecific
+ api: EnergySite
live_coordinator: TeslaFleetEnergySiteLiveCoordinator
history_coordinator: TeslaFleetEnergySiteHistoryCoordinator
info_coordinator: TeslaFleetEnergySiteInfoCoordinator
diff --git a/homeassistant/components/tesla_fleet/number.py b/homeassistant/components/tesla_fleet/number.py
index b806b4dbc77..b4f7e42cafd 100644
--- a/homeassistant/components/tesla_fleet/number.py
+++ b/homeassistant/components/tesla_fleet/number.py
@@ -7,8 +7,8 @@ from dataclasses import dataclass
from itertools import chain
from typing import Any
-from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import Scope
+from tesla_fleet_api.tesla import EnergySite, VehicleFleet
from homeassistant.components.number import (
NumberDeviceClass,
@@ -18,7 +18,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import PERCENTAGE, PRECISION_WHOLE, UnitOfElectricCurrent
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from . import TeslaFleetConfigEntry
@@ -33,7 +33,7 @@ PARALLEL_UPDATES = 0
class TeslaFleetNumberVehicleEntityDescription(NumberEntityDescription):
"""Describes TeslaFleet Number entity."""
- func: Callable[[VehicleSpecific, float], Awaitable[Any]]
+ func: Callable[[VehicleFleet, float], Awaitable[Any]]
native_min_value: float
native_max_value: float
min_key: str | None = None
@@ -74,7 +74,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetNumberVehicleEntityDescription, ...] = (
class TeslaFleetNumberBatteryEntityDescription(NumberEntityDescription):
"""Describes TeslaFleet Number entity."""
- func: Callable[[EnergySpecific, float], Awaitable[Any]]
+ func: Callable[[EnergySite, float], Awaitable[Any]]
requires: str | None = None
@@ -95,7 +95,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TeslaFleetNumberBatteryEntityDescription, ...] =
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslaFleetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the TeslaFleet number platform from a config entry."""
diff --git a/homeassistant/components/tesla_fleet/select.py b/homeassistant/components/tesla_fleet/select.py
index 515a0e7c2e7..1c495657bc1 100644
--- a/homeassistant/components/tesla_fleet/select.py
+++ b/homeassistant/components/tesla_fleet/select.py
@@ -10,7 +10,7 @@ from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope,
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TeslaFleetConfigEntry
from .entity import TeslaFleetEnergyInfoEntity, TeslaFleetVehicleEntity
@@ -78,7 +78,7 @@ SEAT_HEATER_DESCRIPTIONS: tuple[SeatHeaterDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslaFleetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the TeslaFleet select platform from a config entry."""
diff --git a/homeassistant/components/tesla_fleet/sensor.py b/homeassistant/components/tesla_fleet/sensor.py
index c1d38bf85c5..bdd5ce2c001 100644
--- a/homeassistant/components/tesla_fleet/sensor.py
+++ b/homeassistant/components/tesla_fleet/sensor.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from homeassistant.util.variance import ignore_variance
@@ -446,7 +446,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslaFleetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tesla Fleet sensor platform from a config entry."""
async_add_entities(
@@ -466,6 +466,7 @@ async def async_setup_entry(
for energysite in entry.runtime_data.energysites
for description in ENERGY_LIVE_DESCRIPTIONS
if description.key in energysite.live_coordinator.data
+ or description.key == "percentage_charged"
),
( # Add energy site history
TeslaFleetEnergyHistorySensorEntity(energysite, description)
diff --git a/homeassistant/components/tesla_fleet/strings.json b/homeassistant/components/tesla_fleet/strings.json
index 540ea2b7135..fcd2e07306f 100644
--- a/homeassistant/components/tesla_fleet/strings.json
+++ b/homeassistant/components/tesla_fleet/strings.json
@@ -141,7 +141,7 @@
"state_attributes": {
"preset_mode": {
"state": {
- "off": "Normal",
+ "off": "[%key:common::state::normal%]",
"keep": "Keep mode",
"dog": "Dog mode",
"camp": "Camp mode"
@@ -206,72 +206,72 @@
"climate_state_seat_heater_left": {
"name": "Seat heater front left",
"state": {
- "high": "High",
- "low": "Low",
- "medium": "Medium",
- "off": "Off"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_rear_center": {
"name": "Seat heater rear center",
"state": {
- "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_rear_left": {
"name": "Seat heater rear left",
"state": {
- "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_rear_right": {
"name": "Seat heater rear right",
"state": {
- "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_right": {
"name": "Seat heater front right",
"state": {
- "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_third_row_left": {
"name": "Seat heater third row left",
"state": {
- "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_third_row_right": {
"name": "Seat heater third row right",
"state": {
- "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_steering_wheel_heat_level": {
"name": "Steering wheel heater",
"state": {
- "high": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::low%]",
- "off": "[%key:component::tesla_fleet::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "off": "[%key:common::state::off%]"
}
},
"components_customer_preferred_export_rule": {
@@ -329,9 +329,9 @@
"name": "Charging",
"state": {
"starting": "Starting",
- "charging": "Charging",
- "disconnected": "Disconnected",
- "stopped": "Stopped",
+ "charging": "[%key:common::state::charging%]",
+ "disconnected": "[%key:common::state::disconnected%]",
+ "stopped": "[%key:common::state::stopped%]",
"complete": "Complete",
"no_power": "No power"
}
@@ -418,8 +418,8 @@
"name": "Grid Status",
"state": {
"island_status_unknown": "Unknown",
- "on_grid": "Connected",
- "off_grid": "Disconnected",
+ "on_grid": "[%key:common::state::connected%]",
+ "off_grid": "[%key:common::state::disconnected%]",
"off_grid_unintentional": "Disconnected unintentionally",
"off_grid_intentional": "Disconnected intentionally"
}
diff --git a/homeassistant/components/tesla_fleet/switch.py b/homeassistant/components/tesla_fleet/switch.py
index 054ea84cbe1..4c64acfafa6 100644
--- a/homeassistant/components/tesla_fleet/switch.py
+++ b/homeassistant/components/tesla_fleet/switch.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from itertools import chain
from typing import Any
-from tesla_fleet_api.const import Scope, Seat
+from tesla_fleet_api.const import AutoSeat, Scope, Seat
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -15,7 +15,7 @@ from homeassistant.components.switch import (
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import TeslaFleetConfigEntry
@@ -46,7 +46,9 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = (
),
TeslaFleetSwitchEntityDescription(
key="climate_state_auto_seat_climate_left",
- on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True),
+ on_func=lambda api: api.remote_auto_seat_climate_request(
+ AutoSeat.FRONT_LEFT, True
+ ),
off_func=lambda api: api.remote_auto_seat_climate_request(
Seat.FRONT_LEFT, False
),
@@ -55,10 +57,10 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = (
TeslaFleetSwitchEntityDescription(
key="climate_state_auto_seat_climate_right",
on_func=lambda api: api.remote_auto_seat_climate_request(
- Seat.FRONT_RIGHT, True
+ AutoSeat.FRONT_RIGHT, True
),
off_func=lambda api: api.remote_auto_seat_climate_request(
- Seat.FRONT_RIGHT, False
+ AutoSeat.FRONT_RIGHT, False
),
scopes=[Scope.VEHICLE_CMDS],
),
@@ -94,7 +96,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslaFleetSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslaFleetConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the TeslaFleet Switch platform from a config entry."""
diff --git a/homeassistant/components/tesla_wall_connector/binary_sensor.py b/homeassistant/components/tesla_wall_connector/binary_sensor.py
index f7ef385b8ed..6d60162412e 100644
--- a/homeassistant/components/tesla_wall_connector/binary_sensor.py
+++ b/homeassistant/components/tesla_wall_connector/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WallConnectorData
from .const import DOMAIN, WALLCONNECTOR_DATA_VITALS
@@ -48,7 +48,7 @@ WALL_CONNECTOR_SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the Wall Connector sensor devices."""
wall_connector_data = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/tesla_wall_connector/sensor.py b/homeassistant/components/tesla_wall_connector/sensor.py
index a50c81c912e..c6c63a93edb 100644
--- a/homeassistant/components/tesla_wall_connector/sensor.py
+++ b/homeassistant/components/tesla_wall_connector/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WallConnectorData
from .const import DOMAIN, WALLCONNECTOR_DATA_LIFETIME, WALLCONNECTOR_DATA_VITALS
@@ -187,7 +187,7 @@ WALL_CONNECTOR_SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the Wall Connector sensor devices."""
wall_connector_data = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/tesla_wall_connector/strings.json b/homeassistant/components/tesla_wall_connector/strings.json
index 1a03207a012..b356a9f3ebc 100644
--- a/homeassistant/components/tesla_wall_connector/strings.json
+++ b/homeassistant/components/tesla_wall_connector/strings.json
@@ -42,7 +42,7 @@
"charging_finished": "Charging finished",
"waiting_car": "Waiting for car",
"charging_reduced": "Charging (reduced)",
- "charging": "Charging"
+ "charging": "[%key:common::state::charging%]"
}
},
"status_code": {
diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py
index eef974cc5a7..b820d2d1b43 100644
--- a/homeassistant/components/teslemetry/__init__.py
+++ b/homeassistant/components/teslemetry/__init__.py
@@ -4,7 +4,6 @@ import asyncio
from collections.abc import Callable
from typing import Final
-from tesla_fleet_api import EnergySpecific, Teslemetry, VehicleSpecific
from tesla_fleet_api.const import Scope
from tesla_fleet_api.exceptions import (
Forbidden,
@@ -12,6 +11,7 @@ from tesla_fleet_api.exceptions import (
SubscriptionRequired,
TeslaFleetError,
)
+from tesla_fleet_api.teslemetry import Teslemetry
from teslemetry_stream import TeslemetryStream
from homeassistant.config_entries import ConfigEntry
@@ -111,7 +111,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
# Remove the protobuff 'cached_data' that we do not use to save memory
product.pop("cached_data", None)
vin = product["vin"]
- api = VehicleSpecific(teslemetry.vehicle, vin)
+ api = teslemetry.vehicles.create(vin)
coordinator = TeslemetryVehicleDataCoordinator(hass, entry, api, product)
device = DeviceInfo(
identifiers={(DOMAIN, vin)},
@@ -156,7 +156,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
)
continue
- api = EnergySpecific(teslemetry.energy, site_id)
+ api = teslemetry.energySites.create(site_id)
device = DeviceInfo(
identifiers={(DOMAIN, str(site_id))},
manufacturer="Tesla",
diff --git a/homeassistant/components/teslemetry/binary_sensor.py b/homeassistant/components/teslemetry/binary_sensor.py
index 0b6823f8b61..a5ea30e014d 100644
--- a/homeassistant/components/teslemetry/binary_sensor.py
+++ b/homeassistant/components/teslemetry/binary_sensor.py
@@ -6,8 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from typing import cast
-from teslemetry_stream import Signal
-from teslemetry_stream.const import WindowState
+from teslemetry_stream.vehicle import TeslemetryStreamVehicle
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -16,7 +15,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import StateType
@@ -32,6 +31,12 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData
PARALLEL_UPDATES = 0
+WINDOW_STATES = {
+ "Opened": True,
+ "PartiallyOpen": True,
+ "Closed": False,
+}
+
@dataclass(frozen=True, kw_only=True)
class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription):
@@ -39,11 +44,14 @@ class TeslemetryBinarySensorEntityDescription(BinarySensorEntityDescription):
polling_value_fn: Callable[[StateType], bool | None] = bool
polling: bool = False
- streaming_key: Signal | None = None
+ streaming_listener: (
+ Callable[
+ [TeslemetryStreamVehicle, Callable[[bool | None], None]],
+ Callable[[], None],
+ ]
+ | None
+ ) = None
streaming_firmware: str = "2024.26"
- streaming_value_fn: Callable[[StateType], bool | None] = (
- lambda x: x is True or x == "true"
- )
VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
@@ -56,7 +64,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
TeslemetryBinarySensorEntityDescription(
key="charge_state_battery_heater_on",
polling=True,
- streaming_key=Signal.BATTERY_HEATER_ON,
+ streaming_listener=lambda x, y: x.listen_BatteryHeaterOn(y),
device_class=BinarySensorDeviceClass.HEAT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -64,15 +72,16 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
TeslemetryBinarySensorEntityDescription(
key="charge_state_charger_phases",
polling=True,
- streaming_key=Signal.CHARGER_PHASES,
+ streaming_listener=lambda x, y: x.listen_ChargerPhases(
+ lambda z: y(None if z is None else z > 1)
+ ),
polling_value_fn=lambda x: cast(int, x) > 1,
- streaming_value_fn=lambda x: cast(int, x) > 1,
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="charge_state_preconditioning_enabled",
polling=True,
- streaming_key=Signal.PRECONDITIONING_ENABLED,
+ streaming_listener=lambda x, y: x.listen_PreconditioningEnabled(y),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@@ -85,7 +94,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
TeslemetryBinarySensorEntityDescription(
key="charge_state_scheduled_charging_pending",
polling=True,
- streaming_key=Signal.SCHEDULED_CHARGING_PENDING,
+ streaming_listener=lambda x, y: x.listen_ScheduledChargingPending(y),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@@ -153,32 +162,36 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_fd_window",
polling=True,
- streaming_key=Signal.FD_WINDOW,
- streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
+ streaming_listener=lambda x, y: x.listen_FrontDriverWindow(
+ lambda z: y(WINDOW_STATES.get(z))
+ ),
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_fp_window",
polling=True,
- streaming_key=Signal.FP_WINDOW,
- streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
+ streaming_listener=lambda x, y: x.listen_FrontPassengerWindow(
+ lambda z: y(WINDOW_STATES.get(z))
+ ),
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_rd_window",
polling=True,
- streaming_key=Signal.RD_WINDOW,
- streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
+ streaming_listener=lambda x, y: x.listen_RearDriverWindow(
+ lambda z: y(WINDOW_STATES.get(z))
+ ),
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_rp_window",
polling=True,
- streaming_key=Signal.RP_WINDOW,
- streaming_value_fn=lambda x: WindowState.get(x) != "Closed",
+ streaming_listener=lambda x, y: x.listen_RearPassengerWindow(
+ lambda z: y(WINDOW_STATES.get(z))
+ ),
device_class=BinarySensorDeviceClass.WINDOW,
entity_category=EntityCategory.DIAGNOSTIC,
),
@@ -186,180 +199,177 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryBinarySensorEntityDescription, ...] = (
key="vehicle_state_df",
polling=True,
device_class=BinarySensorDeviceClass.DOOR,
- streaming_key=Signal.DOOR_STATE,
- streaming_value_fn=lambda x: cast(dict, x).get("DriverFront"),
+ streaming_listener=lambda x, y: x.listen_FrontDriverDoor(y),
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_dr",
polling=True,
device_class=BinarySensorDeviceClass.DOOR,
- streaming_key=Signal.DOOR_STATE,
- streaming_value_fn=lambda x: cast(dict, x).get("DriverRear"),
+ streaming_listener=lambda x, y: x.listen_RearDriverDoor(y),
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_pf",
polling=True,
device_class=BinarySensorDeviceClass.DOOR,
- streaming_key=Signal.DOOR_STATE,
- streaming_value_fn=lambda x: cast(dict, x).get("PassengerFront"),
+ streaming_listener=lambda x, y: x.listen_FrontPassengerDoor(y),
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="vehicle_state_pr",
polling=True,
device_class=BinarySensorDeviceClass.DOOR,
- streaming_key=Signal.DOOR_STATE,
- streaming_value_fn=lambda x: cast(dict, x).get("PassengerRear"),
+ streaming_listener=lambda x, y: x.listen_RearPassengerDoor(y),
entity_category=EntityCategory.DIAGNOSTIC,
),
TeslemetryBinarySensorEntityDescription(
key="automatic_blind_spot_camera",
- streaming_key=Signal.AUTOMATIC_BLIND_SPOT_CAMERA,
+ streaming_listener=lambda x, y: x.listen_AutomaticBlindSpotCamera(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="automatic_emergency_braking_off",
- streaming_key=Signal.AUTOMATIC_EMERGENCY_BRAKING_OFF,
+ streaming_listener=lambda x, y: x.listen_AutomaticEmergencyBrakingOff(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="blind_spot_collision_warning_chime",
- streaming_key=Signal.BLIND_SPOT_COLLISION_WARNING_CHIME,
+ streaming_listener=lambda x, y: x.listen_BlindSpotCollisionWarningChime(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="bms_full_charge_complete",
- streaming_key=Signal.BMS_FULL_CHARGE_COMPLETE,
+ streaming_listener=lambda x, y: x.listen_BmsFullchargecomplete(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="brake_pedal",
- streaming_key=Signal.BRAKE_PEDAL,
+ streaming_listener=lambda x, y: x.listen_BrakePedal(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="charge_port_cold_weather_mode",
- streaming_key=Signal.CHARGE_PORT_COLD_WEATHER_MODE,
+ streaming_listener=lambda x, y: x.listen_ChargePortColdWeatherMode(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="service_mode",
- streaming_key=Signal.SERVICE_MODE,
+ streaming_listener=lambda x, y: x.listen_ServiceMode(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="pin_to_drive_enabled",
- streaming_key=Signal.PIN_TO_DRIVE_ENABLED,
+ streaming_listener=lambda x, y: x.listen_PinToDriveEnabled(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="drive_rail",
- streaming_key=Signal.DRIVE_RAIL,
+ streaming_listener=lambda x, y: x.listen_DriveRail(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="driver_seat_belt",
- streaming_key=Signal.DRIVER_SEAT_BELT,
+ streaming_listener=lambda x, y: x.listen_DriverSeatBelt(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="driver_seat_occupied",
- streaming_key=Signal.DRIVER_SEAT_OCCUPIED,
+ streaming_listener=lambda x, y: x.listen_DriverSeatOccupied(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="passenger_seat_belt",
- streaming_key=Signal.PASSENGER_SEAT_BELT,
+ streaming_listener=lambda x, y: x.listen_PassengerSeatBelt(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="fast_charger_present",
- streaming_key=Signal.FAST_CHARGER_PRESENT,
+ streaming_listener=lambda x, y: x.listen_FastChargerPresent(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="gps_state",
- streaming_key=Signal.GPS_STATE,
+ streaming_listener=lambda x, y: x.listen_GpsState(y),
entity_registry_enabled_default=False,
entity_category=EntityCategory.DIAGNOSTIC,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
TeslemetryBinarySensorEntityDescription(
key="guest_mode_enabled",
- streaming_key=Signal.GUEST_MODE_ENABLED,
+ streaming_listener=lambda x, y: x.listen_GuestModeEnabled(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="dc_dc_enable",
- streaming_key=Signal.DC_DC_ENABLE,
+ streaming_listener=lambda x, y: x.listen_DCDCEnable(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="emergency_lane_departure_avoidance",
- streaming_key=Signal.EMERGENCY_LANE_DEPARTURE_AVOIDANCE,
+ streaming_listener=lambda x, y: x.listen_EmergencyLaneDepartureAvoidance(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="supercharger_session_trip_planner",
- streaming_key=Signal.SUPERCHARGER_SESSION_TRIP_PLANNER,
+ streaming_listener=lambda x, y: x.listen_SuperchargerSessionTripPlanner(y),
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="wiper_heat_enabled",
- streaming_key=Signal.WIPER_HEAT_ENABLED,
+ streaming_listener=lambda x, y: x.listen_WiperHeatEnabled(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="rear_display_hvac_enabled",
- streaming_key=Signal.REAR_DISPLAY_HVAC_ENABLED,
+ streaming_listener=lambda x, y: x.listen_RearDisplayHvacEnabled(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="offroad_lightbar_present",
- streaming_key=Signal.OFFROAD_LIGHTBAR_PRESENT,
+ streaming_listener=lambda x, y: x.listen_OffroadLightbarPresent(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="homelink_nearby",
- streaming_key=Signal.HOMELINK_NEARBY,
+ streaming_listener=lambda x, y: x.listen_HomelinkNearby(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="europe_vehicle",
- streaming_key=Signal.EUROPE_VEHICLE,
+ streaming_listener=lambda x, y: x.listen_EuropeVehicle(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="right_hand_drive",
- streaming_key=Signal.RIGHT_HAND_DRIVE,
+ streaming_listener=lambda x, y: x.listen_RightHandDrive(y),
streaming_firmware="2024.44.25",
entity_registry_enabled_default=False,
),
TeslemetryBinarySensorEntityDescription(
key="located_at_home",
- streaming_key=Signal.LOCATED_AT_HOME,
+ streaming_listener=lambda x, y: x.listen_LocatedAtHome(y),
streaming_firmware="2024.44.32",
),
TeslemetryBinarySensorEntityDescription(
key="located_at_work",
- streaming_key=Signal.LOCATED_AT_WORK,
+ streaming_listener=lambda x, y: x.listen_LocatedAtWork(y),
streaming_firmware="2024.44.32",
),
TeslemetryBinarySensorEntityDescription(
key="located_at_favorite",
- streaming_key=Signal.LOCATED_AT_FAVORITE,
+ streaming_listener=lambda x, y: x.listen_LocatedAtFavorite(y),
streaming_firmware="2024.44.32",
entity_registry_enabled_default=False,
),
)
+
ENERGY_LIVE_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(key="backup_capable"),
BinarySensorEntityDescription(key="grid_services_active"),
@@ -377,7 +387,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry binary sensor platform from a config entry."""
@@ -386,7 +396,7 @@ async def async_setup_entry(
for description in VEHICLE_DESCRIPTIONS:
if (
not vehicle.api.pre2021
- and description.streaming_key
+ and description.streaming_listener
and vehicle.firmware >= description.streaming_firmware
):
entities.append(
@@ -453,8 +463,7 @@ class TeslemetryVehicleStreamingBinarySensorEntity(
) -> None:
"""Initialize the sensor."""
self.entity_description = description
- assert description.streaming_key
- super().__init__(data, description.key, description.streaming_key)
+ super().__init__(data, description.key)
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
@@ -462,11 +471,18 @@ class TeslemetryVehicleStreamingBinarySensorEntity(
if (state := await self.async_get_last_state()) is not None:
self._attr_is_on = state.state == STATE_ON
- def _async_value_from_stream(self, value) -> None:
+ assert self.entity_description.streaming_listener
+ self.async_on_remove(
+ self.entity_description.streaming_listener(
+ self.vehicle.stream_vehicle, self._async_value_from_stream
+ )
+ )
+
+ def _async_value_from_stream(self, value: bool | None) -> None:
"""Update the value of the entity."""
self._attr_available = value is not None
- if self._attr_available:
- self._attr_is_on = self.entity_description.streaming_value_fn(value)
+ self._attr_is_on = value
+ self.async_write_ha_state()
class TeslemetryEnergyLiveBinarySensorEntity(
diff --git a/homeassistant/components/teslemetry/button.py b/homeassistant/components/teslemetry/button.py
index ceeda265795..4ca2fd9b166 100644
--- a/homeassistant/components/teslemetry/button.py
+++ b/homeassistant/components/teslemetry/button.py
@@ -10,7 +10,7 @@ from tesla_fleet_api.const import Scope
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TeslemetryConfigEntry
from .entity import TeslemetryVehicleEntity
@@ -61,7 +61,7 @@ DESCRIPTIONS: tuple[TeslemetryButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry Button platform from a config entry."""
diff --git a/homeassistant/components/teslemetry/climate.py b/homeassistant/components/teslemetry/climate.py
index 95b769a1c2d..c1c8fcd2f73 100644
--- a/homeassistant/components/teslemetry/climate.py
+++ b/homeassistant/components/teslemetry/climate.py
@@ -6,9 +6,11 @@ from itertools import chain
from typing import Any, cast
from tesla_fleet_api.const import CabinOverheatProtectionTemp, Scope
+from tesla_fleet_api.teslemetry import Vehicle
from homeassistant.components.climate import (
ATTR_HVAC_MODE,
+ HVAC_MODES,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
@@ -21,16 +23,33 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
from . import TeslemetryConfigEntry
from .const import DOMAIN, TeslemetryClimateSide
-from .entity import TeslemetryVehicleEntity
+from .entity import (
+ TeslemetryRootEntity,
+ TeslemetryVehicleEntity,
+ TeslemetryVehicleStreamEntity,
+)
from .helpers import handle_vehicle_command
from .models import TeslemetryVehicleData
DEFAULT_MIN_TEMP = 15
DEFAULT_MAX_TEMP = 28
+COP_TEMPERATURES = {
+ 30: CabinOverheatProtectionTemp.LOW,
+ 35: CabinOverheatProtectionTemp.MEDIUM,
+ 40: CabinOverheatProtectionTemp.HIGH,
+}
+PRESET_MODES = {
+ "Off": "off",
+ "On": "keep",
+ "Dog": "dog",
+ "Party": "camp",
+}
+
PARALLEL_UPDATES = 0
@@ -38,20 +57,28 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry Climate platform from a config entry."""
async_add_entities(
chain(
(
- TeslemetryClimateEntity(
+ TeslemetryPollingClimateEntity(
+ vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes
+ )
+ if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25"
+ else TeslemetryStreamingClimateEntity(
vehicle, TeslemetryClimateSide.DRIVER, entry.runtime_data.scopes
)
for vehicle in entry.runtime_data.vehicles
),
(
- TeslemetryCabinOverheatProtectionEntity(
+ TeslemetryPollingCabinOverheatProtectionEntity(
+ vehicle, entry.runtime_data.scopes
+ )
+ if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25"
+ else TeslemetryStreamingCabinOverheatProtectionEntity(
vehicle, entry.runtime_data.scopes
)
for vehicle in entry.runtime_data.vehicles
@@ -60,66 +87,22 @@ async def async_setup_entry(
)
-class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
- """Telemetry vehicle climate entity."""
+class TeslemetryClimateEntity(TeslemetryRootEntity, ClimateEntity):
+ """Vehicle Climate Control."""
+
+ api: Vehicle
_attr_precision = PRECISION_HALVES
-
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF]
- _attr_supported_features = (
- ClimateEntityFeature.TURN_ON
- | ClimateEntityFeature.TURN_OFF
- | ClimateEntityFeature.TARGET_TEMPERATURE
- | ClimateEntityFeature.PRESET_MODE
- )
- _attr_preset_modes = ["off", "keep", "dog", "camp"]
-
- def __init__(
- self,
- data: TeslemetryVehicleData,
- side: TeslemetryClimateSide,
- scopes: Scope,
- ) -> None:
- """Initialize the climate."""
- self.scoped = Scope.VEHICLE_CMDS in scopes
-
- if not self.scoped:
- self._attr_supported_features = ClimateEntityFeature(0)
- self._attr_hvac_modes = []
-
- super().__init__(
- data,
- side,
- )
-
- def _async_update_attrs(self) -> None:
- """Update the attributes of the entity."""
- value = self.get("climate_state_is_climate_on")
- if value:
- self._attr_hvac_mode = HVACMode.HEAT_COOL
- else:
- self._attr_hvac_mode = HVACMode.OFF
-
- # If not scoped, prevent the user from changing the HVAC mode by making it the only option
- if self._attr_hvac_mode and not self.scoped:
- self._attr_hvac_modes = [self._attr_hvac_mode]
-
- self._attr_current_temperature = self.get("climate_state_inside_temp")
- self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting")
- self._attr_preset_mode = self.get("climate_state_climate_keeper_mode")
- self._attr_min_temp = cast(
- float, self.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP)
- )
- self._attr_max_temp = cast(
- float, self.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP)
- )
+ _attr_preset_modes = list(PRESET_MODES.values())
+ _attr_fan_modes = ["off", "bioweapon"]
+ _enable_turn_on_off_backwards_compatibility = False
async def async_turn_on(self) -> None:
"""Set the climate state to on."""
-
self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
+
await handle_vehicle_command(self.api.auto_conditioning_start())
self._attr_hvac_mode = HVACMode.HEAT_COOL
@@ -127,19 +110,21 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
async def async_turn_off(self) -> None:
"""Set the climate state to off."""
-
self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
+
await handle_vehicle_command(self.api.auto_conditioning_stop())
self._attr_hvac_mode = HVACMode.OFF
self._attr_preset_mode = self._attr_preset_modes[0]
+ self._attr_fan_mode = self._attr_fan_modes[0]
self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the climate temperature."""
+
if temp := kwargs.get(ATTR_TEMPERATURE):
- await self.wake_up_if_asleep()
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
+
await handle_vehicle_command(
self.api.set_temps(
driver_temp=temp,
@@ -163,18 +148,210 @@ class TeslemetryClimateEntity(TeslemetryVehicleEntity, ClimateEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the climate preset mode."""
- await self.wake_up_if_asleep()
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
+
await handle_vehicle_command(
self.api.set_climate_keeper_mode(
climate_keeper_mode=self._attr_preset_modes.index(preset_mode)
)
)
self._attr_preset_mode = preset_mode
- if preset_mode != self._attr_preset_modes[0]:
- # Changing preset mode will also turn on climate
+ if preset_mode == self._attr_preset_modes[0]:
+ self._attr_hvac_mode = HVACMode.OFF
+ else:
self._attr_hvac_mode = HVACMode.HEAT_COOL
self.async_write_ha_state()
+ async def async_set_fan_mode(self, fan_mode: str) -> None:
+ """Set the Bioweapon defense mode."""
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
+
+ await handle_vehicle_command(
+ self.api.set_bioweapon_mode(
+ on=(fan_mode != "off"),
+ manual_override=True,
+ )
+ )
+ self._attr_fan_mode = fan_mode
+ if fan_mode == self._attr_fan_modes[1]:
+ self._attr_hvac_mode = HVACMode.HEAT_COOL
+ self.async_write_ha_state()
+
+
+class TeslemetryPollingClimateEntity(TeslemetryClimateEntity, TeslemetryVehicleEntity):
+ """Polling vehicle climate entity."""
+
+ _attr_supported_features = (
+ ClimateEntityFeature.TURN_ON
+ | ClimateEntityFeature.TURN_OFF
+ | ClimateEntityFeature.TARGET_TEMPERATURE
+ | ClimateEntityFeature.PRESET_MODE
+ | ClimateEntityFeature.FAN_MODE
+ )
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ side: TeslemetryClimateSide,
+ scopes: list[Scope],
+ ) -> None:
+ """Initialize the climate."""
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ if not self.scoped:
+ self._attr_supported_features = ClimateEntityFeature(0)
+
+ super().__init__(data, side)
+
+ def _async_update_attrs(self) -> None:
+ """Update the attributes of the entity."""
+ value = self.get("climate_state_is_climate_on")
+ if value is None:
+ self._attr_hvac_mode = None
+ if value:
+ self._attr_hvac_mode = HVACMode.HEAT_COOL
+ else:
+ self._attr_hvac_mode = HVACMode.OFF
+
+ self._attr_current_temperature = self.get("climate_state_inside_temp")
+ self._attr_target_temperature = self.get(f"climate_state_{self.key}_setting")
+ self._attr_preset_mode = self.get("climate_state_climate_keeper_mode")
+ if self.get("climate_state_bioweapon_mode"):
+ self._attr_fan_mode = "bioweapon"
+ else:
+ self._attr_fan_mode = "off"
+ self._attr_min_temp = cast(
+ float, self.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP)
+ )
+ self._attr_max_temp = cast(
+ float, self.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP)
+ )
+
+
+class TeslemetryStreamingClimateEntity(
+ TeslemetryClimateEntity, TeslemetryVehicleStreamEntity, RestoreEntity
+):
+ """Teslemetry steering wheel climate control."""
+
+ _attr_supported_features = (
+ ClimateEntityFeature.TURN_ON
+ | ClimateEntityFeature.TURN_OFF
+ | ClimateEntityFeature.TARGET_TEMPERATURE
+ | ClimateEntityFeature.PRESET_MODE
+ )
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ side: TeslemetryClimateSide,
+ scopes: list[Scope],
+ ) -> None:
+ """Initialize the climate."""
+
+ # Initialize defaults
+ self._attr_hvac_mode = None
+ self._attr_current_temperature = None
+ self._attr_target_temperature = None
+ self._attr_fan_mode = None
+ self._attr_preset_mode = None
+
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ if not self.scoped:
+ self._attr_supported_features = ClimateEntityFeature(0)
+ self.side = side
+ super().__init__(
+ data,
+ side,
+ )
+
+ self._attr_min_temp = cast(
+ float,
+ data.coordinator.data.get("climate_state_min_avail_temp", DEFAULT_MIN_TEMP),
+ )
+ self._attr_max_temp = cast(
+ float,
+ data.coordinator.data.get("climate_state_max_avail_temp", DEFAULT_MAX_TEMP),
+ )
+ self.rhd: bool = data.coordinator.data.get("vehicle_config_rhd", False)
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ if (state := await self.async_get_last_state()) is not None:
+ self._attr_hvac_mode = (
+ HVACMode(state.state) if state.state in HVAC_MODES else None
+ )
+ self._attr_current_temperature = state.attributes.get("current_temperature")
+ self._attr_target_temperature = state.attributes.get("temperature")
+ self._attr_preset_mode = state.attributes.get("preset_mode")
+
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_InsideTemp(
+ self._async_handle_inside_temp
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_HvacACEnabled(
+ self._async_handle_hvac_ac_enabled
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_ClimateKeeperMode(
+ self._async_handle_climate_keeper_mode
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_RightHandDrive(self._async_handle_rhd)
+ )
+
+ if self.side == TeslemetryClimateSide.DRIVER:
+ if self.rhd:
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_HvacRightTemperatureRequest(
+ self._async_handle_hvac_temperature_request
+ )
+ )
+ else:
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_HvacLeftTemperatureRequest(
+ self._async_handle_hvac_temperature_request
+ )
+ )
+ elif self.side == TeslemetryClimateSide.PASSENGER:
+ if self.rhd:
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_HvacLeftTemperatureRequest(
+ self._async_handle_hvac_temperature_request
+ )
+ )
+ else:
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_HvacRightTemperatureRequest(
+ self._async_handle_hvac_temperature_request
+ )
+ )
+
+ def _async_handle_inside_temp(self, data: float | None):
+ self._attr_current_temperature = data
+ self.async_write_ha_state()
+
+ def _async_handle_hvac_ac_enabled(self, data: bool | None):
+ self._attr_hvac_mode = (
+ None if data is None else HVACMode.HEAT_COOL if data else HVACMode.OFF
+ )
+ self.async_write_ha_state()
+
+ def _async_handle_climate_keeper_mode(self, data: str | None):
+ self._attr_preset_mode = PRESET_MODES.get(data) if data else None
+ self.async_write_ha_state()
+
+ def _async_handle_hvac_temperature_request(self, data: float | None):
+ self._attr_target_temperature = data
+ self.async_write_ha_state()
+
+ def _async_handle_rhd(self, data: bool | None):
+ if data is not None:
+ self.rhd = data
+
COP_MODES = {
"Off": HVACMode.OFF,
@@ -182,73 +359,27 @@ COP_MODES = {
"FanOnly": HVACMode.FAN_ONLY,
}
-# String to celsius
COP_LEVELS = {
"Low": 30,
"Medium": 35,
"High": 40,
}
-# Celsius to IntEnum
-TEMP_LEVELS = {
- 30: CabinOverheatProtectionTemp.LOW,
- 35: CabinOverheatProtectionTemp.MEDIUM,
- 40: CabinOverheatProtectionTemp.HIGH,
-}
+class TeslemetryCabinOverheatProtectionEntity(TeslemetryRootEntity, ClimateEntity):
+ """Vehicle Cabin Overheat Protection."""
-class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEntity):
- """Telemetry vehicle cabin overheat protection entity."""
+ api: Vehicle
_attr_precision = PRECISION_WHOLE
_attr_target_temperature_step = 5
- _attr_min_temp = COP_LEVELS["Low"]
- _attr_max_temp = COP_LEVELS["High"]
+ _attr_min_temp = 30
+ _attr_max_temp = 40
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_hvac_modes = list(COP_MODES.values())
-
_attr_entity_registry_enabled_default = False
- def __init__(
- self,
- data: TeslemetryVehicleData,
- scopes: Scope,
- ) -> None:
- """Initialize the climate."""
-
- self.scoped = Scope.VEHICLE_CMDS in scopes
- if self.scoped:
- self._attr_supported_features = (
- ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
- )
- else:
- self._attr_supported_features = ClimateEntityFeature(0)
- self._attr_hvac_modes = []
-
- super().__init__(data, "climate_state_cabin_overheat_protection")
-
- # Supported Features from data
- if self.scoped and self.get("vehicle_config_cop_user_set_temp_supported"):
- self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
-
- def _async_update_attrs(self) -> None:
- """Update the attributes of the entity."""
-
- if (state := self.get("climate_state_cabin_overheat_protection")) is None:
- self._attr_hvac_mode = None
- else:
- self._attr_hvac_mode = COP_MODES.get(state)
-
- # If not scoped, prevent the user from changing the HVAC mode by making it the only option
- if self._attr_hvac_mode and not self.scoped:
- self._attr_hvac_modes = [self._attr_hvac_mode]
-
- if (level := self.get("climate_state_cop_activation_temperature")) is None:
- self._attr_target_temperature = None
- else:
- self._attr_target_temperature = COP_LEVELS.get(level)
-
- self._attr_current_temperature = self.get("climate_state_inside_temp")
+ _enable_turn_on_off_backwards_compatibility = False
async def async_turn_on(self) -> None:
"""Set the climate state to on."""
@@ -260,26 +391,28 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the climate temperature."""
- self.raise_for_scope(Scope.VEHICLE_CMDS)
- if (temp := kwargs.get(ATTR_TEMPERATURE)) is None or (
- cop_mode := TEMP_LEVELS.get(temp)
- ) is None:
- raise ServiceValidationError(
- translation_domain=DOMAIN,
- translation_key="invalid_cop_temp",
- )
+ if temp := kwargs.get(ATTR_TEMPERATURE):
+ if (cop_mode := COP_TEMPERATURES.get(temp)) is None:
+ raise ServiceValidationError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_cop_temp",
+ )
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
- await handle_vehicle_command(self.api.set_cop_temp(cop_mode))
- self._attr_target_temperature = temp
+ await handle_vehicle_command(self.api.set_cop_temp(cop_mode))
+ self._attr_target_temperature = temp
if mode := kwargs.get(ATTR_HVAC_MODE):
- await self._async_set_cop(mode)
+ # Set HVAC mode will call write_ha_state
+ await self.async_set_hvac_mode(mode)
+ else:
+ self.async_write_ha_state()
- self.async_write_ha_state()
+ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
+ """Set the climate mode and state."""
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
- async def _async_set_cop(self, hvac_mode: HVACMode) -> None:
if hvac_mode == HVACMode.OFF:
await handle_vehicle_command(
self.api.set_cabin_overheat_protection(on=False, fan_only=False)
@@ -294,10 +427,125 @@ class TeslemetryCabinOverheatProtectionEntity(TeslemetryVehicleEntity, ClimateEn
)
self._attr_hvac_mode = hvac_mode
-
- async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
- """Set the climate mode and state."""
- self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
- await self._async_set_cop(hvac_mode)
+ self.async_write_ha_state()
+
+
+class TeslemetryPollingCabinOverheatProtectionEntity(
+ TeslemetryVehicleEntity, TeslemetryCabinOverheatProtectionEntity
+):
+ """Vehicle Cabin Overheat Protection."""
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ scopes: list[Scope],
+ ) -> None:
+ """Initialize the climate."""
+
+ super().__init__(
+ data,
+ "climate_state_cabin_overheat_protection",
+ )
+
+ # Supported Features
+ self._attr_supported_features = (
+ ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
+ )
+ if self.get("vehicle_config_cop_user_set_temp_supported"):
+ self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
+
+ # Scopes
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ if not self.scoped:
+ self._attr_supported_features = ClimateEntityFeature(0)
+
+ def _async_update_attrs(self) -> None:
+ """Update the attributes of the entity."""
+
+ if (state := self.get("climate_state_cabin_overheat_protection")) is None:
+ self._attr_hvac_mode = None
+ else:
+ self._attr_hvac_mode = COP_MODES.get(state)
+
+ if (level := self.get("climate_state_cop_activation_temperature")) is None:
+ self._attr_target_temperature = None
+ else:
+ self._attr_target_temperature = COP_LEVELS.get(level)
+
+ self._attr_current_temperature = self.get("climate_state_inside_temp")
+
+
+class TeslemetryStreamingCabinOverheatProtectionEntity(
+ TeslemetryVehicleStreamEntity,
+ TeslemetryCabinOverheatProtectionEntity,
+ RestoreEntity,
+):
+ """Vehicle Cabin Overheat Protection."""
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ scopes: list[Scope],
+ ) -> None:
+ """Initialize the climate."""
+
+ # Initialize defaults
+ self._attr_hvac_mode = None
+ self._attr_current_temperature = None
+ self._attr_target_temperature = None
+ self._attr_fan_mode = None
+ self._attr_preset_mode = None
+
+ super().__init__(data, "climate_state_cabin_overheat_protection")
+
+ # Supported Features
+ self._attr_supported_features = (
+ ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF
+ )
+ if data.coordinator.data.get("vehicle_config_cop_user_set_temp_supported"):
+ self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
+
+ # Scopes
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ if not self.scoped:
+ self._attr_supported_features = ClimateEntityFeature(0)
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ if (state := await self.async_get_last_state()) is not None:
+ self._attr_hvac_mode = (
+ HVACMode(state.state) if state.state in HVAC_MODES else None
+ )
+ self._attr_current_temperature = state.attributes.get("temperature")
+ self._attr_target_temperature = state.attributes.get("target_temperature")
+
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_InsideTemp(
+ self._async_handle_inside_temp
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_CabinOverheatProtectionMode(
+ self._async_handle_protection_mode
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_CabinOverheatProtectionTemperatureLimit(
+ self._async_handle_temperature_limit
+ )
+ )
+
+ def _async_handle_inside_temp(self, value: float | None):
+ self._attr_current_temperature = value
+ self.async_write_ha_state()
+
+ def _async_handle_protection_mode(self, value: str | None):
+ self._attr_hvac_mode = COP_MODES.get(value) if value is not None else None
+ self.async_write_ha_state()
+
+ def _async_handle_temperature_limit(self, value: str | None):
+ self._attr_target_temperature = (
+ COP_LEVELS.get(value) if value is not None else None
+ )
self.async_write_ha_state()
diff --git a/homeassistant/components/teslemetry/config_flow.py b/homeassistant/components/teslemetry/config_flow.py
index d8cf2bd7945..a25a98d6c68 100644
--- a/homeassistant/components/teslemetry/config_flow.py
+++ b/homeassistant/components/teslemetry/config_flow.py
@@ -6,12 +6,12 @@ from collections.abc import Mapping
from typing import Any
from aiohttp import ClientConnectionError
-from tesla_fleet_api import Teslemetry
from tesla_fleet_api.exceptions import (
InvalidToken,
SubscriptionRequired,
TeslaFleetError,
)
+from tesla_fleet_api.teslemetry import Teslemetry
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
diff --git a/homeassistant/components/teslemetry/coordinator.py b/homeassistant/components/teslemetry/coordinator.py
index 0cd2a5a62d6..07549008a6c 100644
--- a/homeassistant/components/teslemetry/coordinator.py
+++ b/homeassistant/components/teslemetry/coordinator.py
@@ -5,13 +5,13 @@ from __future__ import annotations
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any
-from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import TeslaEnergyPeriod, VehicleDataEndpoint
from tesla_fleet_api.exceptions import (
InvalidToken,
SubscriptionRequired,
TeslaFleetError,
)
+from tesla_fleet_api.teslemetry import EnergySite, Vehicle
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -49,7 +49,7 @@ class TeslemetryVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self,
hass: HomeAssistant,
config_entry: TeslemetryConfigEntry,
- api: VehicleSpecific,
+ api: Vehicle,
product: dict,
) -> None:
"""Initialize Teslemetry Vehicle Update Coordinator."""
@@ -87,7 +87,7 @@ class TeslemetryEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
self,
hass: HomeAssistant,
config_entry: TeslemetryConfigEntry,
- api: EnergySpecific,
+ api: EnergySite,
data: dict,
) -> None:
"""Initialize Teslemetry Energy Site Live coordinator."""
@@ -133,7 +133,7 @@ class TeslemetryEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
self,
hass: HomeAssistant,
config_entry: TeslemetryConfigEntry,
- api: EnergySpecific,
+ api: EnergySite,
product: dict,
) -> None:
"""Initialize Teslemetry Energy Info coordinator."""
@@ -169,7 +169,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self,
hass: HomeAssistant,
config_entry: TeslemetryConfigEntry,
- api: EnergySpecific,
+ api: EnergySite,
) -> None:
"""Initialize Teslemetry Energy Info coordinator."""
super().__init__(
@@ -192,7 +192,7 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
raise UpdateFailed(e.message) from e
# Add all time periods together
- output = {key: 0 for key in ENERGY_HISTORY_FIELDS}
+ output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0)
for period in data.get("time_series", []):
for key in ENERGY_HISTORY_FIELDS:
output[key] += period.get(key, 0)
diff --git a/homeassistant/components/teslemetry/cover.py b/homeassistant/components/teslemetry/cover.py
index 4cc15b6feb8..de91f43f084 100644
--- a/homeassistant/components/teslemetry/cover.py
+++ b/homeassistant/components/teslemetry/cover.py
@@ -15,7 +15,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from . import TeslemetryConfigEntry
@@ -36,7 +36,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry cover platform from a config entry."""
diff --git a/homeassistant/components/teslemetry/device_tracker.py b/homeassistant/components/teslemetry/device_tracker.py
index 42c8fea8d09..6a758e68497 100644
--- a/homeassistant/components/teslemetry/device_tracker.py
+++ b/homeassistant/components/teslemetry/device_tracker.py
@@ -14,7 +14,7 @@ from homeassistant.components.device_tracker.config_entry import (
)
from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from . import TeslemetryConfigEntry
@@ -68,7 +68,7 @@ DESCRIPTIONS: tuple[TeslemetryDeviceTrackerEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry device tracker platform from a config entry."""
diff --git a/homeassistant/components/teslemetry/diagnostics.py b/homeassistant/components/teslemetry/diagnostics.py
index fc601a58ae6..755935951fc 100644
--- a/homeassistant/components/teslemetry/diagnostics.py
+++ b/homeassistant/components/teslemetry/diagnostics.py
@@ -35,7 +35,9 @@ async def async_get_config_entry_diagnostics(
vehicles = [
{
"data": async_redact_data(x.coordinator.data, VEHICLE_REDACT),
- # Stream diag will go here when implemented
+ "stream": {
+ "config": x.stream_vehicle.config,
+ },
}
for x in entry.runtime_data.vehicles
]
@@ -45,6 +47,7 @@ async def async_get_config_entry_diagnostics(
if x.live_coordinator
else None,
"info": async_redact_data(x.info_coordinator.data, ENERGY_INFO_REDACT),
+ "history": x.history_coordinator.data if x.history_coordinator else None,
}
for x in entry.runtime_data.energysites
]
diff --git a/homeassistant/components/teslemetry/entity.py b/homeassistant/components/teslemetry/entity.py
index 82d3db123c3..3d145d24b0c 100644
--- a/homeassistant/components/teslemetry/entity.py
+++ b/homeassistant/components/teslemetry/entity.py
@@ -4,8 +4,8 @@ from abc import abstractmethod
from typing import Any
from propcache.api import cached_property
-from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import Scope
+from tesla_fleet_api.teslemetry import EnergySite, Vehicle
from teslemetry_stream import Signal
from homeassistant.exceptions import ServiceValidationError
@@ -29,7 +29,7 @@ class TeslemetryRootEntity(Entity):
_attr_has_entity_name = True
scoped: bool
- api: VehicleSpecific | EnergySpecific
+ api: Vehicle | EnergySite
def raise_for_scope(self, scope: Scope):
"""Raise an error if a scope is not available."""
@@ -105,7 +105,7 @@ class TeslemetryVehicleEntity(TeslemetryEntity):
"""Parent class for Teslemetry Vehicle entities."""
_last_update: int = 0
- api: VehicleSpecific
+ api: Vehicle
vehicle: TeslemetryVehicleData
def __init__(
@@ -134,7 +134,7 @@ class TeslemetryVehicleEntity(TeslemetryEntity):
class TeslemetryEnergyLiveEntity(TeslemetryEntity):
"""Parent class for Teslemetry Energy Site Live entities."""
- api: EnergySpecific
+ api: EnergySite
def __init__(
self,
@@ -155,7 +155,7 @@ class TeslemetryEnergyLiveEntity(TeslemetryEntity):
class TeslemetryEnergyInfoEntity(TeslemetryEntity):
"""Parent class for Teslemetry Energy Site Info Entities."""
- api: EnergySpecific
+ api: EnergySite
def __init__(
self,
@@ -194,7 +194,7 @@ class TeslemetryWallConnectorEntity(TeslemetryEntity):
"""Parent class for Teslemetry Wall Connector Entities."""
_attr_has_entity_name = True
- api: EnergySpecific
+ api: EnergySite
def __init__(
self,
diff --git a/homeassistant/components/teslemetry/lock.py b/homeassistant/components/teslemetry/lock.py
index 18b88273bec..68505a12a13 100644
--- a/homeassistant/components/teslemetry/lock.py
+++ b/homeassistant/components/teslemetry/lock.py
@@ -10,7 +10,7 @@ from tesla_fleet_api.const import Scope
from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from . import TeslemetryConfigEntry
@@ -31,7 +31,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry lock platform from a config entry."""
diff --git a/homeassistant/components/teslemetry/manifest.json b/homeassistant/components/teslemetry/manifest.json
index bfa0d831a16..4c21bb017d8 100644
--- a/homeassistant/components/teslemetry/manifest.json
+++ b/homeassistant/components/teslemetry/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
- "requirements": ["tesla-fleet-api==0.9.8", "teslemetry-stream==0.6.10"]
+ "requirements": ["tesla-fleet-api==1.0.17", "teslemetry-stream==0.7.1"]
}
diff --git a/homeassistant/components/teslemetry/media_player.py b/homeassistant/components/teslemetry/media_player.py
index e0e144ffe3a..50f15618e66 100644
--- a/homeassistant/components/teslemetry/media_player.py
+++ b/homeassistant/components/teslemetry/media_player.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from tesla_fleet_api.const import Scope
+from tesla_fleet_api.teslemetry import Vehicle
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
@@ -11,10 +12,15 @@ from homeassistant.components.media_player import (
MediaPlayerState,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
from . import TeslemetryConfigEntry
-from .entity import TeslemetryVehicleEntity
+from .entity import (
+ TeslemetryRootEntity,
+ TeslemetryVehicleEntity,
+ TeslemetryVehicleStreamEntity,
+)
from .helpers import handle_vehicle_command
from .models import TeslemetryVehicleData
@@ -24,8 +30,16 @@ STATES = {
"Stopped": MediaPlayerState.IDLE,
"Off": MediaPlayerState.OFF,
}
-VOLUME_MAX = 11.0
-VOLUME_STEP = 1.0 / 3
+DISPLAY_STATES = {
+ "On": MediaPlayerState.IDLE,
+ "Accessory": MediaPlayerState.IDLE,
+ "Charging": MediaPlayerState.OFF,
+ "Sentry": MediaPlayerState.OFF,
+ "Off": MediaPlayerState.OFF,
+}
+# Tesla uses 31 steps, in 0.333 increments up to 10.333
+VOLUME_STEP = 1 / 31
+VOLUME_FACTOR = 31 / 3 # 10.333
PARALLEL_UPDATES = 0
@@ -33,73 +47,104 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry Media platform from a config entry."""
async_add_entities(
- TeslemetryMediaEntity(vehicle, Scope.VEHICLE_CMDS in entry.runtime_data.scopes)
+ TeslemetryPollingMediaEntity(vehicle, entry.runtime_data.scopes)
+ if vehicle.api.pre2021 or vehicle.firmware < "2025.2.6"
+ else TeslemetryStreamingMediaEntity(vehicle, entry.runtime_data.scopes)
for vehicle in entry.runtime_data.vehicles
)
-class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity):
- """Vehicle media player class."""
+class TeslemetryMediaEntity(TeslemetryRootEntity, MediaPlayerEntity):
+ """Base vehicle media player class."""
+
+ api: Vehicle
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
- _attr_supported_features = (
- MediaPlayerEntityFeature.NEXT_TRACK
- | MediaPlayerEntityFeature.PAUSE
- | MediaPlayerEntityFeature.PLAY
- | MediaPlayerEntityFeature.PREVIOUS_TRACK
- | MediaPlayerEntityFeature.VOLUME_SET
- )
- _volume_max: float = VOLUME_MAX
+ _attr_volume_step = VOLUME_STEP
+
+ async def async_set_volume_level(self, volume: float) -> None:
+ """Set volume level, range 0..1."""
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
+
+ await handle_vehicle_command(self.api.adjust_volume(volume * VOLUME_FACTOR))
+ self._attr_volume_level = volume
+ self.async_write_ha_state()
+
+ async def async_media_play(self) -> None:
+ """Send play command."""
+ if self.state != MediaPlayerState.PLAYING:
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
+
+ await handle_vehicle_command(self.api.media_toggle_playback())
+ self._attr_state = MediaPlayerState.PLAYING
+ self.async_write_ha_state()
+
+ async def async_media_pause(self) -> None:
+ """Send pause command."""
+
+ if self.state == MediaPlayerState.PLAYING:
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
+
+ await handle_vehicle_command(self.api.media_toggle_playback())
+ self._attr_state = MediaPlayerState.PAUSED
+ self.async_write_ha_state()
+
+ async def async_media_next_track(self) -> None:
+ """Send next track command."""
+
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
+ await handle_vehicle_command(self.api.media_next_track())
+
+ async def async_media_previous_track(self) -> None:
+ """Send previous track command."""
+
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
+ await handle_vehicle_command(self.api.media_prev_track())
+
+
+class TeslemetryPollingMediaEntity(TeslemetryVehicleEntity, TeslemetryMediaEntity):
+ """Polling vehicle media player class."""
def __init__(
self,
data: TeslemetryVehicleData,
- scoped: bool,
+ scopes: list[Scope],
) -> None:
"""Initialize the media player entity."""
super().__init__(data, "media")
- self.scoped = scoped
- if not scoped:
+
+ self._attr_supported_features = (
+ MediaPlayerEntityFeature.NEXT_TRACK
+ | MediaPlayerEntityFeature.PAUSE
+ | MediaPlayerEntityFeature.PLAY
+ | MediaPlayerEntityFeature.PREVIOUS_TRACK
+ | MediaPlayerEntityFeature.VOLUME_SET
+ )
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ if not self.scoped:
self._attr_supported_features = MediaPlayerEntityFeature(0)
def _async_update_attrs(self) -> None:
"""Update entity attributes."""
- self._volume_max = (
- self.get("vehicle_state_media_info_audio_volume_max") or VOLUME_MAX
- )
- self._attr_state = STATES.get(
- self.get("vehicle_state_media_info_media_playback_status") or "Off",
- )
- self._attr_volume_step = (
- 1.0
- / self._volume_max
- / (
- self.get("vehicle_state_media_info_audio_volume_increment")
- or VOLUME_STEP
- )
- )
+ state = self.get("vehicle_state_media_info_media_playback_status")
+ self._attr_state = STATES.get(state) if state else None
+ self._attr_volume_level = (
+ self.get("vehicle_state_media_info_audio_volume") or 0
+ ) / VOLUME_FACTOR
- if volume := self.get("vehicle_state_media_info_audio_volume"):
- self._attr_volume_level = volume / self._volume_max
- else:
- self._attr_volume_level = None
+ duration = self.get("vehicle_state_media_info_now_playing_duration")
+ self._attr_media_duration = duration / 1000 if duration is not None else None
- if duration := self.get("vehicle_state_media_info_now_playing_duration"):
- self._attr_media_duration = duration / 1000
- else:
- self._attr_media_duration = None
-
- if duration and (
- position := self.get("vehicle_state_media_info_now_playing_elapsed")
- ):
- self._attr_media_position = position / 1000
- else:
- self._attr_media_position = None
+ # Return media position only when a media duration is > 0.
+ elapsed = self.get("vehicle_state_media_info_now_playing_elapsed")
+ self._attr_media_position = (
+ elapsed / 1000 if duration and elapsed is not None else None
+ )
self._attr_media_title = self.get("vehicle_state_media_info_now_playing_title")
self._attr_media_artist = self.get(
@@ -113,42 +158,151 @@ class TeslemetryMediaEntity(TeslemetryVehicleEntity, MediaPlayerEntity):
)
self._attr_source = self.get("vehicle_state_media_info_now_playing_source")
- async def async_set_volume_level(self, volume: float) -> None:
- """Set volume level, range 0..1."""
- self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
- await handle_vehicle_command(
- self.api.adjust_volume(int(volume * self._volume_max))
+
+class TeslemetryStreamingMediaEntity(
+ TeslemetryVehicleStreamEntity, TeslemetryMediaEntity, RestoreEntity
+):
+ """Streaming vehicle media player class."""
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ scopes: list[Scope],
+ ) -> None:
+ """Initialize the media player entity."""
+ super().__init__(data, "media")
+
+ self._attr_supported_features = (
+ MediaPlayerEntityFeature.NEXT_TRACK
+ | MediaPlayerEntityFeature.PAUSE
+ | MediaPlayerEntityFeature.PLAY
+ | MediaPlayerEntityFeature.PREVIOUS_TRACK
+ | MediaPlayerEntityFeature.VOLUME_SET
)
- self._attr_volume_level = volume
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ if not self.scoped:
+ self._attr_supported_features = MediaPlayerEntityFeature(0)
+
+ async def async_added_to_hass(self) -> None:
+ """Call when entity is added to hass."""
+
+ await super().async_added_to_hass()
+ if (state := await self.async_get_last_state()) is not None:
+ try:
+ self._attr_state = MediaPlayerState(state.state)
+ except ValueError:
+ self._attr_state = None
+ self._attr_volume_level = state.attributes.get("volume_level")
+ self._attr_media_title = state.attributes.get("media_title")
+ self._attr_media_artist = state.attributes.get("media_artist")
+ self._attr_media_album_name = state.attributes.get("media_album_name")
+ self._attr_media_playlist = state.attributes.get("media_playlist")
+ self._attr_media_duration = state.attributes.get("media_duration")
+ self._attr_media_position = state.attributes.get("media_position")
+ self._attr_source = state.attributes.get("source")
+
+ self.async_write_ha_state()
+
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_CenterDisplay(
+ self._async_handle_center_display
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_MediaPlaybackStatus(
+ self._async_handle_media_playback_status
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_MediaPlaybackSource(
+ self._async_handle_media_playback_source
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_MediaAudioVolume(
+ self._async_handle_media_audio_volume
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_MediaNowPlayingDuration(
+ self._async_handle_media_now_playing_duration
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_MediaNowPlayingElapsed(
+ self._async_handle_media_now_playing_elapsed
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_MediaNowPlayingArtist(
+ self._async_handle_media_now_playing_artist
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_MediaNowPlayingAlbum(
+ self._async_handle_media_now_playing_album
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_MediaNowPlayingTitle(
+ self._async_handle_media_now_playing_title
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_MediaNowPlayingStation(
+ self._async_handle_media_now_playing_station
+ )
+ )
+
+ def _async_handle_center_display(self, value: str | None) -> None:
+ """Update entity attributes."""
+ if value is not None:
+ self._attr_state = DISPLAY_STATES.get(value)
+ self.async_write_ha_state()
+
+ def _async_handle_media_playback_status(self, value: str | None) -> None:
+ """Update entity attributes."""
+ self._attr_state = MediaPlayerState.OFF if value is None else STATES.get(value)
self.async_write_ha_state()
- async def async_media_play(self) -> None:
- """Send play command."""
- if self.state != MediaPlayerState.PLAYING:
- self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
- await handle_vehicle_command(self.api.media_toggle_playback())
- self._attr_state = MediaPlayerState.PLAYING
- self.async_write_ha_state()
+ def _async_handle_media_playback_source(self, value: str | None) -> None:
+ """Update entity attributes."""
+ self._attr_source = value
+ self.async_write_ha_state()
- async def async_media_pause(self) -> None:
- """Send pause command."""
- if self.state == MediaPlayerState.PLAYING:
- self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
- await handle_vehicle_command(self.api.media_toggle_playback())
- self._attr_state = MediaPlayerState.PAUSED
- self.async_write_ha_state()
+ def _async_handle_media_audio_volume(self, value: float | None) -> None:
+ """Update entity attributes."""
+ self._attr_volume_level = None if value is None else value / VOLUME_FACTOR
+ self.async_write_ha_state()
- async def async_media_next_track(self) -> None:
- """Send next track command."""
- self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
- await handle_vehicle_command(self.api.media_next_track())
+ def _async_handle_media_now_playing_duration(self, value: int | None) -> None:
+ """Update entity attributes."""
+ self._attr_media_duration = None if value is None else int(value / 1000)
+ self.async_write_ha_state()
- async def async_media_previous_track(self) -> None:
- """Send previous track command."""
- self.raise_for_scope(Scope.VEHICLE_CMDS)
- await self.wake_up_if_asleep()
- await handle_vehicle_command(self.api.media_prev_track())
+ def _async_handle_media_now_playing_elapsed(self, value: int | None) -> None:
+ """Update entity attributes."""
+ self._attr_media_position = None if value is None else int(value / 1000)
+ self.async_write_ha_state()
+
+ def _async_handle_media_now_playing_artist(self, value: str | None) -> None:
+ """Update entity attributes."""
+ self._attr_media_artist = value # Check if this is album artist or not
+ self.async_write_ha_state()
+
+ def _async_handle_media_now_playing_album(self, value: str | None) -> None:
+ """Update entity attributes."""
+ self._attr_media_album_name = value
+ self.async_write_ha_state()
+
+ def _async_handle_media_now_playing_title(self, value: str | None) -> None:
+ """Update entity attributes."""
+ self._attr_media_title = value
+ self.async_write_ha_state()
+
+ def _async_handle_media_now_playing_station(self, value: str | None) -> None:
+ """Update entity attributes."""
+ self._attr_media_channel = (
+ value # could also be _attr_media_playlist when Spotify
+ )
+ self.async_write_ha_state()
diff --git a/homeassistant/components/teslemetry/models.py b/homeassistant/components/teslemetry/models.py
index 5b78386c68a..fd6cf12b5b9 100644
--- a/homeassistant/components/teslemetry/models.py
+++ b/homeassistant/components/teslemetry/models.py
@@ -6,8 +6,8 @@ import asyncio
from collections.abc import Callable
from dataclasses import dataclass
-from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import Scope
+from tesla_fleet_api.teslemetry import EnergySite, Vehicle
from teslemetry_stream import TeslemetryStream, TeslemetryStreamVehicle
from homeassistant.config_entries import ConfigEntry
@@ -34,7 +34,7 @@ class TeslemetryData:
class TeslemetryVehicleData:
"""Data for a vehicle in the Teslemetry integration."""
- api: VehicleSpecific
+ api: Vehicle
config_entry: ConfigEntry
coordinator: TeslemetryVehicleDataCoordinator
stream: TeslemetryStream
@@ -50,7 +50,7 @@ class TeslemetryVehicleData:
class TeslemetryEnergyData:
"""Data for a vehicle in the Teslemetry integration."""
- api: EnergySpecific
+ api: EnergySite
live_coordinator: TeslemetryEnergySiteLiveCoordinator | None
info_coordinator: TeslemetryEnergySiteInfoCoordinator
history_coordinator: TeslemetryEnergyHistoryCoordinator | None
diff --git a/homeassistant/components/teslemetry/number.py b/homeassistant/components/teslemetry/number.py
index c44028f2da7..ff25dec59b8 100644
--- a/homeassistant/components/teslemetry/number.py
+++ b/homeassistant/components/teslemetry/number.py
@@ -7,8 +7,8 @@ from dataclasses import dataclass
from itertools import chain
from typing import Any
-from tesla_fleet_api import EnergySpecific, VehicleSpecific
from tesla_fleet_api.const import Scope
+from tesla_fleet_api.teslemetry import EnergySite, Vehicle
from teslemetry_stream import TeslemetryStreamVehicle
from homeassistant.components.number import (
@@ -26,7 +26,7 @@ from homeassistant.const import (
UnitOfElectricCurrent,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from . import TeslemetryConfigEntry
@@ -46,7 +46,7 @@ PARALLEL_UPDATES = 0
class TeslemetryNumberVehicleEntityDescription(NumberEntityDescription):
"""Describes Teslemetry Number entity."""
- func: Callable[[VehicleSpecific, int], Awaitable[Any]]
+ func: Callable[[Vehicle, int], Awaitable[Any]]
min_key: str | None = None
max_key: str
native_min_value: float
@@ -99,7 +99,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryNumberVehicleEntityDescription, ...] = (
class TeslemetryNumberBatteryEntityDescription(NumberEntityDescription):
"""Describes Teslemetry Number entity."""
- func: Callable[[EnergySpecific, float], Awaitable[Any]]
+ func: Callable[[EnergySite, float], Awaitable[Any]]
requires: str | None = None
scopes: list[Scope]
@@ -133,7 +133,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TeslemetryNumberBatteryEntityDescription, ...] =
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry number platform from a config entry."""
diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py
index d2e90a4f5c9..9e13d15edc4 100644
--- a/homeassistant/components/teslemetry/select.py
+++ b/homeassistant/components/teslemetry/select.py
@@ -7,13 +7,13 @@ from dataclasses import dataclass
from itertools import chain
from typing import Any
-from tesla_fleet_api import VehicleSpecific
from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode, Scope, Seat
+from tesla_fleet_api.teslemetry import Vehicle
from teslemetry_stream import TeslemetryStreamVehicle
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from . import TeslemetryConfigEntry
@@ -40,7 +40,7 @@ LEVEL = {OFF: 0, LOW: 1, MEDIUM: 2, HIGH: 3}
class TeslemetrySelectEntityDescription(SelectEntityDescription):
"""Seat Heater entity description."""
- select_fn: Callable[[VehicleSpecific, int], Awaitable[Any]]
+ select_fn: Callable[[Vehicle, int], Awaitable[Any]]
supported_fn: Callable[[dict], bool] = lambda _: True
streaming_listener: (
Callable[
@@ -170,7 +170,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySelectEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry select platform from a config entry."""
diff --git a/homeassistant/components/teslemetry/sensor.py b/homeassistant/components/teslemetry/sensor.py
index dd83ad04ed6..fb653314bc5 100644
--- a/homeassistant/components/teslemetry/sensor.py
+++ b/homeassistant/components/teslemetry/sensor.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from datetime import datetime, timedelta
from propcache.api import cached_property
-from teslemetry_stream import Signal
+from teslemetry_stream import TeslemetryStreamVehicle
from homeassistant.components.sensor import (
RestoreSensor,
@@ -30,7 +30,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from homeassistant.util.variance import ignore_variance
@@ -49,6 +49,7 @@ from .models import TeslemetryEnergyData, TeslemetryVehicleData
PARALLEL_UPDATES = 0
+
CHARGE_STATES = {
"Starting": "starting",
"Charging": "charging",
@@ -67,9 +68,14 @@ class TeslemetryVehicleSensorEntityDescription(SensorEntityDescription):
polling: bool = False
polling_value_fn: Callable[[StateType], StateType] = lambda x: x
- polling_available_fn: Callable[[StateType], bool] = lambda x: x is not None
- streaming_key: Signal | None = None
- streaming_value_fn: Callable[[StateType], StateType] = lambda x: x
+ nullable: bool = False
+ streaming_listener: (
+ Callable[
+ [TeslemetryStreamVehicle, Callable[[StateType], None]],
+ Callable[[], None],
+ ]
+ | None
+ ) = None
streaming_firmware: str = "2024.26"
@@ -77,18 +83,17 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_charging_state",
polling=True,
- streaming_key=Signal.DETAILED_CHARGE_STATE,
- polling_value_fn=lambda value: CHARGE_STATES.get(str(value)),
- streaming_value_fn=lambda value: CHARGE_STATES.get(
- str(value).replace("DetailedChargeState", "")
+ streaming_listener=lambda x, y: x.listen_DetailedChargeState(
+ lambda z: None if z is None else y(z.lower())
),
+ polling_value_fn=lambda value: CHARGE_STATES.get(str(value)),
options=list(CHARGE_STATES.values()),
device_class=SensorDeviceClass.ENUM,
),
TeslemetryVehicleSensorEntityDescription(
key="charge_state_battery_level",
polling=True,
- streaming_key=Signal.BATTERY_LEVEL,
+ streaming_listener=lambda x, y: x.listen_BatteryLevel(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
@@ -97,15 +102,17 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_usable_battery_level",
polling=True,
+ streaming_listener=lambda x, y: x.listen_Soc(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
entity_registry_enabled_default=False,
+ suggested_display_precision=1,
),
TeslemetryVehicleSensorEntityDescription(
key="charge_state_charge_energy_added",
polling=True,
- streaming_key=Signal.AC_CHARGING_ENERGY_IN,
+ streaming_listener=lambda x, y: x.listen_ACChargingEnergyIn(y),
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
@@ -114,7 +121,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_charger_power",
polling=True,
- streaming_key=Signal.AC_CHARGING_POWER,
+ streaming_listener=lambda x, y: x.listen_ACChargingPower(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
@@ -122,6 +129,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_charger_voltage",
polling=True,
+ streaming_listener=lambda x, y: x.listen_ChargerVoltage(y),
+ streaming_firmware="2024.44.32",
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
@@ -130,7 +139,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_charger_actual_current",
polling=True,
- streaming_key=Signal.CHARGE_AMPS,
+ streaming_listener=lambda x, y: x.listen_ChargeAmps(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
@@ -147,14 +156,14 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_conn_charge_cable",
polling=True,
- streaming_key=Signal.CHARGING_CABLE_TYPE,
+ streaming_listener=lambda x, y: x.listen_ChargingCableType(y),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
TeslemetryVehicleSensorEntityDescription(
key="charge_state_fast_charger_type",
polling=True,
- streaming_key=Signal.FAST_CHARGER_TYPE,
+ streaming_listener=lambda x, y: x.listen_FastChargerType(y),
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
@@ -169,7 +178,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_est_battery_range",
polling=True,
- streaming_key=Signal.EST_BATTERY_RANGE,
+ streaming_listener=lambda x, y: x.listen_EstBatteryRange(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
@@ -179,7 +188,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="charge_state_ideal_battery_range",
polling=True,
- streaming_key=Signal.IDEAL_BATTERY_RANGE,
+ streaming_listener=lambda x, y: x.listen_IdealBatteryRange(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
@@ -190,7 +199,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
key="drive_state_speed",
polling=True,
polling_value_fn=lambda value: value or 0,
- streaming_key=Signal.VEHICLE_SPEED,
+ streaming_listener=lambda x, y: x.listen_VehicleSpeed(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfSpeed.MILES_PER_HOUR,
device_class=SensorDeviceClass.SPEED,
@@ -209,10 +218,11 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="drive_state_shift_state",
polling=True,
- polling_available_fn=lambda x: True,
polling_value_fn=lambda x: SHIFT_STATES.get(str(x), "p"),
- streaming_key=Signal.GEAR,
- streaming_value_fn=lambda x: SHIFT_STATES.get(str(x)),
+ nullable=True,
+ streaming_listener=lambda x, y: x.listen_Gear(
+ lambda z: y("p" if z is None else z.lower())
+ ),
options=list(SHIFT_STATES.values()),
device_class=SensorDeviceClass.ENUM,
entity_registry_enabled_default=False,
@@ -220,7 +230,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_odometer",
polling=True,
- streaming_key=Signal.ODOMETER,
+ streaming_listener=lambda x, y: x.listen_Odometer(y),
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
@@ -231,7 +241,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_fl",
polling=True,
- streaming_key=Signal.TPMS_PRESSURE_FL,
+ streaming_listener=lambda x, y: x.listen_TpmsPressureFl(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@@ -243,7 +253,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_fr",
polling=True,
- streaming_key=Signal.TPMS_PRESSURE_FR,
+ streaming_listener=lambda x, y: x.listen_TpmsPressureFr(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@@ -255,7 +265,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_rl",
polling=True,
- streaming_key=Signal.TPMS_PRESSURE_RL,
+ streaming_listener=lambda x, y: x.listen_TpmsPressureRl(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@@ -267,7 +277,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="vehicle_state_tpms_pressure_rr",
polling=True,
- streaming_key=Signal.TPMS_PRESSURE_RR,
+ streaming_listener=lambda x, y: x.listen_TpmsPressureRr(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfPressure.BAR,
suggested_unit_of_measurement=UnitOfPressure.PSI,
@@ -279,7 +289,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="climate_state_inside_temp",
polling=True,
- streaming_key=Signal.INSIDE_TEMP,
+ streaming_listener=lambda x, y: x.listen_InsideTemp(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
@@ -288,7 +298,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="climate_state_outside_temp",
polling=True,
- streaming_key=Signal.OUTSIDE_TEMP,
+ streaming_listener=lambda x, y: x.listen_OutsideTemp(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
@@ -317,7 +327,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="drive_state_active_route_traffic_minutes_delay",
polling=True,
- streaming_key=Signal.ROUTE_TRAFFIC_MINUTES_DELAY,
+ streaming_listener=lambda x, y: x.listen_RouteTrafficMinutesDelay(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTime.MINUTES,
device_class=SensorDeviceClass.DURATION,
@@ -326,7 +336,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="drive_state_active_route_energy_at_arrival",
polling=True,
- streaming_key=Signal.EXPECTED_ENERGY_PERCENT_AT_TRIP_ARRIVAL,
+ streaming_listener=lambda x, y: x.listen_ExpectedEnergyPercentAtTripArrival(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.BATTERY,
@@ -336,7 +346,7 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetryVehicleSensorEntityDescription, ...] = (
TeslemetryVehicleSensorEntityDescription(
key="drive_state_active_route_miles_to_arrival",
polling=True,
- streaming_key=Signal.MILES_TO_ARRIVAL,
+ streaming_listener=lambda x, y: x.listen_MilesToArrival(y),
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfLength.MILES,
device_class=SensorDeviceClass.DISTANCE,
@@ -349,21 +359,27 @@ class TeslemetryTimeEntityDescription(SensorEntityDescription):
"""Describes Teslemetry Sensor entity."""
variance: int
- streaming_key: Signal
+ streaming_listener: Callable[
+ [TeslemetryStreamVehicle, Callable[[float | None], None]],
+ Callable[[], None],
+ ]
streaming_firmware: str = "2024.26"
+ streaming_unit: str
VEHICLE_TIME_DESCRIPTIONS: tuple[TeslemetryTimeEntityDescription, ...] = (
TeslemetryTimeEntityDescription(
key="charge_state_minutes_to_full_charge",
- streaming_key=Signal.TIME_TO_FULL_CHARGE,
+ streaming_listener=lambda x, y: x.listen_TimeToFullCharge(y),
+ streaming_unit="hours",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
variance=4,
),
TeslemetryTimeEntityDescription(
key="drive_state_active_route_minutes_to_arrival",
- streaming_key=Signal.MINUTES_TO_ARRIVAL,
+ streaming_listener=lambda x, y: x.listen_MinutesToArrival(y),
+ streaming_unit="minutes",
device_class=SensorDeviceClass.TIMESTAMP,
variance=1,
),
@@ -529,7 +545,7 @@ ENERGY_HISTORY_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = tuple(
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry sensor platform from a config entry."""
@@ -538,7 +554,7 @@ async def async_setup_entry(
for description in VEHICLE_DESCRIPTIONS:
if (
not vehicle.api.pre2021
- and description.streaming_key
+ and description.streaming_listener
and vehicle.firmware >= description.streaming_firmware
):
entities.append(TeslemetryStreamSensorEntity(vehicle, description))
@@ -604,8 +620,7 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor)
) -> None:
"""Initialize the sensor."""
self.entity_description = description
- assert description.streaming_key
- super().__init__(data, description.key, description.streaming_key)
+ super().__init__(data, description.key)
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
@@ -614,17 +629,22 @@ class TeslemetryStreamSensorEntity(TeslemetryVehicleStreamEntity, RestoreSensor)
if (sensor_data := await self.async_get_last_sensor_data()) is not None:
self._attr_native_value = sensor_data.native_value
+ if self.entity_description.streaming_listener is not None:
+ self.async_on_remove(
+ self.entity_description.streaming_listener(
+ self.vehicle.stream_vehicle, self._async_value_from_stream
+ )
+ )
+
@cached_property
def available(self) -> bool:
"""Return True if entity is available."""
return self.stream.connected
- def _async_value_from_stream(self, value) -> None:
+ def _async_value_from_stream(self, value: StateType) -> None:
"""Update the value of the entity."""
- if value is None:
- self._attr_native_value = None
- else:
- self._attr_native_value = self.entity_description.streaming_value_fn(value)
+ self._attr_native_value = value
+ self.async_write_ha_state()
class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity):
@@ -643,7 +663,7 @@ class TeslemetryVehicleSensorEntity(TeslemetryVehicleEntity, SensorEntity):
def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor."""
- if self.entity_description.polling_available_fn(self._value):
+ if self.entity_description.nullable or self._value is not None:
self._attr_available = True
self._attr_native_value = self.entity_description.polling_value_fn(
self._value
@@ -666,23 +686,28 @@ class TeslemetryStreamTimeSensorEntity(TeslemetryVehicleStreamEntity, SensorEnti
"""Initialize the sensor."""
self.entity_description = description
self._get_timestamp = ignore_variance(
- func=lambda value: dt_util.now() + timedelta(minutes=value),
+ func=lambda value: dt_util.now()
+ + timedelta(**{self.entity_description.streaming_unit: value}),
ignored_variance=timedelta(minutes=description.variance),
)
- assert description.streaming_key
- super().__init__(data, description.key, description.streaming_key)
+ super().__init__(data, description.key)
- @cached_property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self.stream.connected
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ self.entity_description.streaming_listener(
+ self.vehicle.stream_vehicle, self._value_callback
+ )
+ )
- def _async_value_from_stream(self, value) -> None:
+ def _value_callback(self, value: float | None) -> None:
"""Update the value of the entity."""
if value is None:
self._attr_native_value = None
else:
self._attr_native_value = self._get_timestamp(value)
+ self.async_write_ha_state()
class TeslemetryVehicleTimeSensorEntity(TeslemetryVehicleEntity, SensorEntity):
diff --git a/homeassistant/components/teslemetry/strings.json b/homeassistant/components/teslemetry/strings.json
index 68ad12a46b6..69b1551a561 100644
--- a/homeassistant/components/teslemetry/strings.json
+++ b/homeassistant/components/teslemetry/strings.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "Account is already configured",
+ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reauth_account_mismatch": "The reauthentication account does not match the original account"
},
@@ -132,7 +132,7 @@
"name": "Tire pressure warning rear right"
},
"pin_to_drive_enabled": {
- "name": "Pin to drive enabled"
+ "name": "PIN to Drive enabled"
},
"drive_rail": {
"name": "Drive rail"
@@ -221,11 +221,17 @@
"state_attributes": {
"preset_mode": {
"state": {
- "off": "Normal",
+ "off": "[%key:common::state::normal%]",
"keep": "Keep mode",
"dog": "Dog mode",
"camp": "Camp mode"
}
+ },
+ "fan_mode": {
+ "state": {
+ "off": "[%key:common::state::off%]",
+ "bioweapon": "Bioweapon defense"
+ }
}
}
}
@@ -256,72 +262,72 @@
"climate_state_seat_heater_left": {
"name": "Seat heater front left",
"state": {
- "high": "High",
- "low": "Low",
- "medium": "Medium",
- "off": "Off"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_rear_center": {
"name": "Seat heater rear center",
"state": {
- "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_rear_left": {
"name": "Seat heater rear left",
"state": {
- "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_rear_right": {
"name": "Seat heater rear right",
"state": {
- "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_right": {
"name": "Seat heater front right",
"state": {
- "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_third_row_left": {
"name": "Seat heater third row left",
"state": {
- "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_seat_heater_third_row_right": {
"name": "Seat heater third row right",
"state": {
- "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::medium%]",
- "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "off": "[%key:common::state::off%]"
}
},
"climate_state_steering_wheel_heat_level": {
"name": "Steering wheel heater",
"state": {
- "high": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::high%]",
- "low": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::low%]",
- "off": "[%key:component::teslemetry::entity::select::climate_state_seat_heater_left::state::off%]"
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
+ "off": "[%key:common::state::off%]"
}
},
"components_customer_preferred_export_rule": {
@@ -415,9 +421,9 @@
"name": "Charging",
"state": {
"starting": "Starting",
- "charging": "Charging",
- "disconnected": "Disconnected",
- "stopped": "Stopped",
+ "charging": "[%key:common::state::charging%]",
+ "disconnected": "[%key:common::state::disconnected%]",
+ "stopped": "[%key:common::state::stopped%]",
"complete": "Complete",
"no_power": "No power"
}
@@ -528,7 +534,7 @@
"vin": {
"name": "Vehicle",
"state": {
- "disconnected": "Disconnected"
+ "disconnected": "[%key:common::state::disconnected%]"
}
},
"vpp_backup_reserve_percent": {
@@ -712,7 +718,7 @@
"name": "Navigate to coordinates"
},
"set_scheduled_charging": {
- "description": "Sets a time at which charging should be completed.",
+ "description": "Sets a time at which charging should be started.",
"fields": {
"device_id": {
"description": "Vehicle to schedule.",
@@ -720,7 +726,7 @@
},
"enable": {
"description": "Enable or disable scheduled charging.",
- "name": "Enable"
+ "name": "[%key:common::action::enable%]"
},
"time": {
"description": "Time to start charging.",
@@ -742,7 +748,7 @@
},
"enable": {
"description": "Enable or disable scheduled departure.",
- "name": "Enable"
+ "name": "[%key:common::action::enable%]"
},
"end_off_peak_time": {
"description": "Time to complete charging by.",
@@ -776,7 +782,7 @@
},
"enable": {
"description": "Enable or disable speed limit.",
- "name": "Enable"
+ "name": "[%key:common::action::enable%]"
},
"pin": {
"description": "4 digit PIN.",
@@ -808,7 +814,7 @@
},
"enable": {
"description": "Enable or disable valet mode.",
- "name": "Enable"
+ "name": "[%key:common::action::enable%]"
},
"pin": {
"description": "4 digit PIN.",
diff --git a/homeassistant/components/teslemetry/switch.py b/homeassistant/components/teslemetry/switch.py
index f810dee8554..645a8398820 100644
--- a/homeassistant/components/teslemetry/switch.py
+++ b/homeassistant/components/teslemetry/switch.py
@@ -7,7 +7,8 @@ from dataclasses import dataclass
from itertools import chain
from typing import Any
-from tesla_fleet_api.const import Scope, Seat
+from tesla_fleet_api.const import AutoSeat, Scope
+from teslemetry_stream import TeslemetryStreamVehicle
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -15,11 +16,17 @@ from homeassistant.components.switch import (
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import StateType
from . import TeslemetryConfigEntry
-from .entity import TeslemetryEnergyInfoEntity, TeslemetryVehicleEntity
+from .entity import (
+ TeslemetryEnergyInfoEntity,
+ TeslemetryRootEntity,
+ TeslemetryVehicleEntity,
+ TeslemetryVehicleStreamEntity,
+)
from .helpers import handle_command, handle_vehicle_command
from .models import TeslemetryEnergyData, TeslemetryVehicleData
@@ -34,36 +41,49 @@ class TeslemetrySwitchEntityDescription(SwitchEntityDescription):
off_func: Callable
scopes: list[Scope]
value_func: Callable[[StateType], bool] = bool
+ streaming_listener: Callable[
+ [TeslemetryStreamVehicle, Callable[[StateType], None]],
+ Callable[[], None],
+ ]
+ streaming_value_fn: Callable[[StateType], bool] = bool
+ streaming_firmware: str = "2024.26"
unique_id: str | None = None
VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
TeslemetrySwitchEntityDescription(
key="vehicle_state_sentry_mode",
+ streaming_listener=lambda x, y: x.listen_SentryMode(y),
+ streaming_value_fn=lambda x: x != "Off",
on_func=lambda api: api.set_sentry_mode(on=True),
off_func=lambda api: api.set_sentry_mode(on=False),
scopes=[Scope.VEHICLE_CMDS],
),
TeslemetrySwitchEntityDescription(
key="climate_state_auto_seat_climate_left",
- on_func=lambda api: api.remote_auto_seat_climate_request(Seat.FRONT_LEFT, True),
+ streaming_listener=lambda x, y: x.listen_AutoSeatClimateLeft(y),
+ on_func=lambda api: api.remote_auto_seat_climate_request(
+ AutoSeat.FRONT_LEFT, True
+ ),
off_func=lambda api: api.remote_auto_seat_climate_request(
- Seat.FRONT_LEFT, False
+ AutoSeat.FRONT_LEFT, False
),
scopes=[Scope.VEHICLE_CMDS],
),
TeslemetrySwitchEntityDescription(
key="climate_state_auto_seat_climate_right",
+ streaming_listener=lambda x, y: x.listen_AutoSeatClimateRight(y),
on_func=lambda api: api.remote_auto_seat_climate_request(
- Seat.FRONT_RIGHT, True
+ AutoSeat.FRONT_RIGHT, True
),
off_func=lambda api: api.remote_auto_seat_climate_request(
- Seat.FRONT_RIGHT, False
+ AutoSeat.FRONT_RIGHT, False
),
scopes=[Scope.VEHICLE_CMDS],
),
TeslemetrySwitchEntityDescription(
key="climate_state_auto_steering_wheel_heat",
+ streaming_listener=lambda x, y: x.listen_HvacSteeringWheelHeatAuto(y),
on_func=lambda api: api.remote_auto_steering_wheel_heat_climate_request(
on=True
),
@@ -74,6 +94,8 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
),
TeslemetrySwitchEntityDescription(
key="climate_state_defrost_mode",
+ streaming_listener=lambda x, y: x.listen_DefrostMode(y),
+ streaming_value_fn=lambda x: x != "Off",
on_func=lambda api: api.set_preconditioning_max(on=True, manual_override=False),
off_func=lambda api: api.set_preconditioning_max(
on=False, manual_override=False
@@ -83,9 +105,11 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
TeslemetrySwitchEntityDescription(
key="charge_state_charging_state",
unique_id="charge_state_user_charge_enable_request",
+ value_func=lambda state: state in {"Starting", "Charging"},
+ streaming_listener=lambda x, y: x.listen_DetailedChargeState(y),
+ streaming_value_fn=lambda x: x in {"Starting", "Charging"},
on_func=lambda api: api.charge_start(),
off_func=lambda api: api.charge_stop(),
- value_func=lambda state: state in {"Starting", "Charging"},
scopes=[Scope.VEHICLE_CMDS, Scope.VEHICLE_CHARGING_CMDS],
),
)
@@ -94,19 +118,23 @@ VEHICLE_DESCRIPTIONS: tuple[TeslemetrySwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry Switch platform from a config entry."""
async_add_entities(
chain(
(
- TeslemetryVehicleSwitchEntity(
+ TeslemetryPollingVehicleSwitchEntity(
+ vehicle, description, entry.runtime_data.scopes
+ )
+ if vehicle.api.pre2021
+ or vehicle.firmware < description.streaming_firmware
+ else TeslemetryStreamingVehicleSwitchEntity(
vehicle, description, entry.runtime_data.scopes
)
for vehicle in entry.runtime_data.vehicles
for description in VEHICLE_DESCRIPTIONS
- if description.key in vehicle.coordinator.data
),
(
TeslemetryChargeFromGridSwitchEntity(
@@ -126,15 +154,31 @@ async def async_setup_entry(
)
-class TeslemetrySwitchEntity(SwitchEntity):
+class TeslemetryVehicleSwitchEntity(TeslemetryRootEntity, SwitchEntity):
"""Base class for all Teslemetry switch entities."""
_attr_device_class = SwitchDeviceClass.SWITCH
entity_description: TeslemetrySwitchEntityDescription
+ async def async_turn_on(self, **kwargs: Any) -> None:
+ """Turn on the Switch."""
+ self.raise_for_scope(self.entity_description.scopes[0])
+ await handle_vehicle_command(self.entity_description.on_func(self.api))
+ self._attr_is_on = True
+ self.async_write_ha_state()
-class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEntity):
- """Base class for Teslemetry vehicle switch entities."""
+ async def async_turn_off(self, **kwargs: Any) -> None:
+ """Turn off the Switch."""
+ self.raise_for_scope(self.entity_description.scopes[0])
+ await handle_vehicle_command(self.entity_description.off_func(self.api))
+ self._attr_is_on = False
+ self.async_write_ha_state()
+
+
+class TeslemetryPollingVehicleSwitchEntity(
+ TeslemetryVehicleEntity, TeslemetryVehicleSwitchEntity
+):
+ """Base class for Teslemetry polling vehicle switch entities."""
def __init__(
self,
@@ -151,30 +195,63 @@ class TeslemetryVehicleSwitchEntity(TeslemetryVehicleEntity, TeslemetrySwitchEnt
def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor."""
- self._attr_is_on = self.entity_description.value_func(self._value)
-
- async def async_turn_on(self, **kwargs: Any) -> None:
- """Turn on the Switch."""
- self.raise_for_scope(self.entity_description.scopes[0])
- await self.wake_up_if_asleep()
- await handle_vehicle_command(self.entity_description.on_func(self.api))
- self._attr_is_on = True
- self.async_write_ha_state()
-
- async def async_turn_off(self, **kwargs: Any) -> None:
- """Turn off the Switch."""
- self.raise_for_scope(self.entity_description.scopes[0])
- await self.wake_up_if_asleep()
- await handle_vehicle_command(self.entity_description.off_func(self.api))
- self._attr_is_on = False
- self.async_write_ha_state()
+ self._attr_is_on = (
+ None
+ if self._value is None
+ else self.entity_description.value_func(self._value)
+ )
-class TeslemetryChargeFromGridSwitchEntity(
- TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity
+class TeslemetryStreamingVehicleSwitchEntity(
+ TeslemetryVehicleStreamEntity, TeslemetryVehicleSwitchEntity, RestoreEntity
):
+ """Base class for Teslemetry streaming vehicle switch entities."""
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ description: TeslemetrySwitchEntityDescription,
+ scopes: list[Scope],
+ ) -> None:
+ """Initialize the Switch."""
+
+ self.entity_description = description
+ self.scoped = any(scope in scopes for scope in description.scopes)
+ super().__init__(data, description.key)
+ if description.unique_id:
+ self._attr_unique_id = f"{data.vin}-{description.unique_id}"
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+
+ # Restore previous state
+ if (state := await self.async_get_last_state()) is not None:
+ if state.state == "on":
+ self._attr_is_on = True
+ elif state.state == "off":
+ self._attr_is_on = False
+
+ # Add listener
+ self.async_on_remove(
+ self.entity_description.streaming_listener(
+ self.vehicle.stream_vehicle, self._value_callback
+ )
+ )
+
+ def _value_callback(self, value: StateType) -> None:
+ """Update the value of the entity."""
+ self._attr_is_on = (
+ None if value is None else self.entity_description.streaming_value_fn(value)
+ )
+ self.async_write_ha_state()
+
+
+class TeslemetryChargeFromGridSwitchEntity(TeslemetryEnergyInfoEntity, SwitchEntity):
"""Entity class for Charge From Grid switch."""
+ _attr_device_class = SwitchDeviceClass.SWITCH
+
def __init__(
self,
data: TeslemetryEnergyData,
@@ -215,11 +292,11 @@ class TeslemetryChargeFromGridSwitchEntity(
self.async_write_ha_state()
-class TeslemetryStormModeSwitchEntity(
- TeslemetryEnergyInfoEntity, TeslemetrySwitchEntity
-):
+class TeslemetryStormModeSwitchEntity(TeslemetryEnergyInfoEntity, SwitchEntity):
"""Entity class for Storm Mode switch."""
+ _attr_device_class = SwitchDeviceClass.SWITCH
+
def __init__(
self,
data: TeslemetryEnergyData,
diff --git a/homeassistant/components/teslemetry/update.py b/homeassistant/components/teslemetry/update.py
index 670cd0e0eda..b8d40877de4 100644
--- a/homeassistant/components/teslemetry/update.py
+++ b/homeassistant/components/teslemetry/update.py
@@ -2,16 +2,22 @@
from __future__ import annotations
-from typing import Any, cast
+from typing import Any
from tesla_fleet_api.const import Scope
+from tesla_fleet_api.teslemetry import Vehicle
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.helpers.restore_state import RestoreEntity
from . import TeslemetryConfigEntry
-from .entity import TeslemetryVehicleEntity
+from .entity import (
+ TeslemetryRootEntity,
+ TeslemetryVehicleEntity,
+ TeslemetryVehicleStreamEntity,
+)
from .helpers import handle_vehicle_command
from .models import TeslemetryVehicleData
@@ -27,17 +33,36 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TeslemetryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Teslemetry update platform from a config entry."""
async_add_entities(
- TeslemetryUpdateEntity(vehicle, entry.runtime_data.scopes)
+ TeslemetryPollingUpdateEntity(vehicle, entry.runtime_data.scopes)
+ if vehicle.api.pre2021 or vehicle.firmware < "2024.44.25"
+ else TeslemetryStreamingUpdateEntity(vehicle, entry.runtime_data.scopes)
for vehicle in entry.runtime_data.vehicles
)
-class TeslemetryUpdateEntity(TeslemetryVehicleEntity, UpdateEntity):
+class TeslemetryUpdateEntity(TeslemetryRootEntity, UpdateEntity):
+ """Teslemetry Updates entity."""
+
+ api: Vehicle
+ _attr_supported_features = UpdateEntityFeature.PROGRESS
+
+ async def async_install(
+ self, version: str | None, backup: bool, **kwargs: Any
+ ) -> None:
+ """Install an update."""
+ self.raise_for_scope(Scope.VEHICLE_CMDS)
+
+ await handle_vehicle_command(self.api.schedule_software_update(offset_sec=0))
+ self._attr_in_progress = True
+ self.async_write_ha_state()
+
+
+class TeslemetryPollingUpdateEntity(TeslemetryVehicleEntity, TeslemetryUpdateEntity):
"""Teslemetry Updates entity."""
def __init__(
@@ -94,18 +119,125 @@ class TeslemetryUpdateEntity(TeslemetryVehicleEntity, UpdateEntity):
):
self._attr_in_progress = True
if install_perc := self.get("vehicle_state_software_update_install_perc"):
- self._attr_update_percentage = cast(int, install_perc)
+ self._attr_update_percentage = install_perc
else:
self._attr_in_progress = False
self._attr_update_percentage = None
- async def async_install(
- self, version: str | None, backup: bool, **kwargs: Any
+
+class TeslemetryStreamingUpdateEntity(
+ TeslemetryVehicleStreamEntity, TeslemetryUpdateEntity, RestoreEntity
+):
+ """Teslemetry Updates entity."""
+
+ _download_percentage: int = 0
+ _install_percentage: int = 0
+
+ def __init__(
+ self,
+ data: TeslemetryVehicleData,
+ scopes: list[Scope],
) -> None:
- """Install an update."""
- self.raise_for_scope(Scope.ENERGY_CMDS)
- await self.wake_up_if_asleep()
- await handle_vehicle_command(self.api.schedule_software_update(offset_sec=60))
- self._attr_in_progress = True
- self._attr_update_percentage = None
+ """Initialize the Update."""
+ self.scoped = Scope.VEHICLE_CMDS in scopes
+ super().__init__(
+ data,
+ "vehicle_state_software_update_status",
+ )
+
+ async def async_added_to_hass(self) -> None:
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ if (state := await self.async_get_last_state()) is not None:
+ self._attr_in_progress = state.attributes.get("in_progress", False)
+ self._install_percentage = state.attributes.get("install_percentage", False)
+ self._attr_installed_version = state.attributes.get("installed_version")
+ self._attr_latest_version = state.attributes.get("latest_version")
+ self._attr_supported_features = UpdateEntityFeature(
+ state.attributes.get(
+ "supported_features", self._attr_supported_features
+ )
+ )
+ self.async_write_ha_state()
+
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_SoftwareUpdateDownloadPercentComplete(
+ self._async_handle_software_update_download_percent_complete
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_SoftwareUpdateInstallationPercentComplete(
+ self._async_handle_software_update_installation_percent_complete
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_SoftwareUpdateScheduledStartTime(
+ self._async_handle_software_update_scheduled_start_time
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_SoftwareUpdateVersion(
+ self._async_handle_software_update_version
+ )
+ )
+ self.async_on_remove(
+ self.vehicle.stream_vehicle.listen_Version(self._async_handle_version)
+ )
+
+ def _async_handle_software_update_download_percent_complete(
+ self, value: float | None
+ ):
+ """Handle software update download percent complete."""
+
+ self._download_percentage = round(value) if value is not None else 0
+ if self.scoped and self._download_percentage == 100:
+ self._attr_supported_features = (
+ UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL
+ )
+ else:
+ self._attr_supported_features = UpdateEntityFeature.PROGRESS
+ self._async_update_progress()
self.async_write_ha_state()
+
+ def _async_handle_software_update_installation_percent_complete(
+ self, value: float | None
+ ):
+ """Handle software update installation percent complete."""
+
+ self._install_percentage = round(value) if value is not None else 0
+ self._async_update_progress()
+ self.async_write_ha_state()
+
+ def _async_handle_software_update_scheduled_start_time(self, value: str | None):
+ """Handle software update scheduled start time."""
+
+ self._attr_in_progress = value is not None
+ self.async_write_ha_state()
+
+ def _async_handle_software_update_version(self, value: str | None):
+ """Handle software update version."""
+
+ self._attr_latest_version = (
+ value if value and value != " " else self._attr_installed_version
+ )
+ self.async_write_ha_state()
+
+ def _async_handle_version(self, value: str | None):
+ """Handle version."""
+
+ if value is not None:
+ self._attr_installed_version = value.split(" ")[0]
+ self.async_write_ha_state()
+
+ def _async_update_progress(self) -> None:
+ """Update the progress of the update."""
+
+ if self._download_percentage > 1 and self._download_percentage < 100:
+ self._attr_in_progress = True
+ self._attr_update_percentage = self._download_percentage
+ elif self._install_percentage > 1:
+ self._attr_in_progress = True
+ self._attr_update_percentage = self._install_percentage
+ else:
+ self._attr_in_progress = False
+ self._attr_update_percentage = None
diff --git a/homeassistant/components/tessie/__init__.py b/homeassistant/components/tessie/__init__.py
index f73ecc7a729..7fd2729ef03 100644
--- a/homeassistant/components/tessie/__init__.py
+++ b/homeassistant/components/tessie/__init__.py
@@ -5,9 +5,14 @@ from http import HTTPStatus
import logging
from aiohttp import ClientError, ClientResponseError
-from tesla_fleet_api import EnergySpecific, Tessie
from tesla_fleet_api.const import Scope
-from tesla_fleet_api.exceptions import TeslaFleetError
+from tesla_fleet_api.exceptions import (
+ Forbidden,
+ InvalidToken,
+ SubscriptionRequired,
+ TeslaFleetError,
+)
+from tesla_fleet_api.tessie import Tessie
from tessie_api import get_state_of_all_vehicles
from homeassistant.config_entries import ConfigEntry
@@ -123,13 +128,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
)
continue
- api = EnergySpecific(tessie.energy, site_id)
+ api = tessie.energySites.create(site_id)
+
+ try:
+ live_status = (await api.live_status())["response"]
+ except (InvalidToken, Forbidden, SubscriptionRequired) as e:
+ raise ConfigEntryAuthFailed from e
+ except TeslaFleetError as e:
+ raise ConfigEntryNotReady(e.message) from e
+
energysites.append(
TessieEnergyData(
api=api,
id=site_id,
- live_coordinator=TessieEnergySiteLiveCoordinator(
- hass, entry, api
+ live_coordinator=(
+ TessieEnergySiteLiveCoordinator(
+ hass, entry, api, live_status
+ )
+ if isinstance(live_status, dict)
+ else None
),
info_coordinator=TessieEnergySiteInfoCoordinator(
hass, entry, api
@@ -147,6 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TessieConfigEntry) -> bo
*(
energysite.live_coordinator.async_config_entry_first_refresh()
for energysite in energysites
+ if energysite.live_coordinator is not None
),
*(
energysite.info_coordinator.async_config_entry_first_refresh()
diff --git a/homeassistant/components/tessie/binary_sensor.py b/homeassistant/components/tessie/binary_sensor.py
index fd6565b62b7..cdf3b0035fc 100644
--- a/homeassistant/components/tessie/binary_sensor.py
+++ b/homeassistant/components/tessie/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TessieConfigEntry
from .const import TessieState
@@ -177,7 +177,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie binary sensor platform from a config entry."""
async_add_entities(
@@ -191,6 +191,7 @@ async def async_setup_entry(
TessieEnergyLiveBinarySensorEntity(energy, description)
for energy in entry.runtime_data.energysites
for description in ENERGY_LIVE_DESCRIPTIONS
+ if energy.live_coordinator is not None
),
(
TessieEnergyInfoBinarySensorEntity(vehicle, description)
@@ -233,6 +234,7 @@ class TessieEnergyLiveBinarySensorEntity(TessieEnergyEntity, BinarySensorEntity)
) -> None:
"""Initialize the binary sensor."""
self.entity_description = description
+ assert data.live_coordinator is not None
super().__init__(data, data.live_coordinator, description.key)
def _async_update_attrs(self) -> None:
diff --git a/homeassistant/components/tessie/button.py b/homeassistant/components/tessie/button.py
index bef9c2585f6..a370f504323 100644
--- a/homeassistant/components/tessie/button.py
+++ b/homeassistant/components/tessie/button.py
@@ -16,7 +16,7 @@ from tessie_api import (
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TessieConfigEntry
from .entity import TessieEntity
@@ -50,7 +50,7 @@ DESCRIPTIONS: tuple[TessieButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie Button platform from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/tessie/climate.py b/homeassistant/components/tessie/climate.py
index 1d26926aeaa..a8aa18132ee 100644
--- a/homeassistant/components/tessie/climate.py
+++ b/homeassistant/components/tessie/climate.py
@@ -19,7 +19,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_HALVES, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TessieConfigEntry
from .const import TessieClimateKeeper
@@ -32,7 +32,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie Climate platform from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/tessie/coordinator.py b/homeassistant/components/tessie/coordinator.py
index b06fe6123a5..8b6fb639a64 100644
--- a/homeassistant/components/tessie/coordinator.py
+++ b/homeassistant/components/tessie/coordinator.py
@@ -8,8 +8,8 @@ import logging
from typing import TYPE_CHECKING, Any
from aiohttp import ClientResponseError
-from tesla_fleet_api import EnergySpecific
from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError
+from tesla_fleet_api.tessie import EnergySite
from tessie_api import get_state, get_status
from homeassistant.core import HomeAssistant
@@ -102,7 +102,11 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]):
config_entry: TessieConfigEntry
def __init__(
- self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific
+ self,
+ hass: HomeAssistant,
+ config_entry: TessieConfigEntry,
+ api: EnergySite,
+ data: dict[str, Any],
) -> None:
"""Initialize Tessie Energy Site Live coordinator."""
super().__init__(
@@ -114,6 +118,12 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
self.api = api
+ # Convert Wall Connectors from array to dict
+ data["wall_connectors"] = {
+ wc["din"]: wc for wc in (data.get("wall_connectors") or [])
+ }
+ self.data = data
+
async def _async_update_data(self) -> dict[str, Any]:
"""Update energy site data using Tessie API."""
@@ -138,7 +148,7 @@ class TessieEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]):
config_entry: TessieConfigEntry
def __init__(
- self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySpecific
+ self, hass: HomeAssistant, config_entry: TessieConfigEntry, api: EnergySite
) -> None:
"""Initialize Tessie Energy Info coordinator."""
super().__init__(
diff --git a/homeassistant/components/tessie/cover.py b/homeassistant/components/tessie/cover.py
index e739f8c074d..bfd7b1b816c 100644
--- a/homeassistant/components/tessie/cover.py
+++ b/homeassistant/components/tessie/cover.py
@@ -22,7 +22,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TessieConfigEntry
from .const import TessieCoverStates
@@ -35,7 +35,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie sensor platform from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/tessie/device_tracker.py b/homeassistant/components/tessie/device_tracker.py
index df74cd2a7a7..fe81ed67337 100644
--- a/homeassistant/components/tessie/device_tracker.py
+++ b/homeassistant/components/tessie/device_tracker.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import TessieConfigEntry
@@ -17,7 +17,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie device tracker platform from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/tessie/diagnostics.py b/homeassistant/components/tessie/diagnostics.py
index bd2db772b57..21fc208612d 100644
--- a/homeassistant/components/tessie/diagnostics.py
+++ b/homeassistant/components/tessie/diagnostics.py
@@ -41,7 +41,9 @@ async def async_get_config_entry_diagnostics(
]
energysites = [
{
- "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT),
+ "live": async_redact_data(x.live_coordinator.data, ENERGY_LIVE_REDACT)
+ if x.live_coordinator
+ else None,
"info": async_redact_data(x.info_coordinator.data, ENERGY_INFO_REDACT),
}
for x in entry.runtime_data.energysites
diff --git a/homeassistant/components/tessie/entity.py b/homeassistant/components/tessie/entity.py
index a2b6d3c9761..fb49d02f42e 100644
--- a/homeassistant/components/tessie/entity.py
+++ b/homeassistant/components/tessie/entity.py
@@ -155,7 +155,7 @@ class TessieWallConnectorEntity(TessieBaseEntity):
via_device=(DOMAIN, str(data.id)),
serial_number=din.split("-")[-1],
)
-
+ assert data.live_coordinator
super().__init__(data.live_coordinator, key)
@property
diff --git a/homeassistant/components/tessie/lock.py b/homeassistant/components/tessie/lock.py
index 76d58a9070c..66cb813b995 100644
--- a/homeassistant/components/tessie/lock.py
+++ b/homeassistant/components/tessie/lock.py
@@ -9,7 +9,7 @@ from tessie_api import lock, open_unlock_charge_port, unlock
from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TessieConfigEntry
from .const import DOMAIN, TessieChargeCableLockStates
@@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie sensor platform from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/tessie/manifest.json b/homeassistant/components/tessie/manifest.json
index ef4d366c779..3f71bcb95e3 100644
--- a/homeassistant/components/tessie/manifest.json
+++ b/homeassistant/components/tessie/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tessie",
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
- "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==0.9.8"]
+ "requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.0.17"]
}
diff --git a/homeassistant/components/tessie/media_player.py b/homeassistant/components/tessie/media_player.py
index 7dfe568926b..139ee07ca5b 100644
--- a/homeassistant/components/tessie/media_player.py
+++ b/homeassistant/components/tessie/media_player.py
@@ -8,7 +8,7 @@ from homeassistant.components.media_player import (
MediaPlayerState,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TessieConfigEntry
from .entity import TessieEntity
@@ -26,7 +26,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie Media platform from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/tessie/models.py b/homeassistant/components/tessie/models.py
index ca670b9650b..5330d2d0bf0 100644
--- a/homeassistant/components/tessie/models.py
+++ b/homeassistant/components/tessie/models.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from dataclasses import dataclass
-from tesla_fleet_api import EnergySpecific
+from tesla_fleet_api.tessie import EnergySite
from homeassistant.helpers.device_registry import DeviceInfo
@@ -27,8 +27,8 @@ class TessieData:
class TessieEnergyData:
"""Data for a Energy Site in the Tessie integration."""
- api: EnergySpecific
- live_coordinator: TessieEnergySiteLiveCoordinator
+ api: EnergySite
+ live_coordinator: TessieEnergySiteLiveCoordinator | None
info_coordinator: TessieEnergySiteInfoCoordinator
id: int
device: DeviceInfo
diff --git a/homeassistant/components/tessie/number.py b/homeassistant/components/tessie/number.py
index 74249d392a7..77d8037fb14 100644
--- a/homeassistant/components/tessie/number.py
+++ b/homeassistant/components/tessie/number.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from itertools import chain
from typing import Any
-from tesla_fleet_api import EnergySpecific
+from tesla_fleet_api.tessie import EnergySite
from tessie_api import set_charge_limit, set_charging_amps, set_speed_limit
from homeassistant.components.number import (
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfSpeed,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.icon import icon_for_battery_level
from . import TessieConfigEntry
@@ -90,7 +90,7 @@ VEHICLE_DESCRIPTIONS: tuple[TessieNumberEntityDescription, ...] = (
class TessieNumberBatteryEntityDescription(NumberEntityDescription):
"""Describes Tessie Number entity."""
- func: Callable[[EnergySpecific, float], Awaitable[Any]]
+ func: Callable[[EnergySite, float], Awaitable[Any]]
requires: str
@@ -111,7 +111,7 @@ ENERGY_INFO_DESCRIPTIONS: tuple[TessieNumberBatteryEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie sensor platform from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/tessie/select.py b/homeassistant/components/tessie/select.py
index 4dfe7088439..471372a68bd 100644
--- a/homeassistant/components/tessie/select.py
+++ b/homeassistant/components/tessie/select.py
@@ -9,7 +9,7 @@ from tessie_api import set_seat_cool, set_seat_heat
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TessieConfigEntry
from .const import TessieSeatCoolerOptions, TessieSeatHeaterOptions
@@ -38,7 +38,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie select platform from a config entry."""
diff --git a/homeassistant/components/tessie/sensor.py b/homeassistant/components/tessie/sensor.py
index 323fa76ef1f..52accb15575 100644
--- a/homeassistant/components/tessie/sensor.py
+++ b/homeassistant/components/tessie/sensor.py
@@ -28,7 +28,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
from homeassistant.util.variance import ignore_variance
@@ -148,7 +148,7 @@ DESCRIPTIONS: tuple[TessieSensorEntityDescription, ...] = (
key="drive_state_shift_state",
options=["p", "d", "r", "n"],
device_class=SensorDeviceClass.ENUM,
- value_fn=lambda x: x.lower() if isinstance(x, str) else x,
+ value_fn=lambda x: x.lower() if isinstance(x, str) else "p",
),
TessieSensorEntityDescription(
key="vehicle_state_odometer",
@@ -375,7 +375,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie sensor platform from a config entry."""
@@ -396,11 +396,16 @@ async def async_setup_entry(
TessieEnergyLiveSensorEntity(energysite, description)
for energysite in entry.runtime_data.energysites
for description in ENERGY_LIVE_DESCRIPTIONS
- if description.key in energysite.live_coordinator.data
+ if energysite.live_coordinator is not None
+ and (
+ description.key in energysite.live_coordinator.data
+ or description.key == "percentage_charged"
+ )
),
( # Add wall connectors
TessieWallConnectorSensorEntity(energysite, din, description)
for energysite in entry.runtime_data.energysites
+ if energysite.live_coordinator is not None
for din in energysite.live_coordinator.data.get("wall_connectors", {})
for description in WALL_CONNECTOR_DESCRIPTIONS
),
@@ -445,11 +450,11 @@ class TessieEnergyLiveSensorEntity(TessieEnergyEntity, SensorEntity):
) -> None:
"""Initialize the sensor."""
self.entity_description = description
+ assert data.live_coordinator is not None
super().__init__(data, data.live_coordinator, description.key)
def _async_update_attrs(self) -> None:
"""Update the attributes of the sensor."""
- self._attr_available = self._value is not None
self._attr_native_value = self.entity_description.value_fn(self._value)
diff --git a/homeassistant/components/tessie/strings.json b/homeassistant/components/tessie/strings.json
index 8384bb3d8fb..1c0ec7ecc80 100644
--- a/homeassistant/components/tessie/strings.json
+++ b/homeassistant/components/tessie/strings.json
@@ -48,7 +48,7 @@
"state_attributes": {
"preset_mode": {
"state": {
- "off": "Normal",
+ "off": "[%key:common::state::normal%]",
"on": "Keep mode",
"dog": "Dog mode",
"camp": "Camp mode"
@@ -75,9 +75,9 @@
"name": "Charging",
"state": {
"starting": "Starting",
- "charging": "Charging",
- "disconnected": "Disconnected",
- "stopped": "Stopped",
+ "charging": "[%key:common::state::charging%]",
+ "disconnected": "[%key:common::state::disconnected%]",
+ "stopped": "[%key:common::state::stopped%]",
"complete": "Complete",
"no_power": "No power"
}
@@ -212,7 +212,7 @@
"name": "State",
"state": {
"booting": "Booting",
- "charging": "[%key:component::tessie::entity::sensor::charge_state_charging_state::state::charging%]",
+ "charging": "[%key:common::state::charging%]",
"disconnected": "[%key:common::state::disconnected%]",
"connected": "[%key:common::state::connected%]",
"scheduled": "Scheduled",
@@ -246,81 +246,81 @@
"name": "Seat heater left",
"state": {
"off": "[%key:common::state::off%]",
- "low": "Low",
- "medium": "Medium",
- "high": "High"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
},
"climate_state_seat_heater_right": {
"name": "Seat heater right",
"state": {
"off": "[%key:common::state::off%]",
- "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]",
- "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
},
"climate_state_seat_heater_rear_left": {
"name": "Seat heater rear left",
"state": {
"off": "[%key:common::state::off%]",
- "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]",
- "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
},
"climate_state_seat_heater_rear_center": {
"name": "Seat heater rear center",
"state": {
"off": "[%key:common::state::off%]",
- "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]",
- "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
},
"climate_state_seat_heater_rear_right": {
"name": "Seat heater rear right",
"state": {
"off": "[%key:common::state::off%]",
- "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]",
- "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
},
"climate_state_seat_heater_third_row_left": {
"name": "Seat heater third row left",
"state": {
"off": "[%key:common::state::off%]",
- "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]",
- "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
},
"climate_state_seat_heater_third_row_right": {
"name": "Seat heater third row right",
"state": {
"off": "[%key:common::state::off%]",
- "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]",
- "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
},
"climate_state_seat_fan_front_left": {
"name": "Seat cooler left",
"state": {
"off": "[%key:common::state::off%]",
- "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]",
- "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
},
"climate_state_seat_fan_front_right": {
"name": "Seat cooler right",
"state": {
"off": "[%key:common::state::off%]",
- "low": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::low%]",
- "medium": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::medium%]",
- "high": "[%key:component::tessie::entity::select::climate_state_seat_heater_left::state::high%]"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
},
"components_customer_preferred_export_rule": {
@@ -506,7 +506,7 @@
},
"exceptions": {
"unknown": {
- "message": "An unknown issue occured changing {name}."
+ "message": "An unknown issue occurred changing {name}."
},
"not_supported": {
"message": "{name} is not supported."
diff --git a/homeassistant/components/tessie/switch.py b/homeassistant/components/tessie/switch.py
index dba00a85bb2..41134b38fda 100644
--- a/homeassistant/components/tessie/switch.py
+++ b/homeassistant/components/tessie/switch.py
@@ -26,7 +26,7 @@ from homeassistant.components.switch import (
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import TessieConfigEntry
@@ -81,7 +81,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie Switch platform from a config entry."""
diff --git a/homeassistant/components/tessie/update.py b/homeassistant/components/tessie/update.py
index f6198fa6c03..e9af673b1f4 100644
--- a/homeassistant/components/tessie/update.py
+++ b/homeassistant/components/tessie/update.py
@@ -8,7 +8,7 @@ from tessie_api import schedule_software_update
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TessieConfigEntry
from .const import TessieUpdateStatus
@@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TessieConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tessie Update platform from a config entry."""
data = entry.runtime_data
diff --git a/homeassistant/components/thermador/__init__.py b/homeassistant/components/thermador/__init__.py
new file mode 100644
index 00000000000..2bd83b2ff71
--- /dev/null
+++ b/homeassistant/components/thermador/__init__.py
@@ -0,0 +1 @@
+"""Thermador virtual integration."""
diff --git a/homeassistant/components/thermador/manifest.json b/homeassistant/components/thermador/manifest.json
new file mode 100644
index 00000000000..b09861623de
--- /dev/null
+++ b/homeassistant/components/thermador/manifest.json
@@ -0,0 +1,6 @@
+{
+ "domain": "thermador",
+ "name": "Thermador",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+}
diff --git a/homeassistant/components/thermobeacon/config_flow.py b/homeassistant/components/thermobeacon/config_flow.py
index 08994a41008..6fa502716ca 100644
--- a/homeassistant/components/thermobeacon/config_flow.py
+++ b/homeassistant/components/thermobeacon/config_flow.py
@@ -72,7 +72,7 @@ class ThermoBeaconConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
- current_addresses = self._async_current_ids()
+ current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
diff --git a/homeassistant/components/thermobeacon/manifest.json b/homeassistant/components/thermobeacon/manifest.json
index ce6a3f71ef3..b231137d335 100644
--- a/homeassistant/components/thermobeacon/manifest.json
+++ b/homeassistant/components/thermobeacon/manifest.json
@@ -14,6 +14,12 @@
"manufacturer_data_start": [0],
"connectable": false
},
+ {
+ "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb",
+ "manufacturer_id": 20,
+ "manufacturer_data_start": [0],
+ "connectable": false
+ },
{
"service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb",
"manufacturer_id": 21,
@@ -48,5 +54,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/thermobeacon",
"iot_class": "local_push",
- "requirements": ["thermobeacon-ble==0.7.0"]
+ "requirements": ["thermobeacon-ble==0.8.1"]
}
diff --git a/homeassistant/components/thermobeacon/sensor.py b/homeassistant/components/thermobeacon/sensor.py
index 53e86f37f11..916ec91359a 100644
--- a/homeassistant/components/thermobeacon/sensor.py
+++ b/homeassistant/components/thermobeacon/sensor.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
@@ -113,7 +113,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ThermoBeacon BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/thermopro/__init__.py b/homeassistant/components/thermopro/__init__.py
index 2cd207818c5..742449cffbe 100644
--- a/homeassistant/components/thermopro/__init__.py
+++ b/homeassistant/components/thermopro/__init__.py
@@ -2,25 +2,47 @@
from __future__ import annotations
+from functools import partial
import logging
-from thermopro_ble import ThermoProBluetoothDeviceData
+from thermopro_ble import SensorUpdate, ThermoProBluetoothDeviceData
-from homeassistant.components.bluetooth import BluetoothScanningMode
+from homeassistant.components.bluetooth import (
+ BluetoothScanningMode,
+ BluetoothServiceInfoBleak,
+)
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
-from .const import DOMAIN
+from .const import DOMAIN, SIGNAL_DATA_UPDATED
-PLATFORMS: list[Platform] = [Platform.SENSOR]
+CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
+
+PLATFORMS: list[Platform] = [Platform.BUTTON, Platform.SENSOR]
_LOGGER = logging.getLogger(__name__)
+def process_service_info(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ data: ThermoProBluetoothDeviceData,
+ service_info: BluetoothServiceInfoBleak,
+) -> SensorUpdate:
+ """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data."""
+ update = data.update(service_info)
+ async_dispatcher_send(
+ hass, f"{SIGNAL_DATA_UPDATED}_{entry.entry_id}", data, service_info, update
+ )
+ return update
+
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up ThermoPro BLE device from a config entry."""
address = entry.unique_id
@@ -32,13 +54,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
- update_method=data.update,
+ update_method=partial(process_service_info, hass, entry, data),
)
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
- entry.async_on_unload(
- coordinator.async_start()
- ) # only start after all platforms have had a chance to subscribe
+ # only start after all platforms have had a chance to subscribe
+ entry.async_on_unload(coordinator.async_start())
return True
diff --git a/homeassistant/components/thermopro/button.py b/homeassistant/components/thermopro/button.py
new file mode 100644
index 00000000000..9faa9f22c4c
--- /dev/null
+++ b/homeassistant/components/thermopro/button.py
@@ -0,0 +1,157 @@
+"""Thermopro button platform."""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Coroutine
+from dataclasses import dataclass
+from typing import Any
+
+from thermopro_ble import SensorUpdate, ThermoProBluetoothDeviceData, ThermoProDevice
+
+from homeassistant.components.bluetooth import (
+ BluetoothServiceInfoBleak,
+ async_ble_device_from_address,
+ async_track_unavailable,
+)
+from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EntityCategory
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect,
+ async_dispatcher_send,
+)
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+from homeassistant.util.dt import now
+
+from .const import DOMAIN, SIGNAL_AVAILABILITY_UPDATED, SIGNAL_DATA_UPDATED
+
+PARALLEL_UPDATES = 1 # one connection at a time
+
+
+@dataclass(kw_only=True, frozen=True)
+class ThermoProButtonEntityDescription(ButtonEntityDescription):
+ """Describe a ThermoPro button entity."""
+
+ press_action_fn: Callable[[HomeAssistant, str], Coroutine[None, Any, Any]]
+
+
+async def _async_set_datetime(hass: HomeAssistant, address: str) -> None:
+ """Set Date&Time for a given device."""
+ ble_device = async_ble_device_from_address(hass, address, connectable=True)
+ assert ble_device is not None
+ await ThermoProDevice(ble_device).set_datetime(now(), am_pm=False)
+
+
+BUTTON_ENTITIES: tuple[ThermoProButtonEntityDescription, ...] = (
+ ThermoProButtonEntityDescription(
+ key="datetime",
+ translation_key="set_datetime",
+ icon="mdi:calendar-clock",
+ entity_category=EntityCategory.CONFIG,
+ press_action_fn=_async_set_datetime,
+ ),
+)
+
+MODELS_THAT_SUPPORT_BUTTONS = {"TP358", "TP393"}
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up the thermopro button platform."""
+ address = entry.unique_id
+ assert address is not None
+ availability_signal = f"{SIGNAL_AVAILABILITY_UPDATED}_{entry.entry_id}"
+ entity_added = False
+
+ @callback
+ def _async_on_data_updated(
+ data: ThermoProBluetoothDeviceData,
+ service_info: BluetoothServiceInfoBleak,
+ update: SensorUpdate,
+ ) -> None:
+ nonlocal entity_added
+ sensor_device_info = update.devices[data.primary_device_id]
+ if sensor_device_info.model not in MODELS_THAT_SUPPORT_BUTTONS:
+ return
+
+ if not entity_added:
+ name = sensor_device_info.name
+ assert name is not None
+ entity_added = True
+ async_add_entities(
+ ThermoProButtonEntity(
+ description=description,
+ data=data,
+ availability_signal=availability_signal,
+ address=address,
+ )
+ for description in BUTTON_ENTITIES
+ )
+
+ if service_info.connectable:
+ async_dispatcher_send(hass, availability_signal, True)
+
+ entry.async_on_unload(
+ async_dispatcher_connect(
+ hass, f"{SIGNAL_DATA_UPDATED}_{entry.entry_id}", _async_on_data_updated
+ )
+ )
+
+
+class ThermoProButtonEntity(ButtonEntity):
+ """Representation of a ThermoPro button entity."""
+
+ _attr_has_entity_name = True
+ entity_description: ThermoProButtonEntityDescription
+
+ def __init__(
+ self,
+ description: ThermoProButtonEntityDescription,
+ data: ThermoProBluetoothDeviceData,
+ availability_signal: str,
+ address: str,
+ ) -> None:
+ """Initialize the thermopro button entity."""
+ self.entity_description = description
+ self._address = address
+ self._availability_signal = availability_signal
+ self._attr_unique_id = f"{address}-{description.key}"
+ self._attr_device_info = dr.DeviceInfo(
+ name=data.get_device_name(),
+ identifiers={(DOMAIN, address)},
+ connections={(dr.CONNECTION_BLUETOOTH, address)},
+ )
+
+ async def async_added_to_hass(self) -> None:
+ """Connect availability dispatcher."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ self._availability_signal,
+ self._async_on_availability_changed,
+ )
+ )
+ self.async_on_remove(
+ async_track_unavailable(
+ self.hass, self._async_on_unavailable, self._address, connectable=True
+ )
+ )
+
+ @callback
+ def _async_on_unavailable(self, _: BluetoothServiceInfoBleak) -> None:
+ self._async_on_availability_changed(False)
+
+ @callback
+ def _async_on_availability_changed(self, available: bool) -> None:
+ self._attr_available = available
+ self.async_write_ha_state()
+
+ async def async_press(self) -> None:
+ """Execute the press action for the entity."""
+ await self.entity_description.press_action_fn(self.hass, self._address)
diff --git a/homeassistant/components/thermopro/const.py b/homeassistant/components/thermopro/const.py
index 343729442cf..7d2170f8cf9 100644
--- a/homeassistant/components/thermopro/const.py
+++ b/homeassistant/components/thermopro/const.py
@@ -1,3 +1,6 @@
"""Constants for the ThermoPro Bluetooth integration."""
DOMAIN = "thermopro"
+
+SIGNAL_DATA_UPDATED = f"{DOMAIN}_service_info_updated"
+SIGNAL_AVAILABILITY_UPDATED = f"{DOMAIN}_availability_updated"
diff --git a/homeassistant/components/thermopro/sensor.py b/homeassistant/components/thermopro/sensor.py
index 4aca6101685..853f00f2dd5 100644
--- a/homeassistant/components/thermopro/sensor.py
+++ b/homeassistant/components/thermopro/sensor.py
@@ -9,7 +9,6 @@ from thermopro_ble import (
Units,
)
-from homeassistant import config_entries
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
@@ -23,6 +22,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
@@ -30,7 +30,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
@@ -110,8 +110,8 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
- entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ThermoPro BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json
index 4e12a84b653..5789de410b2 100644
--- a/homeassistant/components/thermopro/strings.json
+++ b/homeassistant/components/thermopro/strings.json
@@ -17,5 +17,12 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
+ },
+ "entity": {
+ "button": {
+ "set_datetime": {
+ "name": "Set Date&Time"
+ }
+ }
}
}
diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py
index 25dd2f1e1eb..5aa851d99ae 100644
--- a/homeassistant/components/thethingsnetwork/sensor.py
+++ b/homeassistant/components/thethingsnetwork/sensor.py
@@ -7,7 +7,7 @@ from ttn_client import TTNSensorValue
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import CONF_APP_ID, DOMAIN
@@ -17,7 +17,9 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add entities for TTN."""
@@ -38,7 +40,7 @@ async def async_setup_entry(
if (device_id, field_id) not in sensors
and isinstance(ttn_value, TTNSensorValue)
}
- if len(new_sensors):
+ if new_sensors:
async_add_entities(new_sensors.values())
sensors.update(new_sensors.keys())
diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py
index 3d52d2225be..3227f030812 100644
--- a/homeassistant/components/threshold/binary_sensor.py
+++ b/homeassistant/components/threshold/binary_sensor.py
@@ -33,7 +33,10 @@ from homeassistant.core import (
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -90,7 +93,7 @@ PLATFORM_SCHEMA = vol.All(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize threshold config entry."""
registry = er.async_get(hass)
diff --git a/homeassistant/components/tibber/coordinator.py b/homeassistant/components/tibber/coordinator.py
index 2de9ebd1ec6..e565fdc7dd8 100644
--- a/homeassistant/components/tibber/coordinator.py
+++ b/homeassistant/components/tibber/coordinator.py
@@ -9,7 +9,11 @@ from typing import cast
import tibber
from homeassistant.components.recorder import get_instance
-from homeassistant.components.recorder.models import StatisticData, StatisticMetaData
+from homeassistant.components.recorder.models import (
+ StatisticData,
+ StatisticMeanType,
+ StatisticMetaData,
+)
from homeassistant.components.recorder.statistics import (
async_add_external_statistics,
get_last_statistics,
@@ -159,7 +163,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
)
metadata = StatisticMetaData(
- has_mean=False,
+ mean_type=StatisticMeanType.NONE,
has_sum=True,
name=f"{home.name} {sensor_type}",
source=TIBBER_DOMAIN,
diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py
index fdeeeba68ef..df6541591e0 100644
--- a/homeassistant/components/tibber/notify.py
+++ b/homeassistant/components/tibber/notify.py
@@ -12,13 +12,15 @@ from homeassistant.components.notify import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN as TIBBER_DOMAIN
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber notification entity."""
async_add_entities([TibberNotificationEntity(entry.entry_id)])
diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py
index c14a62bb608..9f87b8a8490 100644
--- a/homeassistant/components/tibber/sensor.py
+++ b/homeassistant/components/tibber/sensor.py
@@ -33,7 +33,7 @@ from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
@@ -261,7 +261,9 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber sensor."""
@@ -531,7 +533,7 @@ class TibberRtEntityCreator:
def __init__(
self,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
tibber_home: tibber.TibberHome,
entity_registry: er.EntityRegistry,
) -> None:
diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json
index 05b98b97995..ec2c005d4e3 100644
--- a/homeassistant/components/tibber/strings.json
+++ b/homeassistant/components/tibber/strings.json
@@ -87,7 +87,7 @@
"services": {
"get_prices": {
"name": "Get energy prices",
- "description": "Get hourly energy prices from Tibber",
+ "description": "Fetches hourly energy prices including price level.",
"fields": {
"start": {
"name": "Start",
diff --git a/homeassistant/components/tile/binary_sensor.py b/homeassistant/components/tile/binary_sensor.py
index 1719c793c0e..6abc80732a6 100644
--- a/homeassistant/components/tile/binary_sensor.py
+++ b/homeassistant/components/tile/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TileConfigEntry, TileCoordinator
from .entity import TileEntity
@@ -35,7 +35,9 @@ ENTITIES: tuple[TileBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: TileConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TileConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tile binary sensors."""
diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py
index 6a0aae1bdf9..66a3b8b0e27 100644
--- a/homeassistant/components/tile/device_tracker.py
+++ b/homeassistant/components/tile/device_tracker.py
@@ -6,7 +6,7 @@ import logging
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import as_utc
from .coordinator import TileConfigEntry, TileCoordinator
@@ -26,7 +26,9 @@ ATTR_VOIP_STATE = "voip_state"
async def async_setup_entry(
- hass: HomeAssistant, entry: TileConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TileConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tile device trackers."""
diff --git a/homeassistant/components/tilt_ble/sensor.py b/homeassistant/components/tilt_ble/sensor.py
index e8e1f902cd9..411484cf2fe 100644
--- a/homeassistant/components/tilt_ble/sensor.py
+++ b/homeassistant/components/tilt_ble/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .const import DOMAIN
@@ -86,7 +86,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tilt Hydrometer BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py
index 60e55c214fe..1e3c37b55b3 100644
--- a/homeassistant/components/time/__init__.py
+++ b/homeassistant/components/time/__init__.py
@@ -72,7 +72,7 @@ class TimeEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Representation of a Time entity."""
entity_description: TimeEntityDescription
- _attr_native_value: time | None
+ _attr_native_value: time | None = None
_attr_device_class: None = None
_attr_state: None = None
diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py
index 1e86a1ba6c6..f05244e7680 100644
--- a/homeassistant/components/time_date/sensor.py
+++ b/homeassistant/components/time_date/sensor.py
@@ -18,7 +18,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE
from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -56,7 +59,9 @@ async def async_setup_platform(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Time & Date sensor."""
diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py
index b0ade17b9c9..3cf8307e9b3 100644
--- a/homeassistant/components/timer/__init__.py
+++ b/homeassistant/components/timer/__init__.py
@@ -374,6 +374,9 @@ class Timer(collection.CollectionEntity, RestoreEntity):
@callback
def async_cancel(self) -> None:
"""Cancel a timer."""
+ if self._state == STATUS_IDLE:
+ return
+
if self._listener:
self._listener()
self._listener = None
@@ -389,13 +392,15 @@ class Timer(collection.CollectionEntity, RestoreEntity):
@callback
def async_finish(self) -> None:
"""Reset and updates the states, fire finished event."""
- if self._state != STATUS_ACTIVE or self._end is None:
+ if self._state == STATUS_IDLE:
return
if self._listener:
self._listener()
self._listener = None
end = self._end
+ if end is None:
+ end = dt_util.utcnow().replace(microsecond=0)
self._state = STATUS_IDLE
self._end = None
self._remaining = None
diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py
index 3ac90b5578c..1ab34861a6e 100644
--- a/homeassistant/components/tod/binary_sensor.py
+++ b/homeassistant/components/tod/binary_sensor.py
@@ -24,7 +24,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, event
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.sun import get_astral_event_date, get_astral_event_next
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util
@@ -59,7 +62,7 @@ PLATFORM_SCHEMA = BINARY_SENSOR_PLATFORM_SCHEMA.extend(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Times of the Day config entry."""
if hass.config.time_zone is None:
diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py
index 937187c1c6f..b8c90f917d4 100644
--- a/homeassistant/components/todo/__init__.py
+++ b/homeassistant/components/todo/__init__.py
@@ -129,7 +129,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
vol.All(
cv.make_entity_service_schema(
{
- vol.Required(ATTR_ITEM): vol.All(cv.string, vol.Length(min=1)),
+ vol.Required(ATTR_ITEM): vol.All(
+ cv.string, str.strip, vol.Length(min=1)
+ ),
**TODO_ITEM_FIELD_SCHEMA,
}
),
@@ -144,7 +146,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
cv.make_entity_service_schema(
{
vol.Required(ATTR_ITEM): vol.All(cv.string, vol.Length(min=1)),
- vol.Optional(ATTR_RENAME): vol.All(cv.string, vol.Length(min=1)),
+ vol.Optional(ATTR_RENAME): vol.All(
+ cv.string, str.strip, vol.Length(min=1)
+ ),
vol.Optional(ATTR_STATUS): vol.In(
{TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED},
),
@@ -219,18 +223,10 @@ class TodoItem:
"""A status or confirmation of the To-do item."""
due: datetime.date | datetime.datetime | None = None
- """The date and time that a to-do is expected to be completed.
-
- This field may be a date or datetime depending whether the entity feature
- DUE_DATE or DUE_DATETIME are set.
- """
+ """The date and time that a to-do is expected to be completed."""
description: str | None = None
- """A more complete description of than that provided by the summary.
-
- This field may be set when TodoListEntityFeature.DESCRIPTION is supported by
- the entity.
- """
+ """A more complete description than that provided by the summary."""
CACHED_PROPERTIES_WITH_ATTR_ = {
diff --git a/homeassistant/components/todo/intent.py b/homeassistant/components/todo/intent.py
index c678408a576..d679a57bf96 100644
--- a/homeassistant/components/todo/intent.py
+++ b/homeassistant/components/todo/intent.py
@@ -11,11 +11,13 @@ from . import TodoItem, TodoItemStatus, TodoListEntity
from .const import DATA_COMPONENT, DOMAIN
INTENT_LIST_ADD_ITEM = "HassListAddItem"
+INTENT_LIST_COMPLETE_ITEM = "HassListCompleteItem"
async def async_setup_intents(hass: HomeAssistant) -> None:
"""Set up the todo intents."""
intent.async_register(hass, ListAddItemIntent())
+ intent.async_register(hass, ListCompleteItemIntent())
class ListAddItemIntent(intent.IntentHandler):
@@ -53,14 +55,92 @@ class ListAddItemIntent(intent.IntentHandler):
match_result.states[0].entity_id
)
if target_list is None:
- raise intent.IntentHandleError(f"No to-do list: {list_name}")
+ raise intent.IntentHandleError(
+ f"No to-do list: {list_name}", "list_not_found"
+ )
# Add to list
await target_list.async_create_todo_item(
TodoItem(summary=item, status=TodoItemStatus.NEEDS_ACTION)
)
- response = intent_obj.create_response()
+ response: intent.IntentResponse = intent_obj.create_response()
+ response.response_type = intent.IntentResponseType.ACTION_DONE
+ response.async_set_results(
+ [
+ intent.IntentResponseTarget(
+ type=intent.IntentResponseTargetType.ENTITY,
+ name=list_name,
+ id=match_result.states[0].entity_id,
+ )
+ ]
+ )
+ return response
+
+
+class ListCompleteItemIntent(intent.IntentHandler):
+ """Handle ListCompleteItem intents."""
+
+ intent_type = INTENT_LIST_COMPLETE_ITEM
+ description = "Complete item on a todo list"
+ slot_schema = {
+ vol.Required("item"): intent.non_empty_string,
+ vol.Required("name"): intent.non_empty_string,
+ }
+ platforms = {DOMAIN}
+
+ async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse:
+ """Handle the intent."""
+ hass = intent_obj.hass
+
+ slots = self.async_validate_slots(intent_obj.slots)
+ item = slots["item"]["value"]
+ list_name = slots["name"]["value"]
+
+ target_list: TodoListEntity | None = None
+
+ # Find matching list
+ match_constraints = intent.MatchTargetsConstraints(
+ name=list_name, domains=[DOMAIN], assistant=intent_obj.assistant
+ )
+ match_result = intent.async_match_targets(hass, match_constraints)
+ if not match_result.is_match:
+ raise intent.MatchFailedError(
+ result=match_result, constraints=match_constraints
+ )
+
+ target_list = hass.data[DATA_COMPONENT].get_entity(
+ match_result.states[0].entity_id
+ )
+ if target_list is None:
+ raise intent.IntentHandleError(
+ f"No to-do list: {list_name}", "list_not_found"
+ )
+
+ # Find item in list
+ matching_item = None
+ for todo_item in target_list.todo_items or ():
+ if (
+ item in (todo_item.uid, todo_item.summary)
+ and todo_item.status == TodoItemStatus.NEEDS_ACTION
+ ):
+ matching_item = todo_item
+ break
+ if not matching_item or not matching_item.uid:
+ raise intent.IntentHandleError(
+ f"Item '{item}' not found on list", "item_not_found"
+ )
+
+ # Mark as completed
+ await target_list.async_update_todo_item(
+ TodoItem(
+ uid=matching_item.uid,
+ summary=matching_item.summary,
+ status=TodoItemStatus.COMPLETED,
+ )
+ )
+
+ response: intent.IntentResponse = intent_obj.create_response()
response.response_type = intent.IntentResponseType.ACTION_DONE
response.async_set_results(
[
diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py
index 8c61394d300..2e2873353c6 100644
--- a/homeassistant/components/todoist/calendar.py
+++ b/homeassistant/components/todoist/calendar.py
@@ -24,7 +24,10 @@ from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -113,7 +116,9 @@ SCAN_INTERVAL = timedelta(minutes=1)
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Todoist calendar platform config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py
index 490e4ad9f1a..202c51fb4c0 100644
--- a/homeassistant/components/todoist/todo.py
+++ b/homeassistant/components/todoist/todo.py
@@ -14,7 +14,7 @@ from homeassistant.components.todo import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -23,7 +23,9 @@ from .coordinator import TodoistCoordinator
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Todoist todo platform config entry."""
coordinator: TodoistCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tolo/binary_sensor.py b/homeassistant/components/tolo/binary_sensor.py
index 845f8ed22e3..cb3ba46b604 100644
--- a/homeassistant/components/tolo/binary_sensor.py
+++ b/homeassistant/components/tolo/binary_sensor.py
@@ -7,7 +7,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ToloSaunaUpdateCoordinator
@@ -17,7 +17,7 @@ from .entity import ToloSaunaCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors for TOLO Sauna."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tolo/button.py b/homeassistant/components/tolo/button.py
index b7c4362ca7b..9e4c8c84be9 100644
--- a/homeassistant/components/tolo/button.py
+++ b/homeassistant/components/tolo/button.py
@@ -6,7 +6,7 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ToloSaunaUpdateCoordinator
@@ -16,7 +16,7 @@ from .entity import ToloSaunaCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up buttons for TOLO Sauna."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tolo/climate.py b/homeassistant/components/tolo/climate.py
index 5e6428525c1..0df8635fca9 100644
--- a/homeassistant/components/tolo/climate.py
+++ b/homeassistant/components/tolo/climate.py
@@ -23,7 +23,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_WHOLE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ToloSaunaUpdateCoordinator
@@ -33,7 +33,7 @@ from .entity import ToloSaunaCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up climate controls for TOLO Sauna."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tolo/fan.py b/homeassistant/components/tolo/fan.py
index 9e48778b507..7bddf775143 100644
--- a/homeassistant/components/tolo/fan.py
+++ b/homeassistant/components/tolo/fan.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ToloSaunaUpdateCoordinator
@@ -17,7 +17,7 @@ from .entity import ToloSaunaCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up fan controls for TOLO Sauna."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tolo/light.py b/homeassistant/components/tolo/light.py
index eeb37305fe8..9ccd4a8e407 100644
--- a/homeassistant/components/tolo/light.py
+++ b/homeassistant/components/tolo/light.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ToloSaunaUpdateCoordinator
@@ -17,7 +17,7 @@ from .entity import ToloSaunaCoordinatorEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up light controls for TOLO Sauna."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py
index 73505c5b251..902fb749d23 100644
--- a/homeassistant/components/tolo/number.py
+++ b/homeassistant/components/tolo/number.py
@@ -18,7 +18,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ToloSaunaUpdateCoordinator
@@ -68,7 +68,7 @@ NUMBERS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number controls for TOLO Sauna."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tolo/select.py b/homeassistant/components/tolo/select.py
index fee1ac1774e..b08f37e40ae 100644
--- a/homeassistant/components/tolo/select.py
+++ b/homeassistant/components/tolo/select.py
@@ -11,7 +11,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, AromaTherapySlot, LampMode
from .coordinator import ToloSaunaUpdateCoordinator
@@ -54,7 +54,7 @@ SELECTS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up select entities for TOLO Sauna."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tolo/sensor.py b/homeassistant/components/tolo/sensor.py
index 0e94ec0ae1e..e97211c8e40 100644
--- a/homeassistant/components/tolo/sensor.py
+++ b/homeassistant/components/tolo/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ToloSaunaUpdateCoordinator
@@ -89,7 +89,7 @@ SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up (non-binary, general) sensors for TOLO Sauna."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tolo/strings.json b/homeassistant/components/tolo/strings.json
index c55498b8d92..82b6ecee9e7 100644
--- a/homeassistant/components/tolo/strings.json
+++ b/homeassistant/components/tolo/strings.json
@@ -59,7 +59,7 @@
"name": "Lamp mode",
"state": {
"automatic": "Automatic",
- "manual": "Manual"
+ "manual": "[%key:common::state::manual%]"
}
},
"aroma_therapy_slot": {
diff --git a/homeassistant/components/tolo/switch.py b/homeassistant/components/tolo/switch.py
index d39dd17f0f3..ce863053e26 100644
--- a/homeassistant/components/tolo/switch.py
+++ b/homeassistant/components/tolo/switch.py
@@ -11,7 +11,7 @@ from tololib import ToloClient, ToloStatus
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ToloSaunaUpdateCoordinator
@@ -45,7 +45,7 @@ SWITCHES = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch controls for TOLO Sauna."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py
index 7ff17961b58..08e1991d831 100644
--- a/homeassistant/components/tomorrowio/sensor.py
+++ b/homeassistant/components/tomorrowio/sensor.py
@@ -34,7 +34,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
@@ -328,7 +328,7 @@ SENSOR_TYPES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a config entry."""
coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]]
diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json
index 03a8a169920..c3f52155d29 100644
--- a/homeassistant/components/tomorrowio/strings.json
+++ b/homeassistant/components/tomorrowio/strings.json
@@ -115,33 +115,33 @@
"name": "Tree pollen index",
"state": {
"none": "None",
- "very_low": "Very low",
- "low": "Low",
- "medium": "Medium",
- "high": "High",
- "very_high": "Very high"
+ "very_low": "[%key:common::state::very_low%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
+ "very_high": "[%key:common::state::very_high%]"
}
},
"weed_pollen_index": {
"name": "Weed pollen index",
"state": {
"none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]",
- "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]",
- "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]",
- "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]",
- "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]",
- "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]"
+ "very_low": "[%key:common::state::very_low%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
+ "very_high": "[%key:common::state::very_high%]"
}
},
"grass_pollen_index": {
"name": "Grass pollen index",
"state": {
"none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]",
- "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]",
- "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]",
- "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]",
- "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]",
- "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]"
+ "very_low": "[%key:common::state::very_low%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
+ "very_high": "[%key:common::state::very_high%]"
}
},
"fire_index": {
@@ -153,10 +153,10 @@
"uv_radiation_health_concern": {
"name": "UV radiation health concern",
"state": {
- "low": "Low",
+ "low": "[%key:common::state::low%]",
"moderate": "Moderate",
- "high": "High",
- "very_high": "Very high",
+ "high": "[%key:common::state::high%]",
+ "very_high": "[%key:common::state::very_high%]",
"extreme": "Extreme"
}
}
diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py
index 92b09500e7b..0a070a1b33b 100644
--- a/homeassistant/components/tomorrowio/weather.py
+++ b/homeassistant/components/tomorrowio/weather.py
@@ -33,7 +33,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sun import is_up
from homeassistant.util import dt as dt_util
@@ -66,7 +66,7 @@ from .entity import TomorrowioEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a config entry."""
coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]]
diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py
index 11b13a32ee5..eff8aed0a20 100644
--- a/homeassistant/components/toon/binary_sensor.py
+++ b/homeassistant/components/toon/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ToonDataUpdateCoordinator
@@ -25,7 +25,9 @@ from .entity import (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Toon binary sensor based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py
index 0c2e5b9b232..5538a0abd91 100644
--- a/homeassistant/components/toon/climate.py
+++ b/homeassistant/components/toon/climate.py
@@ -24,7 +24,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ToonDataUpdateCoordinator
from .const import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN
@@ -33,7 +33,9 @@ from .helpers import toon_exception_handler
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Toon binary sensors based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py
index 09f36c88079..e5b155b409b 100644
--- a/homeassistant/components/toon/sensor.py
+++ b/homeassistant/components/toon/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CURRENCY_EUR, DOMAIN, VOLUME_CM3, VOLUME_LMIN
from .coordinator import ToonDataUpdateCoordinator
@@ -36,7 +36,9 @@ from .entity import (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Toon sensors based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/toon/switch.py b/homeassistant/components/toon/switch.py
index deb2a12f2d0..d59a542d4d8 100644
--- a/homeassistant/components/toon/switch.py
+++ b/homeassistant/components/toon/switch.py
@@ -15,7 +15,7 @@ from toonapi import (
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ToonDataUpdateCoordinator
@@ -24,7 +24,9 @@ from .helpers import toon_exception_handler
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Toon switches based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py
index 7121e5bf806..e31e6085832 100644
--- a/homeassistant/components/totalconnect/alarm_control_panel.py
+++ b/homeassistant/components/totalconnect/alarm_control_panel.py
@@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CODE_REQUIRED, DOMAIN
from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator
@@ -28,7 +28,7 @@ SERVICE_ALARM_ARM_HOME_INSTANT = "arm_home_instant"
async def async_setup_entry(
hass: HomeAssistant,
entry: TotalConnectConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TotalConnect alarm panels based on a config entry."""
coordinator = entry.runtime_data
@@ -97,22 +97,6 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity):
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the state of the device."""
- # State attributes can be removed in 2025.3
- attr = {
- "location_id": self._location.location_id,
- "partition": self._partition_id,
- "ac_loss": self._location.ac_loss,
- "low_battery": self._location.low_battery,
- "cover_tampered": self._location.is_cover_tampered(),
- "triggered_source": None,
- "triggered_zone": None,
- }
-
- if self._partition_id == 1:
- attr["location_name"] = self.device.name
- else:
- attr["location_name"] = f"{self.device.name} partition {self._partition_id}"
-
state: AlarmControlPanelState | None = None
if self._partition.arming_state.is_disarmed():
state = AlarmControlPanelState.DISARMED
@@ -128,17 +112,12 @@ class TotalConnectAlarm(TotalConnectLocationEntity, AlarmControlPanelEntity):
state = AlarmControlPanelState.ARMING
elif self._partition.arming_state.is_disarming():
state = AlarmControlPanelState.DISARMING
- elif self._partition.arming_state.is_triggered_police():
+ elif (
+ self._partition.arming_state.is_triggered_police()
+ or self._partition.arming_state.is_triggered_fire()
+ or self._partition.arming_state.is_triggered_gas()
+ ):
state = AlarmControlPanelState.TRIGGERED
- attr["triggered_source"] = "Police/Medical"
- elif self._partition.arming_state.is_triggered_fire():
- state = AlarmControlPanelState.TRIGGERED
- attr["triggered_source"] = "Fire/Smoke"
- elif self._partition.arming_state.is_triggered_gas():
- state = AlarmControlPanelState.TRIGGERED
- attr["triggered_source"] = "Carbon Monoxide"
-
- self._attr_extra_state_attributes = attr
return state
diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py
index 5a67385cd20..2f3802dc9a6 100644
--- a/homeassistant/components/totalconnect/binary_sensor.py
+++ b/homeassistant/components/totalconnect/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator
from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity
@@ -120,7 +120,7 @@ LOCATION_BINARY_SENSORS: tuple[TotalConnectAlarmBinarySensorEntityDescription, .
async def async_setup_entry(
hass: HomeAssistant,
entry: TotalConnectConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TotalConnect device sensors based on a config entry."""
sensors: list = []
diff --git a/homeassistant/components/totalconnect/button.py b/homeassistant/components/totalconnect/button.py
index 7cdad00534d..eb85dcce1bf 100644
--- a/homeassistant/components/totalconnect/button.py
+++ b/homeassistant/components/totalconnect/button.py
@@ -9,7 +9,7 @@ from total_connect_client.zone import TotalConnectZone
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TotalConnectConfigEntry, TotalConnectDataUpdateCoordinator
from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity
@@ -39,7 +39,7 @@ PANEL_BUTTONS: tuple[TotalConnectButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TotalConnectConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up TotalConnect buttons based on a config entry."""
buttons: list = []
diff --git a/homeassistant/components/totalconnect/diagnostics.py b/homeassistant/components/totalconnect/diagnostics.py
index f42ed5e44c3..fc310bf850c 100644
--- a/homeassistant/components/totalconnect/diagnostics.py
+++ b/homeassistant/components/totalconnect/diagnostics.py
@@ -83,6 +83,7 @@ async def async_get_config_entry_diagnostics(
"is_new_partition": partition.is_new_partition,
"is_night_stay_enabled": partition.is_night_stay_enabled,
"exit_delay_timer": partition.exit_delay_timer,
+ "arming_state": partition.arming_state,
}
new_location["partitions"].append(new_partition)
diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py
index f7eec7c54f9..86526f4718b 100644
--- a/homeassistant/components/touchline/climate.py
+++ b/homeassistant/components/touchline/climate.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, NamedTuple
-from pytouchline import PyTouchline
+from pytouchline_extended import PyTouchline
import voluptuous as vol
from homeassistant.components.climate import (
@@ -53,12 +53,13 @@ def setup_platform(
"""Set up the Touchline devices."""
host = config[CONF_HOST]
- py_touchline = PyTouchline()
- number_of_devices = int(py_touchline.get_number_of_devices(host))
- add_entities(
- (Touchline(PyTouchline(device_id)) for device_id in range(number_of_devices)),
- True,
- )
+ py_touchline = PyTouchline(url=host)
+ number_of_devices = int(py_touchline.get_number_of_devices())
+ devices = [
+ Touchline(PyTouchline(id=device_id, url=host))
+ for device_id in range(number_of_devices)
+ ]
+ add_entities(devices, True)
class Touchline(ClimateEntity):
diff --git a/homeassistant/components/touchline/manifest.json b/homeassistant/components/touchline/manifest.json
index c003cca97a4..6d25462408b 100644
--- a/homeassistant/components/touchline/manifest.json
+++ b/homeassistant/components/touchline/manifest.json
@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pytouchline"],
"quality_scale": "legacy",
- "requirements": ["pytouchline==0.7"]
+ "requirements": ["pytouchline_extended==0.4.5"]
}
diff --git a/homeassistant/components/touchline_sl/climate.py b/homeassistant/components/touchline_sl/climate.py
index e7bb33311d0..7c5ea4ea9ca 100644
--- a/homeassistant/components/touchline_sl/climate.py
+++ b/homeassistant/components/touchline_sl/climate.py
@@ -10,7 +10,7 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TouchlineSLConfigEntry, TouchlineSLModuleCoordinator
from .entity import TouchlineSLZoneEntity
@@ -19,7 +19,7 @@ from .entity import TouchlineSLZoneEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: TouchlineSLConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Touchline devices."""
coordinators = entry.runtime_data
diff --git a/homeassistant/components/touchline_sl/strings.json b/homeassistant/components/touchline_sl/strings.json
index e3a0ef5a741..469fb8a50a6 100644
--- a/homeassistant/components/touchline_sl/strings.json
+++ b/homeassistant/components/touchline_sl/strings.json
@@ -1,6 +1,6 @@
{
"config": {
- "flow_title": "Touchline SL Setup Flow",
+ "flow_title": "Touchline SL setup flow",
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
@@ -8,7 +8,7 @@
},
"step": {
"user": {
- "title": "Login to Touchline SL",
+ "title": "Log in to Touchline SL",
"description": "Your credentials for the Roth Touchline SL mobile app/web service",
"data": {
"username": "[%key:common::config_flow::data::username%]",
diff --git a/homeassistant/components/tplink/binary_sensor.py b/homeassistant/components/tplink/binary_sensor.py
index 6986765b110..38935595fe2 100644
--- a/homeassistant/components/tplink/binary_sensor.py
+++ b/homeassistant/components/tplink/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TPLinkConfigEntry
from .entity import CoordinatedTPLinkFeatureEntity, TPLinkFeatureEntityDescription
@@ -73,7 +73,7 @@ BINARYSENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in BINARY_SENSOR_DESCRI
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink/button.py b/homeassistant/components/tplink/button.py
index 4279a233d21..145adb79185 100644
--- a/homeassistant/components/tplink/button.py
+++ b/homeassistant/components/tplink/button.py
@@ -15,7 +15,7 @@ from homeassistant.components.button import (
)
from homeassistant.components.siren import DOMAIN as SIREN_DOMAIN
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TPLinkConfigEntry
from .deprecate import DeprecatedInfo
@@ -95,7 +95,7 @@ BUTTON_DESCRIPTIONS_MAP = {desc.key: desc for desc in BUTTON_DESCRIPTIONS}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up buttons."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink/camera.py b/homeassistant/components/tplink/camera.py
index b0f1f1a62c1..7b59678da8e 100644
--- a/homeassistant/components/tplink/camera.py
+++ b/homeassistant/components/tplink/camera.py
@@ -19,7 +19,7 @@ from homeassistant.components.camera import (
from homeassistant.config_entries import ConfigFlowContext
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TPLinkConfigEntry
from .const import CONF_CAMERA_CREDENTIALS
@@ -59,7 +59,7 @@ CAMERA_DESCRIPTIONS: tuple[TPLinkCameraEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up camera entities."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink/climate.py b/homeassistant/components/tplink/climate.py
index 7204c2a7665..66037d7476e 100644
--- a/homeassistant/components/tplink/climate.py
+++ b/homeassistant/components/tplink/climate.py
@@ -22,7 +22,7 @@ from homeassistant.components.climate import (
from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TPLinkConfigEntry, legacy_device_id
from .const import DOMAIN, UNIT_MAPPING
@@ -71,7 +71,7 @@ CLIMATE_DESCRIPTIONS: tuple[TPLinkClimateEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up climate entities."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py
index 291a7e78c62..0914c4191cf 100644
--- a/homeassistant/components/tplink/config_flow.py
+++ b/homeassistant/components/tplink/config_flow.py
@@ -567,7 +567,7 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
)
async def _async_reload_requires_auth_entries(self) -> None:
- """Reload any in progress config flow that now have credentials."""
+ """Reload all config entries after auth update."""
_config_entries = self.hass.config_entries
if self.source == SOURCE_REAUTH:
@@ -579,11 +579,9 @@ class TPLinkConfigFlow(ConfigFlow, domain=DOMAIN):
context = flow["context"]
if context.get("source") != SOURCE_REAUTH:
continue
- entry_id: str = context["entry_id"]
+ entry_id = context["entry_id"]
if entry := _config_entries.async_get_entry(entry_id):
await _config_entries.async_reload(entry.entry_id)
- if entry.state is ConfigEntryState.LOADED:
- _config_entries.flow.async_abort(flow["flow_id"])
@callback
def _async_create_or_update_entry_from_device(
diff --git a/homeassistant/components/tplink/coordinator.py b/homeassistant/components/tplink/coordinator.py
index fcd1335a77a..1a7b40457f0 100644
--- a/homeassistant/components/tplink/coordinator.py
+++ b/homeassistant/components/tplink/coordinator.py
@@ -9,6 +9,7 @@ import logging
from kasa import AuthenticationError, Credentials, Device, KasaException
from kasa.iot import IotStrip
+from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -46,11 +47,9 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
device: Device,
update_interval: timedelta,
config_entry: TPLinkConfigEntry,
- parent_coordinator: TPLinkDataUpdateCoordinator | None = None,
) -> None:
"""Initialize DataUpdateCoordinator to gather data for specific SmartPlug."""
self.device = device
- self.parent_coordinator = parent_coordinator
# The iot HS300 allows a limited number of concurrent requests and
# fetching the emeter information requires separate ones, so child
@@ -97,12 +96,6 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
) from ex
await self._process_child_devices()
- if not self._update_children:
- # If the children are not being updated, it means this is an
- # IotStrip, and we need to tell the children to write state
- # since the power state is provided by the parent.
- for child_coordinator in self._child_coordinators.values():
- child_coordinator.async_set_updated_data(None)
async def _process_child_devices(self) -> None:
"""Process child devices and remove stale devices."""
@@ -131,20 +124,19 @@ class TPLinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
def get_child_coordinator(
self,
child: Device,
+ platform_domain: str,
) -> TPLinkDataUpdateCoordinator:
"""Get separate child coordinator for a device or self if not needed."""
# The iot HS300 allows a limited number of concurrent requests and fetching the
# emeter information requires separate ones so create child coordinators here.
- if isinstance(self.device, IotStrip):
+ # This does not happen for switches as the state is available on the
+ # parent device info.
+ if isinstance(self.device, IotStrip) and platform_domain != SWITCH_DOMAIN:
if not (child_coordinator := self._child_coordinators.get(child.device_id)):
# The child coordinators only update energy data so we can
# set a longer update interval to avoid flooding the device
child_coordinator = TPLinkDataUpdateCoordinator(
- self.hass,
- child,
- timedelta(seconds=60),
- self.config_entry,
- parent_coordinator=self,
+ self.hass, child, timedelta(seconds=60), self.config_entry
)
self._child_coordinators[child.device_id] = child_coordinator
return child_coordinator
diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py
index 7a0d811b30d..7c1e9e72b85 100644
--- a/homeassistant/components/tplink/entity.py
+++ b/homeassistant/components/tplink/entity.py
@@ -151,13 +151,7 @@ def async_refresh_after[_T: CoordinatedTPLinkEntity, **_P](
"exc": str(ex),
},
) from ex
- coordinator = self.coordinator
- if coordinator.parent_coordinator:
- # If there is a parent coordinator we need to refresh
- # the parent as its what provides the power state data
- # for the child entities.
- coordinator = coordinator.parent_coordinator
- await coordinator.async_request_refresh()
+ await self.coordinator.async_request_refresh()
return _async_wrap
@@ -514,7 +508,9 @@ class CoordinatedTPLinkFeatureEntity(CoordinatedTPLinkEntity, ABC):
)
for child in children:
- child_coordinator = coordinator.get_child_coordinator(child)
+ child_coordinator = coordinator.get_child_coordinator(
+ child, platform_domain
+ )
child_entities = cls._entities_for_device(
hass,
@@ -657,7 +653,9 @@ class CoordinatedTPLinkModuleEntity(CoordinatedTPLinkEntity, ABC):
device.host,
)
for child in children:
- child_coordinator = coordinator.get_child_coordinator(child)
+ child_coordinator = coordinator.get_child_coordinator(
+ child, platform_domain
+ )
child_entities: list[_E] = cls._entities_for_device(
hass,
diff --git a/homeassistant/components/tplink/fan.py b/homeassistant/components/tplink/fan.py
index 1c31d84b778..88396742b36 100644
--- a/homeassistant/components/tplink/fan.py
+++ b/homeassistant/components/tplink/fan.py
@@ -15,7 +15,7 @@ from homeassistant.components.fan import (
FanEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -59,7 +59,7 @@ FAN_DESCRIPTIONS: tuple[TPLinkFanEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up fans."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py
index 718b5ed7120..b3cee1d3baf 100644
--- a/homeassistant/components/tplink/light.py
+++ b/homeassistant/components/tplink/light.py
@@ -29,7 +29,7 @@ from homeassistant.components.light import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from . import TPLinkConfigEntry, legacy_device_id
@@ -196,7 +196,7 @@ LIGHT_EFFECT_DESCRIPTIONS: tuple[TPLinkLightEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lights."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json
index ff65211c9b3..cdd6ab57c6a 100644
--- a/homeassistant/components/tplink/manifest.json
+++ b/homeassistant/components/tplink/manifest.json
@@ -301,5 +301,5 @@
"iot_class": "local_polling",
"loggers": ["kasa"],
"quality_scale": "platinum",
- "requirements": ["python-kasa[speedups]==0.10.1"]
+ "requirements": ["python-kasa[speedups]==0.10.2"]
}
diff --git a/homeassistant/components/tplink/number.py b/homeassistant/components/tplink/number.py
index a9d002c0083..252c4888d26 100644
--- a/homeassistant/components/tplink/number.py
+++ b/homeassistant/components/tplink/number.py
@@ -15,7 +15,7 @@ from homeassistant.components.number import (
NumberMode,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TPLinkConfigEntry
from .entity import (
@@ -81,7 +81,7 @@ NUMBER_DESCRIPTIONS_MAP = {desc.key: desc for desc in NUMBER_DESCRIPTIONS}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number entities."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink/select.py b/homeassistant/components/tplink/select.py
index 8e9dee7b964..72042f571e6 100644
--- a/homeassistant/components/tplink/select.py
+++ b/homeassistant/components/tplink/select.py
@@ -13,7 +13,7 @@ from homeassistant.components.select import (
SelectEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TPLinkConfigEntry
from .entity import (
@@ -53,7 +53,7 @@ SELECT_DESCRIPTIONS_MAP = {desc.key: desc for desc in SELECT_DESCRIPTIONS}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up select entities."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink/sensor.py b/homeassistant/components/tplink/sensor.py
index 9b21ba775a9..cc35b1fd142 100644
--- a/homeassistant/components/tplink/sensor.py
+++ b/homeassistant/components/tplink/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TPLinkConfigEntry
from .const import UNIT_MAPPING
@@ -271,7 +271,7 @@ SENSOR_DESCRIPTIONS_MAP = {desc.key: desc for desc in SENSOR_DESCRIPTIONS}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink/siren.py b/homeassistant/components/tplink/siren.py
index 027fa2dd58f..65cb722052f 100644
--- a/homeassistant/components/tplink/siren.py
+++ b/homeassistant/components/tplink/siren.py
@@ -21,7 +21,7 @@ from homeassistant.components.siren import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TPLinkConfigEntry, legacy_device_id
from .const import DOMAIN
@@ -61,7 +61,7 @@ SIREN_DESCRIPTIONS: tuple[TPLinkSirenEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up siren entities."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py
index f08753def26..3cb20d63cd7 100644
--- a/homeassistant/components/tplink/switch.py
+++ b/homeassistant/components/tplink/switch.py
@@ -14,7 +14,7 @@ from homeassistant.components.switch import (
SwitchEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TPLinkConfigEntry
from .entity import (
@@ -85,7 +85,7 @@ SWITCH_DESCRIPTIONS_MAP = {desc.key: desc for desc in SWITCH_DESCRIPTIONS}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink/vacuum.py b/homeassistant/components/tplink/vacuum.py
index c62cd1d27c8..e948e778be4 100644
--- a/homeassistant/components/tplink/vacuum.py
+++ b/homeassistant/components/tplink/vacuum.py
@@ -16,7 +16,7 @@ from homeassistant.components.vacuum import (
VacuumEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TPLinkConfigEntry
from .coordinator import TPLinkDataUpdateCoordinator
@@ -63,7 +63,7 @@ VACUUM_DESCRIPTIONS: tuple[TPLinkVacuumEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TPLinkConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up vacuum entities."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py
index 06df118463b..7ea7fd95fef 100644
--- a/homeassistant/components/tplink_omada/__init__.py
+++ b/homeassistant/components/tplink_omada/__init__.py
@@ -11,7 +11,7 @@ from tplink_omada_client.exceptions import (
UnsupportedControllerVersion,
)
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
@@ -80,12 +80,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> boo
async def async_unload_entry(hass: HomeAssistant, entry: OmadaConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# This is the last loaded instance of Omada, deregister any services
hass.services.async_remove(DOMAIN, "reconnect_client")
diff --git a/homeassistant/components/tplink_omada/binary_sensor.py b/homeassistant/components/tplink_omada/binary_sensor.py
index 73d5f54b8b3..fb179634fd1 100644
--- a/homeassistant/components/tplink_omada/binary_sensor.py
+++ b/homeassistant/components/tplink_omada/binary_sensor.py
@@ -18,7 +18,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OmadaConfigEntry
from .controller import OmadaGatewayCoordinator
@@ -28,7 +28,7 @@ from .entity import OmadaDeviceEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OmadaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/tplink_omada/device_tracker.py b/homeassistant/components/tplink_omada/device_tracker.py
index fe78adf8847..ce1c8ba40e1 100644
--- a/homeassistant/components/tplink_omada/device_tracker.py
+++ b/homeassistant/components/tplink_omada/device_tracker.py
@@ -6,7 +6,7 @@ from tplink_omada_client.clients import OmadaWirelessClient
from homeassistant.components.device_tracker import ScannerEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import OmadaConfigEntry
@@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OmadaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device trackers and scanners."""
diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json
index af20b54675b..274f2815330 100644
--- a/homeassistant/components/tplink_omada/manifest.json
+++ b/homeassistant/components/tplink_omada/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/tplink_omada",
"integration_type": "hub",
"iot_class": "local_polling",
- "requirements": ["tplink-omada-client==1.4.3"]
+ "requirements": ["tplink-omada-client==1.4.4"]
}
diff --git a/homeassistant/components/tplink_omada/sensor.py b/homeassistant/components/tplink_omada/sensor.py
index 272334d1b52..b41f3da2f33 100644
--- a/homeassistant/components/tplink_omada/sensor.py
+++ b/homeassistant/components/tplink_omada/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import OmadaConfigEntry
@@ -57,7 +57,7 @@ def _map_device_status(device: OmadaListDevice) -> str | None:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OmadaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json
index 73cea692dbf..99c509a73a7 100644
--- a/homeassistant/components/tplink_omada/strings.json
+++ b/homeassistant/components/tplink_omada/strings.json
@@ -24,14 +24,14 @@
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
- "title": "Update TP-Link Omada Credentials",
+ "title": "Update TP-Link Omada credentials",
"description": "The provided credentials have stopped working. Please update them."
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
- "unsupported_controller": "Omada Controller version not supported.",
+ "unsupported_controller": "Omada controller version not supported.",
"unknown": "[%key:common::config_flow::error::unknown%]",
"no_sites_found": "No sites found which the user can manage."
},
@@ -46,31 +46,31 @@
"name": "Port {port_name} PoE"
},
"wan_connect_ipv4": {
- "name": "Port {port_name} Internet Connected"
+ "name": "Port {port_name} Internet connected"
},
"wan_connect_ipv6": {
- "name": "Port {port_name} Internet Connected (IPv6)"
+ "name": "Port {port_name} Internet connected (IPv6)"
}
},
"binary_sensor": {
"wan_link": {
- "name": "Port {port_name} Internet Link"
+ "name": "Port {port_name} Internet link"
},
"online_detection": {
- "name": "Port {port_name} Online Detection"
+ "name": "Port {port_name} online detection"
},
"lan_status": {
- "name": "Port {port_name} LAN Status"
+ "name": "Port {port_name} LAN status"
},
"poe_delivery": {
- "name": "Port {port_name} PoE Delivery"
+ "name": "Port {port_name} PoE delivery"
}
},
"sensor": {
"device_status": {
"name": "Device status",
"state": {
- "error": "Error",
+ "error": "[%key:common::state::error%]",
"disconnected": "[%key:common::state::disconnected%]",
"connected": "[%key:common::state::connected%]",
"pending": "Pending",
@@ -91,7 +91,7 @@
"services": {
"reconnect_client": {
"name": "Reconnect wireless client",
- "description": "Tries to get wireless client to reconnect to Omada Network.",
+ "description": "Tries to get wireless client to reconnect to Omada network.",
"fields": {
"mac": {
"name": "MAC address",
diff --git a/homeassistant/components/tplink_omada/switch.py b/homeassistant/components/tplink_omada/switch.py
index f99d8aaedde..37c73a9e11f 100644
--- a/homeassistant/components/tplink_omada/switch.py
+++ b/homeassistant/components/tplink_omada/switch.py
@@ -22,7 +22,7 @@ from tplink_omada_client.omadasiteclient import GatewayPortSettings
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OmadaConfigEntry
from .controller import OmadaGatewayCoordinator, OmadaSwitchPortCoordinator
@@ -37,7 +37,7 @@ TCoordinator = TypeVar("TCoordinator", bound="OmadaCoordinator[Any]")
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OmadaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/tplink_omada/update.py b/homeassistant/components/tplink_omada/update.py
index 8b7fcfba394..8a8531c10b6 100644
--- a/homeassistant/components/tplink_omada/update.py
+++ b/homeassistant/components/tplink_omada/update.py
@@ -16,7 +16,7 @@ from homeassistant.components.update import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OmadaConfigEntry
from .coordinator import POLL_DEVICES, OmadaCoordinator, OmadaDevicesCoordinator
@@ -93,7 +93,7 @@ class OmadaFirmwareUpdateCoordinator(OmadaCoordinator[FirmwareUpdateStatus]): #
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OmadaConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches."""
controller = config_entry.runtime_data
diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py
index 0fa7fc344ea..43210ee92ea 100644
--- a/homeassistant/components/traccar/device_tracker.py
+++ b/homeassistant/components/traccar/device_tracker.py
@@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from . import DOMAIN, TRACKER_UPDATE
@@ -69,7 +69,9 @@ EVENTS = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Configure a dispatcher connection based on a config entry."""
diff --git a/homeassistant/components/traccar_server/binary_sensor.py b/homeassistant/components/traccar_server/binary_sensor.py
index 58c46502b53..6d81ba84ed4 100644
--- a/homeassistant/components/traccar_server/binary_sensor.py
+++ b/homeassistant/components/traccar_server/binary_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import TraccarServerCoordinator
@@ -55,7 +55,7 @@ TRACCAR_SERVER_BINARY_SENSOR_ENTITY_DESCRIPTIONS: tuple[
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensor entities."""
coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/traccar_server/device_tracker.py b/homeassistant/components/traccar_server/device_tracker.py
index 9e5a3c0ee9f..7f2a6dd7c40 100644
--- a/homeassistant/components/traccar_server/device_tracker.py
+++ b/homeassistant/components/traccar_server/device_tracker.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_CATEGORY, ATTR_TRACCAR_ID, ATTR_TRACKER, DOMAIN
from .coordinator import TraccarServerCoordinator
@@ -17,7 +17,7 @@ from .entity import TraccarServerEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker entities."""
coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/traccar_server/sensor.py b/homeassistant/components/traccar_server/sensor.py
index bb3c4ed4401..9aee6f28489 100644
--- a/homeassistant/components/traccar_server/sensor.py
+++ b/homeassistant/components/traccar_server/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfSpeed
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
@@ -83,7 +83,7 @@ TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS: tuple[
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor entities."""
coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/traccar_server/strings.json b/homeassistant/components/traccar_server/strings.json
index 8bec4b112ac..a4b57562388 100644
--- a/homeassistant/components/traccar_server/strings.json
+++ b/homeassistant/components/traccar_server/strings.json
@@ -12,7 +12,7 @@
},
"data_description": {
"host": "The hostname or IP address of your Traccar Server",
- "username": "The username (email) you use to login to your Traccar Server"
+ "username": "The username (email) you use to log in to your Traccar Server"
}
}
},
@@ -47,7 +47,7 @@
"motion": {
"name": "Motion",
"state": {
- "off": "Stopped",
+ "off": "[%key:common::state::stopped%]",
"on": "Moving"
}
},
diff --git a/homeassistant/components/tractive/__init__.py b/homeassistant/components/tractive/__init__.py
index 8bc2d11d047..60bae9bfd2e 100644
--- a/homeassistant/components/tractive/__init__.py
+++ b/homeassistant/components/tractive/__init__.py
@@ -31,6 +31,7 @@ from .const import (
ATTR_MINUTES_DAY_SLEEP,
ATTR_MINUTES_NIGHT_SLEEP,
ATTR_MINUTES_REST,
+ ATTR_POWER_SAVING,
ATTR_SLEEP_LABEL,
ATTR_TRACKER_STATE,
CLIENT_ID,
@@ -277,6 +278,7 @@ class TractiveClient:
payload = {
ATTR_BATTERY_LEVEL: event["hardware"]["battery_level"],
ATTR_TRACKER_STATE: event["tracker_state"].lower(),
+ ATTR_POWER_SAVING: event.get("tracker_state_reason") == "POWER_SAVING",
ATTR_BATTERY_CHARGING: event["charging_state"] == "CHARGING",
}
self._dispatch_tracker_event(
diff --git a/homeassistant/components/tractive/binary_sensor.py b/homeassistant/components/tractive/binary_sensor.py
index 80219154d81..9ded1f699c3 100644
--- a/homeassistant/components/tractive/binary_sensor.py
+++ b/homeassistant/components/tractive/binary_sensor.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
from typing import Any
from homeassistant.components.binary_sensor import (
@@ -11,10 +13,10 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Trackables, TractiveClient, TractiveConfigEntry
-from .const import TRACKER_HARDWARE_STATUS_UPDATED
+from .const import ATTR_POWER_SAVING, TRACKER_HARDWARE_STATUS_UPDATED
from .entity import TractiveEntity
@@ -25,7 +27,7 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity):
self,
client: TractiveClient,
item: Trackables,
- description: BinarySensorEntityDescription,
+ description: TractiveBinarySensorEntityDescription,
) -> None:
"""Initialize sensor entity."""
super().__init__(
@@ -47,27 +49,43 @@ class TractiveBinarySensor(TractiveEntity, BinarySensorEntity):
super().handle_status_update(event)
-SENSOR_TYPE = BinarySensorEntityDescription(
- key=ATTR_BATTERY_CHARGING,
- translation_key="tracker_battery_charging",
- device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
- entity_category=EntityCategory.DIAGNOSTIC,
-)
+@dataclass(frozen=True, kw_only=True)
+class TractiveBinarySensorEntityDescription(BinarySensorEntityDescription):
+ """Class describing Tractive binary sensor entities."""
+
+ supported: Callable[[dict], bool] = lambda _: True
+
+
+SENSOR_TYPES = [
+ TractiveBinarySensorEntityDescription(
+ key=ATTR_BATTERY_CHARGING,
+ translation_key="tracker_battery_charging",
+ device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ supported=lambda details: details.get("charging_state") is not None,
+ ),
+ TractiveBinarySensorEntityDescription(
+ key=ATTR_POWER_SAVING,
+ translation_key="tracker_power_saving",
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+]
async def async_setup_entry(
hass: HomeAssistant,
entry: TractiveConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tractive device trackers."""
client = entry.runtime_data.client
trackables = entry.runtime_data.trackables
entities = [
- TractiveBinarySensor(client, item, SENSOR_TYPE)
+ TractiveBinarySensor(client, item, description)
+ for description in SENSOR_TYPES
for item in trackables
- if item.tracker_details.get("charging_state") is not None
+ if description.supported(item.tracker_details)
]
async_add_entities(entities)
diff --git a/homeassistant/components/tractive/const.py b/homeassistant/components/tractive/const.py
index cb5d4066dd9..9b925015772 100644
--- a/homeassistant/components/tractive/const.py
+++ b/homeassistant/components/tractive/const.py
@@ -16,6 +16,7 @@ ATTR_MINUTES_ACTIVE = "minutes_active"
ATTR_MINUTES_DAY_SLEEP = "minutes_day_sleep"
ATTR_MINUTES_NIGHT_SLEEP = "minutes_night_sleep"
ATTR_MINUTES_REST = "minutes_rest"
+ATTR_POWER_SAVING = "power_saving"
ATTR_SLEEP_LABEL = "sleep_label"
ATTR_TRACKER_STATE = "tracker_state"
diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py
index f31afaf92f6..bd1380ade4c 100644
--- a/homeassistant/components/tractive/device_tracker.py
+++ b/homeassistant/components/tractive/device_tracker.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.device_tracker import SourceType, TrackerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Trackables, TractiveClient, TractiveConfigEntry
from .const import (
@@ -21,7 +21,7 @@ from .entity import TractiveEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: TractiveConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tractive device trackers."""
client = entry.runtime_data.client
@@ -55,11 +55,9 @@ class TractiveDeviceTracker(TractiveEntity, TrackerEntity):
@property
def source_type(self) -> SourceType:
- """Return the source type, eg gps or router, of the device."""
+ """Return the source type of the device."""
if self._source_type == "PHONE":
return SourceType.BLUETOOTH
- if self._source_type == "KNOWN_WIFI":
- return SourceType.ROUTER
return SourceType.GPS
@property
diff --git a/homeassistant/components/tractive/sensor.py b/homeassistant/components/tractive/sensor.py
index a3c1893267c..18d7e4c23ab 100644
--- a/homeassistant/components/tractive/sensor.py
+++ b/homeassistant/components/tractive/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import Trackables, TractiveClient, TractiveConfigEntry
@@ -182,7 +182,7 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TractiveConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tractive device trackers."""
client = entry.runtime_data.client
diff --git a/homeassistant/components/tractive/strings.json b/homeassistant/components/tractive/strings.json
index 0690328c99c..a56a2982057 100644
--- a/homeassistant/components/tractive/strings.json
+++ b/homeassistant/components/tractive/strings.json
@@ -22,6 +22,9 @@
"binary_sensor": {
"tracker_battery_charging": {
"name": "Tracker battery charging"
+ },
+ "tracker_power_saving": {
+ "name": "Tracker power saving"
}
},
"device_tracker": {
diff --git a/homeassistant/components/tractive/switch.py b/homeassistant/components/tractive/switch.py
index 3bf6887e99c..da2c8e35ff7 100644
--- a/homeassistant/components/tractive/switch.py
+++ b/homeassistant/components/tractive/switch.py
@@ -11,7 +11,7 @@ from aiotractive.exceptions import TractiveError
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Trackables, TractiveClient, TractiveConfigEntry
from .const import (
@@ -57,7 +57,7 @@ SWITCH_TYPES: tuple[TractiveSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TractiveConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tractive switches."""
client = entry.runtime_data.client
diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py
index 2073829e021..c3e8938b244 100644
--- a/homeassistant/components/tradfri/__init__.py
+++ b/homeassistant/components/tradfri/__init__.py
@@ -159,7 +159,7 @@ def remove_stale_devices(
device_entries = dr.async_entries_for_config_entry(
device_registry, config_entry.entry_id
)
- all_device_ids = {device.id for device in devices}
+ all_device_ids = {str(device.id) for device in devices}
for device_entry in device_entries:
device_id: str | None = None
@@ -176,7 +176,7 @@ def remove_stale_devices(
gateway_id = _id
break
- device_id = _id
+ device_id = _id.replace(f"{config_entry.data[CONF_GATEWAY_ID]}-", "")
break
if gateway_id is not None:
@@ -190,3 +190,93 @@ def remove_stale_devices(
device_registry.async_update_device(
device_entry.id, remove_config_entry_id=config_entry.entry_id
)
+
+
+async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
+ """Migrate old entry."""
+ LOGGER.debug(
+ "Migrating Tradfri configuration from version %s.%s",
+ config_entry.version,
+ config_entry.minor_version,
+ )
+
+ if config_entry.version > 1:
+ # This means the user has downgraded from a future version
+ return False
+
+ if config_entry.version == 1:
+ # Migrate to version 2
+ migrate_config_entry_and_identifiers(hass, config_entry)
+
+ hass.config_entries.async_update_entry(config_entry, version=2)
+
+ LOGGER.debug(
+ "Migration to Tradfri configuration version %s.%s successful",
+ config_entry.version,
+ config_entry.minor_version,
+ )
+
+ return True
+
+
+def migrate_config_entry_and_identifiers(
+ hass: HomeAssistant, config_entry: ConfigEntry
+) -> None:
+ """Migrate old non-unique identifiers to new unique identifiers."""
+
+ related_device_flag: bool
+ device_id: str
+
+ device_reg = dr.async_get(hass)
+ # Get all devices associated to contextual gateway config_entry
+ # and loop through list of devices.
+ for device in dr.async_entries_for_config_entry(device_reg, config_entry.entry_id):
+ related_device_flag = False
+ for identifier in device.identifiers:
+ if identifier[0] != DOMAIN:
+ continue
+
+ related_device_flag = True
+
+ _id = identifier[1]
+
+ # Identify gateway device.
+ if _id == config_entry.data[CONF_GATEWAY_ID]:
+ # Using this to avoid updating gateway's own device registry entry
+ related_device_flag = False
+ break
+
+ device_id = str(_id)
+ break
+
+ # Check that device is related to tradfri domain (and is not the gateway itself)
+ if not related_device_flag:
+ continue
+
+ # Loop through list of config_entry_ids for device
+ config_entry_ids = device.config_entries
+ for config_entry_id in config_entry_ids:
+ # Check that the config entry in list is not the device's primary config entry
+ if config_entry_id == device.primary_config_entry:
+ continue
+
+ # Check that the 'other' config entry is also a tradfri config entry
+ other_entry = hass.config_entries.async_get_entry(config_entry_id)
+
+ if other_entry is None or other_entry.domain != DOMAIN:
+ continue
+
+ # Remove non-primary 'tradfri' config entry from device's config_entry_ids
+ device_reg.async_update_device(
+ device.id, remove_config_entry_id=config_entry_id
+ )
+
+ if config_entry.data[CONF_GATEWAY_ID] in device_id:
+ continue
+
+ device_reg.async_update_device(
+ device.id,
+ new_identifiers={
+ (DOMAIN, f"{config_entry.data[CONF_GATEWAY_ID]}-{device_id}")
+ },
+ )
diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py
index 29d876346a7..f4adb1cc09e 100644
--- a/homeassistant/components/tradfri/config_flow.py
+++ b/homeassistant/components/tradfri/config_flow.py
@@ -3,7 +3,7 @@
from __future__ import annotations
import asyncio
-from typing import Any
+from typing import Any, cast
from uuid import uuid4
from pytradfri import Gateway, RequestError
@@ -35,7 +35,7 @@ class AuthError(Exception):
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
- VERSION = 1
+ VERSION = 2
def __init__(self) -> None:
"""Initialize flow."""
@@ -54,7 +54,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {}
if user_input is not None:
- host = user_input.get(CONF_HOST, self._host)
+ host = cast(str, user_input.get(CONF_HOST, self._host))
try:
auth = await authenticate(
self.hass, host, user_input[KEY_SECURITY_CODE]
diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py
index 92d10320327..b1fb9b153ad 100644
--- a/homeassistant/components/tradfri/cover.py
+++ b/homeassistant/components/tradfri/cover.py
@@ -10,7 +10,7 @@ from pytradfri.command import Command
from homeassistant.components.cover import ATTR_POSITION, CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API
from .coordinator import TradfriDeviceDataUpdateCoordinator
@@ -20,7 +20,7 @@ from .entity import TradfriBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Tradfri covers based on a config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
diff --git a/homeassistant/components/tradfri/entity.py b/homeassistant/components/tradfri/entity.py
index b06d0081477..41c20b19de5 100644
--- a/homeassistant/components/tradfri/entity.py
+++ b/homeassistant/components/tradfri/entity.py
@@ -58,7 +58,7 @@ class TradfriBaseEntity(CoordinatorEntity[TradfriDeviceDataUpdateCoordinator]):
info = self._device.device_info
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, self._device_id)},
+ identifiers={(DOMAIN, f"{gateway_id}-{self._device_id}")},
manufacturer=info.manufacturer,
model=info.model_number,
name=self._device.name,
diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py
index 3f45ee3e1eb..e8fb7c050ed 100644
--- a/homeassistant/components/tradfri/fan.py
+++ b/homeassistant/components/tradfri/fan.py
@@ -10,7 +10,7 @@ from pytradfri.command import Command
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API
from .coordinator import TradfriDeviceDataUpdateCoordinator
@@ -33,7 +33,7 @@ def _from_fan_speed(fan_speed: int) -> int:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Tradfri switches based on a config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py
index e464d1a8142..b945c7f2bec 100644
--- a/homeassistant/components/tradfri/light.py
+++ b/homeassistant/components/tradfri/light.py
@@ -19,7 +19,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API
@@ -30,7 +30,7 @@ from .entity import TradfriBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Tradfri lights based on a config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py
index 4e560f0e7b5..b4a7c335481 100644
--- a/homeassistant/components/tradfri/sensor.py
+++ b/homeassistant/components/tradfri/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_GATEWAY_ID,
@@ -128,7 +128,7 @@ def _migrate_old_unique_ids(hass: HomeAssistant, old_unique_id: str, key: str) -
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Tradfri config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json
index 9ed7e167e71..66c46dd482e 100644
--- a/homeassistant/components/tradfri/strings.json
+++ b/homeassistant/components/tradfri/strings.json
@@ -6,7 +6,7 @@
"description": "You can find the security code on the back of your gateway.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
- "security_code": "Security Code"
+ "security_code": "Security code"
},
"data_description": {
"host": "Hostname or IP address of your Trådfri gateway."
@@ -14,7 +14,7 @@
}
},
"error": {
- "invalid_security_code": "Failed to register with provided key. If this keeps happening, try restarting the gateway.",
+ "invalid_security_code": "Failed to register with provided code. If this keeps happening, try restarting the gateway.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout": "Timeout validating the code.",
"cannot_authenticate": "Cannot authenticate, is Gateway paired with another server like e.g. Homekit?"
diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py
index 088b775b9fd..a2a1a5b4623 100644
--- a/homeassistant/components/tradfri/switch.py
+++ b/homeassistant/components/tradfri/switch.py
@@ -10,7 +10,7 @@ from pytradfri.command import Command
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_GATEWAY_ID, COORDINATOR, COORDINATOR_LIST, DOMAIN, KEY_API
from .coordinator import TradfriDeviceDataUpdateCoordinator
@@ -20,7 +20,7 @@ from .entity import TradfriBaseEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Load Tradfri switches based on a config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py
index b367fa0fb45..92112b41466 100644
--- a/homeassistant/components/trafikverket_camera/binary_sensor.py
+++ b/homeassistant/components/trafikverket_camera/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TVCameraConfigEntry
from .coordinator import CameraData
@@ -36,7 +36,7 @@ BINARY_SENSOR_TYPE = TVCameraSensorEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: TVCameraConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Trafikverket Camera binary sensor platform."""
diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py
index ece02cacf70..b4eddb0890f 100644
--- a/homeassistant/components/trafikverket_camera/camera.py
+++ b/homeassistant/components/trafikverket_camera/camera.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.camera import Camera
from homeassistant.const import ATTR_LOCATION
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TVCameraConfigEntry
from .const import ATTR_DESCRIPTION, ATTR_TYPE
@@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: TVCameraConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Trafikverket Camera."""
diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py
index cb5c458f742..726fcb6f901 100644
--- a/homeassistant/components/trafikverket_camera/sensor.py
+++ b/homeassistant/components/trafikverket_camera/sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import DEGREE
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import TVCameraConfigEntry
@@ -74,7 +74,7 @@ SENSOR_TYPES: tuple[TVCameraSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TVCameraConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Trafikverket Camera sensor platform."""
diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json
index b6e2209fc57..8fdc6357156 100644
--- a/homeassistant/components/trafikverket_camera/strings.json
+++ b/homeassistant/components/trafikverket_camera/strings.json
@@ -18,7 +18,7 @@
"location": "[%key:common::config_flow::data::location%]"
},
"data_description": {
- "location": "Equal or part of name, description or camera id. Be as specific as possible to avoid getting multiple cameras as result"
+ "location": "Equal or part of name, description or camera ID. Be as specific as possible to avoid getting multiple cameras as result"
}
},
"multiple_cameras": {
@@ -60,7 +60,7 @@
"name": "[%key:common::config_flow::data::location%]"
},
"photo_url": {
- "name": "Photo url"
+ "name": "Photo URL"
},
"status": {
"name": "Status"
@@ -87,7 +87,7 @@
"name": "Photo time"
},
"photo_url": {
- "name": "Photo url"
+ "name": "Photo URL"
},
"status": {
"name": "Status"
diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py
index 002dc421273..dfa64ed2953 100644
--- a/homeassistant/components/trafikverket_ferry/config_flow.py
+++ b/homeassistant/components/trafikverket_ferry/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
+import logging
from typing import Any
from pytrafikverket import TrafikverketFerry
@@ -17,6 +18,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN
from .util import create_unique_id
+_LOGGER = logging.getLogger(__name__)
+
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): selector.TextSelector(
@@ -81,7 +84,8 @@ class TVFerryConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except NoFerryFound:
errors["base"] = "invalid_route"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "cannot_connect"
else:
return self.async_update_reload_and_abort(
@@ -120,7 +124,8 @@ class TVFerryConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
except NoFerryFound:
errors["base"] = "invalid_route"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "cannot_connect"
else:
if not errors:
diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py
index 44176ab82b7..b908bc5f550 100644
--- a/homeassistant/components/trafikverket_ferry/sensor.py
+++ b/homeassistant/components/trafikverket_ferry/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import as_utc
@@ -92,7 +92,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TVFerryConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Trafikverket sensor entry."""
diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py
index 57d74eef78a..fb39e14815e 100644
--- a/homeassistant/components/trafikverket_train/config_flow.py
+++ b/homeassistant/components/trafikverket_train/config_flow.py
@@ -86,8 +86,8 @@ async def validate_station(
except UnknownError as error:
_LOGGER.error("Unknown error occurred during validation %s", str(error))
errors["base"] = "cannot_connect"
- except Exception as error: # noqa: BLE001
- _LOGGER.error("Unknown exception occurred during validation %s", str(error))
+ except Exception:
+ _LOGGER.exception("Unknown exception occurred during validation")
errors["base"] = "cannot_connect"
return (stations, errors)
@@ -101,6 +101,9 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
_from_stations: list[StationInfoModel]
_to_stations: list[StationInfoModel]
+ _time: str | None
+ _days: list
+ _product: str | None
_data: dict[str, Any]
@staticmethod
@@ -243,8 +246,10 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the select station step."""
if user_input is not None:
api_key: str = self._data[CONF_API_KEY]
- train_from: str = user_input[CONF_FROM]
- train_to: str = user_input[CONF_TO]
+ train_from: str = (
+ user_input.get(CONF_FROM) or self._from_stations[0].signature
+ )
+ train_to: str = user_input.get(CONF_TO) or self._to_stations[0].signature
train_time: str | None = self._data.get(CONF_TIME)
train_days: list = self._data[CONF_WEEKDAY]
filter_product: str | None = self._data[CONF_FILTER_PRODUCT]
@@ -261,7 +266,7 @@ class TVTrainConfigFlow(ConfigFlow, domain=DOMAIN):
{
CONF_API_KEY: api_key,
CONF_FROM: train_from,
- CONF_TO: user_input[CONF_TO],
+ CONF_TO: train_to,
CONF_TIME: train_time,
CONF_WEEKDAY: train_days,
CONF_FILTER_PRODUCT: filter_product,
diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py
index a4de8c1ef26..150b5ee7abb 100644
--- a/homeassistant/components/trafikverket_train/sensor.py
+++ b/homeassistant/components/trafikverket_train/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import CONF_NAME, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -110,7 +110,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TVTrainConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Trafikverket sensor entry."""
diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py
index f4316b887b3..ee9fe264692 100644
--- a/homeassistant/components/trafikverket_weatherstation/config_flow.py
+++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py
@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
+import logging
from typing import Any
from pytrafikverket.exceptions import (
@@ -25,6 +26,8 @@ from homeassistant.helpers.selector import (
from .const import CONF_STATION, DOMAIN
+_LOGGER = logging.getLogger(__name__)
+
class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Trafikverket Weatherstation integration."""
@@ -56,7 +59,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_station"
except MultipleWeatherStationsFound:
errors["base"] = "more_stations"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected error")
errors["base"] = "cannot_connect"
else:
return self.async_create_entry(
@@ -102,7 +106,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_station"
except MultipleWeatherStationsFound:
errors["base"] = "more_stations"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "cannot_connect"
else:
return self.async_update_reload_and_abort(
@@ -132,7 +137,8 @@ class TVWeatherConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_station"
except MultipleWeatherStationsFound:
errors["base"] = "more_stations"
- except Exception: # noqa: BLE001
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "cannot_connect"
else:
return self.async_update_reload_and_abort(
diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py
index bc17c82748a..bbc6764e3ef 100644
--- a/homeassistant/components/trafikverket_weatherstation/sensor.py
+++ b/homeassistant/components/trafikverket_weatherstation/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -89,7 +89,8 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = (
translation_key="wind_direction",
value_fn=lambda data: data.winddirection,
native_unit_of_measurement=DEGREE,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
),
TrafikverketSensorEntityDescription(
key="wind_speed",
@@ -204,7 +205,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TVWeatherConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Trafikverket sensor entry."""
diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py
index bae9e7f3cc7..a0babe7464a 100644
--- a/homeassistant/components/transmission/sensor.py
+++ b/homeassistant/components/transmission/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import STATE_IDLE, UnitOfDataRate
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -129,7 +129,7 @@ SENSOR_TYPES: tuple[TransmissionSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TransmissionConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Transmission sensors."""
diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py
index d06932ff862..9ca8a197344 100644
--- a/homeassistant/components/transmission/switch.py
+++ b/homeassistant/components/transmission/switch.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -44,7 +44,7 @@ SWITCH_TYPES: tuple[TransmissionSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TransmissionConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Transmission switch."""
diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py
index e5ff5c64a8b..4261f96bbe6 100644
--- a/homeassistant/components/trend/binary_sensor.py
+++ b/homeassistant/components/trend/binary_sensor.py
@@ -35,7 +35,10 @@ from homeassistant.core import Event, EventStateChangedData, HomeAssistant, call
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.entity import generate_entity_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.restore_state import RestoreEntity
@@ -130,7 +133,7 @@ async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up trend sensor from config entry."""
diff --git a/homeassistant/components/triggercmd/config_flow.py b/homeassistant/components/triggercmd/config_flow.py
index fc02dd0b2fc..48c4eacfd5a 100644
--- a/homeassistant/components/triggercmd/config_flow.py
+++ b/homeassistant/components/triggercmd/config_flow.py
@@ -57,7 +57,7 @@ class TriggerCMDConfigFlow(ConfigFlow, domain=DOMAIN):
errors[CONF_TOKEN] = "invalid_token"
except TRIGGERcmdConnectionError:
errors["base"] = "cannot_connect"
- except Exception: # pylint: disable=broad-except
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
diff --git a/homeassistant/components/triggercmd/switch.py b/homeassistant/components/triggercmd/switch.py
index 94566fe301d..e04cf5ee7e8 100644
--- a/homeassistant/components/triggercmd/switch.py
+++ b/homeassistant/components/triggercmd/switch.py
@@ -9,7 +9,7 @@ from triggercmd import client, ha
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TriggercmdConfigEntry
from .const import DOMAIN
@@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TriggercmdConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add switch for passed config_entry in HA."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py
index 6c7e521f3ef..8182d375f96 100644
--- a/homeassistant/components/tts/__init__.py
+++ b/homeassistant/components/tts/__init__.py
@@ -3,9 +3,9 @@
from __future__ import annotations
import asyncio
-from collections.abc import Mapping
+from collections.abc import AsyncGenerator
+from dataclasses import dataclass
from datetime import datetime
-from functools import partial
import hashlib
from http import HTTPStatus
import io
@@ -14,9 +14,8 @@ import mimetypes
import os
import re
import secrets
-import subprocess
-import tempfile
-from typing import Any, Final, TypedDict, final
+from time import monotonic
+from typing import Any, Final
from aiohttp import web
import mutagen
@@ -26,30 +25,24 @@ import voluptuous as vol
from homeassistant.components import ffmpeg, websocket_api
from homeassistant.components.http import HomeAssistantView
-from homeassistant.components.media_player import (
- ATTR_MEDIA_ANNOUNCE,
- ATTR_MEDIA_CONTENT_ID,
- ATTR_MEDIA_CONTENT_TYPE,
- DOMAIN as DOMAIN_MP,
- SERVICE_PLAY_MEDIA,
- MediaType,
-)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import (
- ATTR_ENTITY_ID,
- PLATFORM_FORMAT,
- STATE_UNAVAILABLE,
- STATE_UNKNOWN,
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP, PLATFORM_FORMAT
+from homeassistant.core import (
+ CALLBACK_TYPE,
+ Event,
+ HassJob,
+ HassJobType,
+ HomeAssistant,
+ ServiceCall,
+ callback,
)
-from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import get_url
-from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import UNDEFINED, ConfigType
-from homeassistant.util import dt as dt_util, language as language_util
+from homeassistant.util import language as language_util
from .const import (
ATTR_CACHE,
@@ -67,6 +60,7 @@ from .const import (
DOMAIN,
TtsAudioType,
)
+from .entity import TextToSpeechEntity, TTSAudioRequest
from .helper import get_engine_instance
from .legacy import PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, Provider, async_setup_legacy
from .media_source import generate_media_source_id, media_source_id_to_kwargs
@@ -83,12 +77,13 @@ __all__ = [
"PLATFORM_SCHEMA",
"PLATFORM_SCHEMA_BASE",
"Provider",
+ "ResultStream",
"SampleFormat",
+ "TextToSpeechEntity",
"TtsAudioType",
"Voice",
"async_default_engine",
"async_get_media_source_audio",
- "async_support_options",
"generate_media_source_id",
]
@@ -126,12 +121,94 @@ KEY_PATTERN = "{0}_{1}_{2}_{3}"
SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({})
-class TTSCache(TypedDict):
- """Cached TTS file."""
+class TTSCache:
+ """Cached bytes of a TTS result."""
- filename: str
- voice: bytes
- pending: asyncio.Task | None
+ _result_data: bytes | None = None
+ """When fully loaded, contains the result data."""
+
+ _partial_data: list[bytes] | None = None
+ """While loading, contains the data already received from the generator."""
+
+ _loading_error: Exception | None = None
+ """If an error occurred while loading, contains the error."""
+
+ _consumers: list[asyncio.Queue[bytes | None]] | None = None
+ """A queue for each current consumer to notify of new data while the generator is loading."""
+
+ def __init__(
+ self,
+ cache_key: str,
+ extension: str,
+ data_gen: AsyncGenerator[bytes],
+ ) -> None:
+ """Initialize the TTS cache."""
+ self.cache_key = cache_key
+ self.extension = extension
+ self.last_used = monotonic()
+ self._data_gen = data_gen
+
+ async def async_load_data(self) -> bytes:
+ """Load the data from the generator."""
+ if self._result_data is not None or self._partial_data is not None:
+ raise RuntimeError("Data already being loaded")
+
+ self._partial_data = []
+ self._consumers = []
+
+ try:
+ async for chunk in self._data_gen:
+ self._partial_data.append(chunk)
+ for queue in self._consumers:
+ queue.put_nowait(chunk)
+ except Exception as err:
+ self._loading_error = err
+ raise
+ finally:
+ for queue in self._consumers:
+ queue.put_nowait(None)
+ self._consumers = None
+
+ self._result_data = b"".join(self._partial_data)
+ self._partial_data = None
+ return self._result_data
+
+ async def async_stream_data(self) -> AsyncGenerator[bytes]:
+ """Stream the data.
+
+ Will return all data already returned from the generator.
+ Will listen for future data returned from the generator.
+ Raises error if one occurred.
+ """
+ if self._result_data is not None:
+ yield self._result_data
+ return
+ if self._loading_error:
+ raise self._loading_error
+
+ if self._partial_data is None:
+ raise RuntimeError("Data not being loaded")
+
+ queue: asyncio.Queue[bytes | None] | None = None
+ # Check if generator is still feeding data
+ if self._consumers is not None:
+ queue = asyncio.Queue()
+ self._consumers.append(queue)
+
+ for chunk in list(self._partial_data):
+ yield chunk
+
+ if self._loading_error:
+ raise self._loading_error
+
+ if queue is not None:
+ while (chunk2 := await queue.get()) is not None:
+ yield chunk2
+
+ if self._loading_error:
+ raise self._loading_error
+
+ self.last_used = monotonic()
@callback
@@ -169,22 +246,25 @@ def async_resolve_engine(hass: HomeAssistant, engine: str | None) -> str | None:
return async_default_engine(hass)
-async def async_support_options(
+@callback
+def async_create_stream(
hass: HomeAssistant,
engine: str,
language: str | None = None,
options: dict | None = None,
-) -> bool:
- """Return if an engine supports options."""
- if (engine_instance := get_engine_instance(hass, engine)) is None:
- raise HomeAssistantError(f"Provider {engine} not found")
+) -> ResultStream:
+ """Create a streaming URL where the rendered TTS can be retrieved."""
+ return hass.data[DATA_TTS_MANAGER].async_create_result_stream(
+ engine=engine,
+ language=language,
+ options=options,
+ )
- try:
- hass.data[DATA_TTS_MANAGER].process_options(engine_instance, language, options)
- except HomeAssistantError:
- return False
- return True
+@callback
+def async_get_stream(hass: HomeAssistant, token: str) -> ResultStream | None:
+ """Return a result stream given a token."""
+ return hass.data[DATA_TTS_MANAGER].token_to_stream.get(token)
async def async_get_media_source_audio(
@@ -192,9 +272,12 @@ async def async_get_media_source_audio(
media_source_id: str,
) -> tuple[str, bytes]:
"""Get TTS audio as extension, data."""
- return await hass.data[DATA_TTS_MANAGER].async_get_tts_audio(
- **media_source_id_to_kwargs(media_source_id),
+ manager = hass.data[DATA_TTS_MANAGER]
+ cache = manager.async_cache_message_in_memory(
+ **media_source_id_to_kwargs(media_source_id)
)
+ data = b"".join([chunk async for chunk in cache.async_stream_data()])
+ return cache.extension, data
@callback
@@ -213,89 +296,84 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]:
return languages
-async def async_convert_audio(
+async def _async_convert_audio(
hass: HomeAssistant,
from_extension: str,
- audio_bytes: bytes,
+ audio_bytes_gen: AsyncGenerator[bytes],
to_extension: str,
to_sample_rate: int | None = None,
to_sample_channels: int | None = None,
to_sample_bytes: int | None = None,
-) -> bytes:
+) -> AsyncGenerator[bytes]:
"""Convert audio to a preferred format using ffmpeg."""
ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass)
- return await hass.async_add_executor_job(
- lambda: _convert_audio(
- ffmpeg_manager.binary,
- from_extension,
- audio_bytes,
- to_extension,
- to_sample_rate=to_sample_rate,
- to_sample_channels=to_sample_channels,
- to_sample_bytes=to_sample_bytes,
- )
+
+ command = [
+ ffmpeg_manager.binary,
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-f",
+ from_extension,
+ "-i",
+ "pipe:",
+ "-f",
+ to_extension,
+ ]
+ if to_sample_rate is not None:
+ command.extend(["-ar", str(to_sample_rate)])
+ if to_sample_channels is not None:
+ command.extend(["-ac", str(to_sample_channels)])
+ if to_extension == "mp3":
+ # Max quality for MP3.
+ command.extend(["-q:a", "0"])
+ if to_sample_bytes == 2:
+ # 16-bit samples.
+ command.extend(["-sample_fmt", "s16"])
+ command.append("pipe:1") # Send output to stdout.
+
+ process = await asyncio.create_subprocess_exec(
+ *command,
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
)
+ async def write_input() -> None:
+ assert process.stdin
+ try:
+ async for chunk in audio_bytes_gen:
+ process.stdin.write(chunk)
+ await process.stdin.drain()
+ finally:
+ if process.stdin:
+ process.stdin.close()
-def _convert_audio(
- ffmpeg_binary: str,
- from_extension: str,
- audio_bytes: bytes,
- to_extension: str,
- to_sample_rate: int | None = None,
- to_sample_channels: int | None = None,
- to_sample_bytes: int | None = None,
-) -> bytes:
- """Convert audio to a preferred format using ffmpeg."""
+ writer_task = hass.async_create_background_task(
+ write_input(), "tts_ffmpeg_conversion"
+ )
- # We have to use a temporary file here because some formats like WAV store
- # the length of the file in the header, and therefore cannot be written in a
- # streaming fashion.
- with tempfile.NamedTemporaryFile(
- mode="wb+", suffix=f".{to_extension}"
- ) as output_file:
- # input
- command = [
- ffmpeg_binary,
- "-y", # overwrite temp file
- "-f",
- from_extension,
- "-i",
- "pipe:", # input from stdin
- ]
-
- # output
- command.extend(["-f", to_extension])
-
- if to_sample_rate is not None:
- command.extend(["-ar", str(to_sample_rate)])
-
- if to_sample_channels is not None:
- command.extend(["-ac", str(to_sample_channels)])
-
- if to_extension == "mp3":
- # Max quality for MP3
- command.extend(["-q:a", "0"])
-
- if to_sample_bytes == 2:
- # 16-bit samples
- command.extend(["-sample_fmt", "s16"])
-
- command.append(output_file.name)
-
- with subprocess.Popen(
- command, stdin=subprocess.PIPE, stderr=subprocess.PIPE
- ) as proc:
- _stdout, stderr = proc.communicate(input=audio_bytes)
- if proc.returncode != 0:
- _LOGGER.error(stderr.decode())
- raise RuntimeError(
- f"Unexpected error while running ffmpeg with arguments: {command}."
- "See log for details."
- )
-
- output_file.seek(0)
- return output_file.read()
+ assert process.stdout
+ chunk_size = 4096
+ try:
+ while True:
+ chunk = await process.stdout.read(chunk_size)
+ if not chunk:
+ break
+ yield chunk
+ finally:
+ # Ensure we wait for the input writer to complete.
+ await writer_task
+ # Wait for process termination and check for errors.
+ retcode = await process.wait()
+ if retcode != 0:
+ assert process.stderr
+ stderr_data = await process.stderr.read()
+ _LOGGER.error(stderr_data.decode())
+ raise RuntimeError(
+ f"Unexpected error while running ffmpeg with arguments: {command}. "
+ "See log for details."
+ )
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -306,11 +384,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Legacy config options
conf = config[DOMAIN][0] if config.get(DOMAIN) else {}
- use_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE)
+ use_file_cache: bool = conf.get(CONF_CACHE, DEFAULT_CACHE)
cache_dir: str = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR)
- time_memory: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY)
+ memory_cache_maxage: int = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY)
- tts = SpeechManager(hass, use_cache, cache_dir, time_memory)
+ tts = SpeechManager(hass, use_file_cache, cache_dir, memory_cache_maxage)
try:
await tts.async_init_cache()
@@ -375,140 +453,56 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
-CACHED_PROPERTIES_WITH_ATTR_ = {
- "default_language",
- "default_options",
- "supported_languages",
- "supported_options",
-}
+@dataclass
+class ResultStream:
+ """Class that will stream the result when available."""
+ # Streaming/conversion properties
+ token: str
+ extension: str
+ content_type: str
-class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
- """Represent a single TTS engine."""
+ # TTS properties
+ engine: str
+ use_file_cache: bool
+ language: str
+ options: dict
- _attr_should_poll = False
- __last_tts_loaded: str | None = None
-
- _attr_default_language: str
- _attr_default_options: Mapping[str, Any] | None = None
- _attr_supported_languages: list[str]
- _attr_supported_options: list[str] | None = None
-
- @property
- @final
- def state(self) -> str | None:
- """Return the state of the entity."""
- if self.__last_tts_loaded is None:
- return None
- return self.__last_tts_loaded
+ _manager: SpeechManager
@cached_property
- def supported_languages(self) -> list[str]:
- """Return a list of supported languages."""
- return self._attr_supported_languages
+ def url(self) -> str:
+ """Get the URL to stream the result."""
+ return f"/api/tts_proxy/{self.token}"
@cached_property
- def default_language(self) -> str:
- """Return the default language."""
- return self._attr_default_language
-
- @cached_property
- def supported_options(self) -> list[str] | None:
- """Return a list of supported options like voice, emotions."""
- return self._attr_supported_options
-
- @cached_property
- def default_options(self) -> Mapping[str, Any] | None:
- """Return a mapping with the default options."""
- return self._attr_default_options
+ def _result_cache(self) -> asyncio.Future[TTSCache]:
+ """Get the future that returns the cache."""
+ return asyncio.Future()
@callback
- def async_get_supported_voices(self, language: str) -> list[Voice] | None:
- """Return a list of supported voices for a language."""
- return None
+ def async_set_message_cache(self, cache: TTSCache) -> None:
+ """Set cache containing message audio to be streamed."""
+ self._result_cache.set_result(cache)
- async def async_internal_added_to_hass(self) -> None:
- """Call when the entity is added to hass."""
- await super().async_internal_added_to_hass()
- try:
- _ = self.default_language
- except AttributeError as err:
- raise AttributeError(
- "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property"
- ) from err
- try:
- _ = self.supported_languages
- except AttributeError as err:
- raise AttributeError(
- "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property"
- ) from err
- state = await self.async_get_last_state()
- if (
- state is not None
- and state.state is not None
- and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
- ):
- self.__last_tts_loaded = state.state
-
- async def async_speak(
- self,
- media_player_entity_id: list[str],
- message: str,
- cache: bool,
- language: str | None = None,
- options: dict | None = None,
- ) -> None:
- """Speak via a Media Player."""
- await self.hass.services.async_call(
- DOMAIN_MP,
- SERVICE_PLAY_MEDIA,
- {
- ATTR_ENTITY_ID: media_player_entity_id,
- ATTR_MEDIA_CONTENT_ID: generate_media_source_id(
- self.hass,
- message=message,
- engine=self.entity_id,
- language=language,
- options=options,
- cache=cache,
- ),
- ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
- ATTR_MEDIA_ANNOUNCE: True,
- },
- blocking=True,
- context=self._context,
+ @callback
+ def async_set_message(self, message: str) -> None:
+ """Set message to be generated."""
+ self._result_cache.set_result(
+ self._manager.async_cache_message_in_memory(
+ engine=self.engine,
+ message=message,
+ use_file_cache=self.use_file_cache,
+ language=self.language,
+ options=self.options,
+ )
)
- @final
- async def internal_async_get_tts_audio(
- self, message: str, language: str, options: dict[str, Any]
- ) -> TtsAudioType:
- """Process an audio stream to TTS service.
-
- Only streaming content is allowed!
- """
- self.__last_tts_loaded = dt_util.utcnow().isoformat()
- self.async_write_ha_state()
- return await self.async_get_tts_audio(
- message=message, language=language, options=options
- )
-
- def get_tts_audio(
- self, message: str, language: str, options: dict[str, Any]
- ) -> TtsAudioType:
- """Load tts audio file from the engine."""
- raise NotImplementedError
-
- async def async_get_tts_audio(
- self, message: str, language: str, options: dict[str, Any]
- ) -> TtsAudioType:
- """Load tts audio file from the engine.
-
- Return a tuple of file extension and data as bytes.
- """
- return await self.hass.async_add_executor_job(
- partial(self.get_tts_audio, message, language, options=options)
- )
+ async def async_stream_result(self) -> AsyncGenerator[bytes]:
+ """Get the stream of this result."""
+ cache = await self._result_cache
+ async for chunk in cache.async_stream_data():
+ yield chunk
def _hash_options(options: dict) -> str:
@@ -521,29 +515,82 @@ def _hash_options(options: dict) -> str:
return opts_hash.hexdigest()
+class MemcacheCleanup:
+ """Helper to clean up the stale sessions."""
+
+ unsub: CALLBACK_TYPE | None = None
+
+ def __init__(
+ self, hass: HomeAssistant, maxage: float, memcache: dict[str, TTSCache]
+ ) -> None:
+ """Initialize the cleanup."""
+ self.hass = hass
+ self.maxage = maxage
+ self.memcache = memcache
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop)
+ self.cleanup_job = HassJob(
+ self._cleanup, "chat_session_cleanup", job_type=HassJobType.Callback
+ )
+
+ @callback
+ def schedule(self) -> None:
+ """Schedule the cleanup."""
+ if self.unsub:
+ return
+ self.unsub = async_call_later(
+ self.hass,
+ self.maxage + 1,
+ self.cleanup_job,
+ )
+
+ @callback
+ def _on_hass_stop(self, event: Event) -> None:
+ """Cancel the cleanup on shutdown."""
+ if self.unsub:
+ self.unsub()
+ self.unsub = None
+
+ @callback
+ def _cleanup(self, _now: datetime) -> None:
+ """Clean up and schedule follow-up if necessary."""
+ self.unsub = None
+ memcache = self.memcache
+ maxage = self.maxage
+ now = monotonic()
+
+ for cache_key, info in list(memcache.items()):
+ if info.last_used + maxage < now:
+ _LOGGER.debug("Cleaning up %s", cache_key)
+ del memcache[cache_key]
+
+ # Still items left, check again in timeout time.
+ if memcache:
+ self.schedule()
+
+
class SpeechManager:
"""Representation of a speech store."""
def __init__(
self,
hass: HomeAssistant,
- use_cache: bool,
+ use_file_cache: bool,
cache_dir: str,
- time_memory: int,
+ memory_cache_maxage: int,
) -> None:
"""Initialize a speech store."""
self.hass = hass
self.providers: dict[str, Provider] = {}
- self.use_cache = use_cache
+ self.use_file_cache = use_file_cache
self.cache_dir = cache_dir
- self.time_memory = time_memory
+ self.memory_cache_maxage = memory_cache_maxage
self.file_cache: dict[str, str] = {}
self.mem_cache: dict[str, TTSCache] = {}
-
- # filename <-> token
- self.filename_to_token: dict[str, str] = {}
- self.token_to_filename: dict[str, str] = {}
+ self.token_to_stream: dict[str, ResultStream] = {}
+ self.memcache_cleanup = MemcacheCleanup(
+ hass, memory_cache_maxage, self.mem_cache
+ )
def _init_cache(self) -> dict[str, str]:
"""Init cache folder and fetch files."""
@@ -563,18 +610,21 @@ class SpeechManager:
async def async_clear_cache(self) -> None:
"""Read file cache and delete files."""
- self.mem_cache = {}
+ self.mem_cache.clear()
- def remove_files() -> None:
+ def remove_files(files: list[str]) -> None:
"""Remove files from filesystem."""
- for filename in self.file_cache.values():
+ for filename in files:
try:
os.remove(os.path.join(self.cache_dir, filename))
except OSError as err:
_LOGGER.warning("Can't remove cache file '%s': %s", filename, err)
- await self.hass.async_add_executor_job(remove_files)
- self.file_cache = {}
+ task = self.hass.async_add_executor_job(
+ remove_files, list(self.file_cache.values())
+ )
+ self.file_cache.clear()
+ await task
@callback
def async_register_legacy_engine(
@@ -629,110 +679,198 @@ class SpeechManager:
return language, merged_options
- async def async_get_url_path(
+ @callback
+ def async_create_result_stream(
self,
engine: str,
- message: str,
- cache: bool | None = None,
+ message: str | None = None,
+ use_file_cache: bool | None = None,
language: str | None = None,
options: dict | None = None,
- ) -> str:
- """Get URL for play message.
-
- This method is a coroutine.
- """
+ ) -> ResultStream:
+ """Create a streaming URL where the rendered TTS can be retrieved."""
if (engine_instance := get_engine_instance(self.hass, engine)) is None:
raise HomeAssistantError(f"Provider {engine} not found")
language, options = self.process_options(engine_instance, language, options)
- cache_key = self._generate_cache_key(message, language, options, engine)
- use_cache = cache if cache is not None else self.use_cache
+ if use_file_cache is None:
+ use_file_cache = self.use_file_cache
- # Is speech already in memory
- if cache_key in self.mem_cache:
- filename = self.mem_cache[cache_key]["filename"]
- # Is file store in file cache
- elif use_cache and cache_key in self.file_cache:
- filename = self.file_cache[cache_key]
- self.hass.async_create_task(self._async_file_to_mem(cache_key))
- # Load speech from engine into memory
- else:
- filename = await self._async_get_tts_audio(
- engine_instance, cache_key, message, use_cache, language, options
+ extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT)
+ token = f"{secrets.token_urlsafe(16)}.{extension}"
+ content, _ = mimetypes.guess_type(token)
+ result_stream = ResultStream(
+ token=token,
+ extension=extension,
+ content_type=content or "audio/mpeg",
+ use_file_cache=use_file_cache,
+ engine=engine,
+ language=language,
+ options=options,
+ _manager=self,
+ )
+ self.token_to_stream[token] = result_stream
+
+ if message is None:
+ return result_stream
+
+ # We added this method as an alternative to stream.async_set_message
+ # to avoid the options being processed twice
+ result_stream.async_set_message_cache(
+ self._async_ensure_cached_in_memory(
+ engine=engine,
+ engine_instance=engine_instance,
+ message=message,
+ use_file_cache=use_file_cache,
+ language=language,
+ options=options,
)
+ )
- # Use a randomly generated token instead of exposing the filename
- token = self.filename_to_token.get(filename)
- if not token:
- # Keep extension (.mp3, etc.)
- token = secrets.token_urlsafe(16) + os.path.splitext(filename)[1]
-
- # Map token <-> filename
- self.filename_to_token[filename] = token
- self.token_to_filename[token] = filename
-
- return f"/api/tts_proxy/{token}"
-
- async def async_get_tts_audio(
- self,
- engine: str,
- message: str,
- cache: bool | None = None,
- language: str | None = None,
- options: dict | None = None,
- ) -> tuple[str, bytes]:
- """Fetch TTS audio."""
- if (engine_instance := get_engine_instance(self.hass, engine)) is None:
- raise HomeAssistantError(f"Provider {engine} not found")
-
- language, options = self.process_options(engine_instance, language, options)
- cache_key = self._generate_cache_key(message, language, options, engine)
- use_cache = cache if cache is not None else self.use_cache
-
- # If we have the file, load it into memory if necessary
- if cache_key not in self.mem_cache:
- if use_cache and cache_key in self.file_cache:
- await self._async_file_to_mem(cache_key)
- else:
- await self._async_get_tts_audio(
- engine_instance, cache_key, message, use_cache, language, options
- )
-
- extension = os.path.splitext(self.mem_cache[cache_key]["filename"])[1][1:]
- cached = self.mem_cache[cache_key]
- if pending := cached.get("pending"):
- await pending
- cached = self.mem_cache[cache_key]
- return extension, cached["voice"]
+ return result_stream
@callback
- def _generate_cache_key(
+ def async_cache_message_in_memory(
self,
- message: str,
- language: str,
- options: dict | None,
engine: str,
- ) -> str:
- """Generate a cache key for a message."""
+ message: str,
+ use_file_cache: bool | None = None,
+ language: str | None = None,
+ options: dict | None = None,
+ ) -> TTSCache:
+ """Make sure a message is cached in memory and returns cache key."""
+ if (engine_instance := get_engine_instance(self.hass, engine)) is None:
+ raise HomeAssistantError(f"Provider {engine} not found")
+
+ language, options = self.process_options(engine_instance, language, options)
+ if use_file_cache is None:
+ use_file_cache = self.use_file_cache
+
+ return self._async_ensure_cached_in_memory(
+ engine=engine,
+ engine_instance=engine_instance,
+ message=message,
+ use_file_cache=use_file_cache,
+ language=language,
+ options=options,
+ )
+
+ @callback
+ def _async_ensure_cached_in_memory(
+ self,
+ engine: str,
+ engine_instance: TextToSpeechEntity | Provider,
+ message: str,
+ use_file_cache: bool,
+ language: str,
+ options: dict,
+ ) -> TTSCache:
+ """Ensure a message is cached.
+
+ Requires options, language to be processed.
+ """
options_key = _hash_options(options) if options else "-"
msg_hash = hashlib.sha1(bytes(message, "utf-8")).hexdigest()
- return KEY_PATTERN.format(
+ cache_key = KEY_PATTERN.format(
msg_hash, language.replace("_", "-"), options_key, engine
).lower()
- async def _async_get_tts_audio(
+ # Is speech already in memory
+ if cache := self.mem_cache.get(cache_key):
+ _LOGGER.debug("Found audio in cache for %s", message[0:32])
+ return cache
+
+ store_to_disk = use_file_cache
+
+ if use_file_cache and (filename := self.file_cache.get(cache_key)):
+ _LOGGER.debug("Loading audio from disk for %s", message[0:32])
+ extension = os.path.splitext(filename)[1][1:]
+ data_gen = self._async_load_file(cache_key)
+ store_to_disk = False
+ else:
+ _LOGGER.debug("Generating audio for %s", message[0:32])
+ extension = options.get(ATTR_PREFERRED_FORMAT, _DEFAULT_FORMAT)
+ data_gen = self._async_generate_tts_audio(
+ engine_instance, message, language, options
+ )
+
+ cache = TTSCache(
+ cache_key=cache_key,
+ extension=extension,
+ data_gen=data_gen,
+ )
+
+ self.mem_cache[cache_key] = cache
+ self.hass.async_create_background_task(
+ self._load_data_into_cache(
+ cache, engine_instance, message, store_to_disk, language, options
+ ),
+ f"tts_load_data_into_cache_{engine_instance.name}",
+ )
+ self.memcache_cleanup.schedule()
+ return cache
+
+ async def _load_data_into_cache(
+ self,
+ cache: TTSCache,
+ engine_instance: TextToSpeechEntity | Provider,
+ message: str,
+ store_to_disk: bool,
+ language: str,
+ options: dict,
+ ) -> None:
+ """Load and process a finished loading TTS Cache."""
+ try:
+ data = await cache.async_load_data()
+ except Exception as err: # pylint: disable=broad-except # noqa: BLE001
+ # Truncate message so we don't flood the logs. Cutting off at 32 chars
+ # but since we add 3 dots to truncated message, we cut off at 35.
+ trunc_msg = message if len(message) < 35 else f"{message[0:32]}…"
+ _LOGGER.error("Error getting audio for %s: %s", trunc_msg, err)
+ self.mem_cache.pop(cache.cache_key, None)
+ return
+
+ if not store_to_disk:
+ return
+
+ filename = f"{cache.cache_key}.{cache.extension}".lower()
+
+ # Validate filename
+ if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match(
+ filename
+ ):
+ raise HomeAssistantError(
+ f"TTS filename '{filename}' from {engine_instance.name} is invalid!"
+ )
+
+ if cache.extension == "mp3":
+ name = (
+ engine_instance.name if isinstance(engine_instance.name, str) else "-"
+ )
+ data = self.write_tags(filename, data, name, message, language, options)
+
+ voice_file = os.path.join(self.cache_dir, filename)
+
+ def save_speech() -> None:
+ """Store speech to filesystem."""
+ with open(voice_file, "wb") as speech:
+ speech.write(data)
+
+ try:
+ await self.hass.async_add_executor_job(save_speech)
+ except OSError as err:
+ _LOGGER.error("Can't write %s: %s", filename, err)
+ else:
+ self.file_cache[cache.cache_key] = filename
+
+ async def _async_generate_tts_audio(
self,
engine_instance: TextToSpeechEntity | Provider,
- cache_key: str,
message: str,
- cache: bool,
language: str,
options: dict[str, Any],
- ) -> str:
- """Receive TTS, store for view in cache and return filename.
-
- This method is a coroutine.
- """
+ ) -> AsyncGenerator[bytes]:
+ """Generate TTS audio from an engine."""
options = dict(options or {})
supported_options = engine_instance.supported_options or []
@@ -773,114 +911,61 @@ class SpeechManager:
if sample_bytes is not None:
sample_bytes = int(sample_bytes)
- async def get_tts_data() -> str:
- """Handle data available."""
- if engine_instance.name is None or engine_instance.name is UNDEFINED:
- raise HomeAssistantError("TTS engine name is not set.")
+ if engine_instance.name is None or engine_instance.name is UNDEFINED:
+ raise HomeAssistantError("TTS engine name is not set.")
- if isinstance(engine_instance, Provider):
- extension, data = await engine_instance.async_get_tts_audio(
- message, language, options
- )
- else:
- extension, data = await engine_instance.internal_async_get_tts_audio(
- message, language, options
- )
+ if isinstance(engine_instance, Provider):
+ extension, data = await engine_instance.async_get_tts_audio(
+ message, language, options
+ )
if data is None or extension is None:
raise HomeAssistantError(
f"No TTS from {engine_instance.name} for '{message}'"
)
- # Only convert if we have a preferred format different than the
- # expected format from the TTS system, or if a specific sample
- # rate/format/channel count is requested.
- needs_conversion = (
- (final_extension != extension)
- or (sample_rate is not None)
- or (sample_channels is not None)
- or (sample_bytes is not None)
+ async def make_data_generator(data: bytes) -> AsyncGenerator[bytes]:
+ yield data
+
+ data_gen = make_data_generator(data)
+
+ else:
+
+ async def message_gen() -> AsyncGenerator[str]:
+ yield message
+
+ tts_result = await engine_instance.internal_async_stream_tts_audio(
+ TTSAudioRequest(language, options, message_gen())
+ )
+ extension = tts_result.extension
+ data_gen = tts_result.data_gen
+
+ # Only convert if we have a preferred format different than the
+ # expected format from the TTS system, or if a specific sample
+ # rate/format/channel count is requested.
+ needs_conversion = (
+ (final_extension != extension)
+ or (sample_rate is not None)
+ or (sample_channels is not None)
+ or (sample_bytes is not None)
+ )
+
+ if needs_conversion:
+ data_gen = _async_convert_audio(
+ self.hass,
+ extension,
+ data_gen,
+ to_extension=final_extension,
+ to_sample_rate=sample_rate,
+ to_sample_channels=sample_channels,
+ to_sample_bytes=sample_bytes,
)
- if needs_conversion:
- data = await async_convert_audio(
- self.hass,
- extension,
- data,
- to_extension=final_extension,
- to_sample_rate=sample_rate,
- to_sample_channels=sample_channels,
- to_sample_bytes=sample_bytes,
- )
+ async for chunk in data_gen:
+ yield chunk
- # Create file infos
- filename = f"{cache_key}.{final_extension}".lower()
-
- # Validate filename
- if not _RE_VOICE_FILE.match(filename) and not _RE_LEGACY_VOICE_FILE.match(
- filename
- ):
- raise HomeAssistantError(
- f"TTS filename '{filename}' from {engine_instance.name} is invalid!"
- )
-
- # Save to memory
- if final_extension == "mp3":
- data = self.write_tags(
- filename, data, engine_instance.name, message, language, options
- )
-
- self._async_store_to_memcache(cache_key, filename, data)
-
- if cache:
- self.hass.async_create_task(
- self._async_save_tts_audio(cache_key, filename, data)
- )
-
- return filename
-
- audio_task = self.hass.async_create_task(get_tts_data(), eager_start=False)
-
- def handle_error(_future: asyncio.Future) -> None:
- """Handle error."""
- if audio_task.exception():
- self.mem_cache.pop(cache_key, None)
-
- audio_task.add_done_callback(handle_error)
-
- filename = f"{cache_key}.{final_extension}".lower()
- self.mem_cache[cache_key] = {
- "filename": filename,
- "voice": b"",
- "pending": audio_task,
- }
- return filename
-
- async def _async_save_tts_audio(
- self, cache_key: str, filename: str, data: bytes
- ) -> None:
- """Store voice data to file and file_cache.
-
- This method is a coroutine.
- """
- voice_file = os.path.join(self.cache_dir, filename)
-
- def save_speech() -> None:
- """Store speech to filesystem."""
- with open(voice_file, "wb") as speech:
- speech.write(data)
-
- try:
- await self.hass.async_add_executor_job(save_speech)
- self.file_cache[cache_key] = filename
- except OSError as err:
- _LOGGER.error("Can't write %s: %s", filename, err)
-
- async def _async_file_to_mem(self, cache_key: str) -> None:
- """Load voice from file cache into memory.
-
- This method is a coroutine.
- """
+ async def _async_load_file(self, cache_key: str) -> AsyncGenerator[bytes]:
+ """Load TTS audio from disk."""
if not (filename := self.file_cache.get(cache_key)):
raise HomeAssistantError(f"Key {cache_key} not in file cache!")
@@ -897,64 +982,7 @@ class SpeechManager:
del self.file_cache[cache_key]
raise HomeAssistantError(f"Can't read {voice_file}") from err
- self._async_store_to_memcache(cache_key, filename, data)
-
- @callback
- def _async_store_to_memcache(
- self, cache_key: str, filename: str, data: bytes
- ) -> None:
- """Store data to memcache and set timer to remove it."""
- self.mem_cache[cache_key] = {
- "filename": filename,
- "voice": data,
- "pending": None,
- }
-
- @callback
- def async_remove_from_mem(_: datetime) -> None:
- """Cleanup memcache."""
- self.mem_cache.pop(cache_key, None)
-
- async_call_later(
- self.hass,
- self.time_memory,
- HassJob(
- async_remove_from_mem,
- name="tts remove_from_mem",
- cancel_on_shutdown=True,
- ),
- )
-
- async def async_read_tts(self, token: str) -> tuple[str | None, bytes]:
- """Read a voice file and return binary.
-
- This method is a coroutine.
- """
- filename = self.token_to_filename.get(token)
- if not filename:
- raise HomeAssistantError(f"{token} was not recognized!")
-
- if not (record := _RE_VOICE_FILE.match(filename.lower())) and not (
- record := _RE_LEGACY_VOICE_FILE.match(filename.lower())
- ):
- raise HomeAssistantError("Wrong tts file format!")
-
- cache_key = KEY_PATTERN.format(
- record.group(1), record.group(2), record.group(3), record.group(4)
- )
-
- if cache_key not in self.mem_cache:
- if cache_key not in self.file_cache:
- raise HomeAssistantError(f"{cache_key} not in cache!")
- await self._async_file_to_mem(cache_key)
-
- cached = self.mem_cache[cache_key]
- if pending := cached.get("pending"):
- await pending
- cached = self.mem_cache[cache_key]
-
- content, _ = mimetypes.guess_type(filename)
- return content, cached["voice"]
+ yield data
@staticmethod
def write_tags(
@@ -1042,9 +1070,9 @@ class TextToSpeechUrlView(HomeAssistantView):
url = "/api/tts_get_url"
name = "api:tts:geturl"
- def __init__(self, tts: SpeechManager) -> None:
+ def __init__(self, manager: SpeechManager) -> None:
"""Initialize a tts view."""
- self.tts = tts
+ self.manager = manager
async def post(self, request: web.Request) -> web.Response:
"""Generate speech and provide url."""
@@ -1061,45 +1089,65 @@ class TextToSpeechUrlView(HomeAssistantView):
engine = data.get("engine_id") or data[ATTR_PLATFORM]
message = data[ATTR_MESSAGE]
- cache = data.get(ATTR_CACHE)
+ use_file_cache = data.get(ATTR_CACHE)
language = data.get(ATTR_LANGUAGE)
options = data.get(ATTR_OPTIONS)
try:
- path = await self.tts.async_get_url_path(
- engine, message, cache=cache, language=language, options=options
+ stream = self.manager.async_create_result_stream(
+ engine,
+ message,
+ use_file_cache=use_file_cache,
+ language=language,
+ options=options,
)
except HomeAssistantError as err:
_LOGGER.error("Error on init tts: %s", err)
return self.json({"error": err}, HTTPStatus.BAD_REQUEST)
- base = get_url(self.tts.hass)
- url = base + path
+ base = get_url(self.manager.hass)
+ url = base + stream.url
- return self.json({"url": url, "path": path})
+ return self.json({"url": url, "path": stream.url})
class TextToSpeechView(HomeAssistantView):
"""TTS view to serve a speech audio."""
requires_auth = False
- url = "/api/tts_proxy/{filename}"
+ url = "/api/tts_proxy/{token}"
name = "api:tts_speech"
- def __init__(self, tts: SpeechManager) -> None:
+ def __init__(self, manager: SpeechManager) -> None:
"""Initialize a tts view."""
- self.tts = tts
+ self.manager = manager
- async def get(self, request: web.Request, filename: str) -> web.Response:
+ async def get(self, request: web.Request, token: str) -> web.StreamResponse:
"""Start a get request."""
- try:
- # filename is actually token, but we keep its name for compatibility
- content, data = await self.tts.async_read_tts(filename)
- except HomeAssistantError as err:
- _LOGGER.error("Error on load tts: %s", err)
+ stream = self.manager.token_to_stream.get(token)
+
+ if stream is None:
return web.Response(status=HTTPStatus.NOT_FOUND)
- return web.Response(body=data, content_type=content)
+ response: web.StreamResponse | None = None
+ try:
+ async for data in stream.async_stream_result():
+ if response is None:
+ response = web.StreamResponse()
+ response.content_type = stream.content_type
+ await response.prepare(request)
+
+ await response.write(data)
+ # pylint: disable=broad-except
+ except Exception as err: # noqa: BLE001
+ _LOGGER.error("Error streaming tts: %s", err)
+
+ # Empty result or exception happened
+ if response is None:
+ return web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR)
+
+ await response.write_eof()
+ return response
@websocket_api.websocket_command(
diff --git a/homeassistant/components/tts/entity.py b/homeassistant/components/tts/entity.py
new file mode 100644
index 00000000000..199d673398e
--- /dev/null
+++ b/homeassistant/components/tts/entity.py
@@ -0,0 +1,196 @@
+"""Entity for Text-to-Speech."""
+
+from collections.abc import AsyncGenerator, Mapping
+from dataclasses import dataclass
+from functools import partial
+from typing import Any, final
+
+from propcache.api import cached_property
+
+from homeassistant.components.media_player import (
+ ATTR_MEDIA_ANNOUNCE,
+ ATTR_MEDIA_CONTENT_ID,
+ ATTR_MEDIA_CONTENT_TYPE,
+ DOMAIN as DOMAIN_MP,
+ SERVICE_PLAY_MEDIA,
+ MediaType,
+)
+from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.util import dt as dt_util
+
+from .const import TtsAudioType
+from .media_source import generate_media_source_id
+from .models import Voice
+
+CACHED_PROPERTIES_WITH_ATTR_ = {
+ "default_language",
+ "default_options",
+ "supported_languages",
+ "supported_options",
+}
+
+
+@dataclass
+class TTSAudioRequest:
+ """Request to get TTS audio."""
+
+ language: str
+ options: dict[str, Any]
+ message_gen: AsyncGenerator[str]
+
+
+@dataclass
+class TTSAudioResponse:
+ """Response containing TTS audio stream."""
+
+ extension: str
+ data_gen: AsyncGenerator[bytes]
+
+
+class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
+ """Represent a single TTS engine."""
+
+ _attr_should_poll = False
+ __last_tts_loaded: str | None = None
+
+ _attr_default_language: str
+ _attr_default_options: Mapping[str, Any] | None = None
+ _attr_supported_languages: list[str]
+ _attr_supported_options: list[str] | None = None
+
+ @property
+ @final
+ def state(self) -> str | None:
+ """Return the state of the entity."""
+ if self.__last_tts_loaded is None:
+ return None
+ return self.__last_tts_loaded
+
+ @cached_property
+ def supported_languages(self) -> list[str]:
+ """Return a list of supported languages."""
+ return self._attr_supported_languages
+
+ @cached_property
+ def default_language(self) -> str:
+ """Return the default language."""
+ return self._attr_default_language
+
+ @cached_property
+ def supported_options(self) -> list[str] | None:
+ """Return a list of supported options like voice, emotions."""
+ return self._attr_supported_options
+
+ @cached_property
+ def default_options(self) -> Mapping[str, Any] | None:
+ """Return a mapping with the default options."""
+ return self._attr_default_options
+
+ @callback
+ def async_get_supported_voices(self, language: str) -> list[Voice] | None:
+ """Return a list of supported voices for a language."""
+ return None
+
+ async def async_internal_added_to_hass(self) -> None:
+ """Call when the entity is added to hass."""
+ await super().async_internal_added_to_hass()
+ try:
+ _ = self.default_language
+ except AttributeError as err:
+ raise AttributeError(
+ "TTS entities must either set the '_attr_default_language' attribute or override the 'default_language' property"
+ ) from err
+ try:
+ _ = self.supported_languages
+ except AttributeError as err:
+ raise AttributeError(
+ "TTS entities must either set the '_attr_supported_languages' attribute or override the 'supported_languages' property"
+ ) from err
+ state = await self.async_get_last_state()
+ if (
+ state is not None
+ and state.state is not None
+ and state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
+ ):
+ self.__last_tts_loaded = state.state
+
+ async def async_speak(
+ self,
+ media_player_entity_id: list[str],
+ message: str,
+ cache: bool,
+ language: str | None = None,
+ options: dict | None = None,
+ ) -> None:
+ """Speak via a Media Player."""
+ await self.hass.services.async_call(
+ DOMAIN_MP,
+ SERVICE_PLAY_MEDIA,
+ {
+ ATTR_ENTITY_ID: media_player_entity_id,
+ ATTR_MEDIA_CONTENT_ID: generate_media_source_id(
+ self.hass,
+ message=message,
+ engine=self.entity_id,
+ language=language,
+ options=options,
+ cache=cache,
+ ),
+ ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
+ ATTR_MEDIA_ANNOUNCE: True,
+ },
+ blocking=True,
+ context=self._context,
+ )
+
+ @final
+ async def internal_async_stream_tts_audio(
+ self, request: TTSAudioRequest
+ ) -> TTSAudioResponse:
+ """Process an audio stream to TTS service.
+
+ Only streaming content is allowed!
+ """
+ self.__last_tts_loaded = dt_util.utcnow().isoformat()
+ self.async_write_ha_state()
+ return await self.async_stream_tts_audio(request)
+
+ async def async_stream_tts_audio(
+ self, request: TTSAudioRequest
+ ) -> TTSAudioResponse:
+ """Generate speech from an incoming message.
+
+ The default implementation is backwards compatible with async_get_tts_audio.
+ """
+ message = "".join([chunk async for chunk in request.message_gen])
+ extension, data = await self.async_get_tts_audio(
+ message, request.language, request.options
+ )
+
+ if extension is None or data is None:
+ raise HomeAssistantError(f"No TTS from {self.entity_id} for '{message}'")
+
+ async def data_gen() -> AsyncGenerator[bytes]:
+ yield data
+
+ return TTSAudioResponse(extension, data_gen())
+
+ def get_tts_audio(
+ self, message: str, language: str, options: dict[str, Any]
+ ) -> TtsAudioType:
+ """Load tts audio file from the engine."""
+ raise NotImplementedError
+
+ async def async_get_tts_audio(
+ self, message: str, language: str, options: dict[str, Any]
+ ) -> TtsAudioType:
+ """Load tts audio file from the engine.
+
+ Return a tuple of file extension and data as bytes.
+ """
+ return await self.hass.async_add_executor_job(
+ partial(self.get_tts_audio, message, language, options=options)
+ )
diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py
index 4f1fa59f001..aa2cd6e7555 100644
--- a/homeassistant/components/tts/media_source.py
+++ b/homeassistant/components/tts/media_source.py
@@ -3,7 +3,6 @@
from __future__ import annotations
import json
-import mimetypes
from typing import TypedDict
from yarl import URL
@@ -73,7 +72,7 @@ class MediaSourceOptions(TypedDict):
message: str
language: str | None
options: dict | None
- cache: bool | None
+ use_file_cache: bool | None
@callback
@@ -98,10 +97,10 @@ def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions:
"message": parsed.query["message"],
"language": parsed.query.get("language"),
"options": options,
- "cache": None,
+ "use_file_cache": None,
}
if "cache" in parsed.query:
- kwargs["cache"] = parsed.query["cache"] == "true"
+ kwargs["use_file_cache"] = parsed.query["cache"] == "true"
return kwargs
@@ -119,7 +118,7 @@ class TTSMediaSource(MediaSource):
async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia:
"""Resolve media to a url."""
try:
- url = await self.hass.data[DATA_TTS_MANAGER].async_get_url_path(
+ stream = self.hass.data[DATA_TTS_MANAGER].async_create_result_stream(
**media_source_id_to_kwargs(item.identifier)
)
except Unresolvable:
@@ -127,9 +126,7 @@ class TTSMediaSource(MediaSource):
except HomeAssistantError as err:
raise Unresolvable(str(err)) from err
- mime_type = mimetypes.guess_type(url)[0] or "audio/mpeg"
-
- return PlayMedia(url, mime_type)
+ return PlayMedia(stream.url, stream.content_type)
async def async_browse_media(
self,
diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py
index c8a639cd239..32119add5f4 100644
--- a/homeassistant/components/tuya/__init__.py
+++ b/homeassistant/components/tuya/__init__.py
@@ -3,7 +3,8 @@
from __future__ import annotations
import logging
-from typing import Any, NamedTuple
+from typing import TYPE_CHECKING, Any, NamedTuple
+from urllib.parse import urlsplit
from tuya_sharing import (
CustomerDevice,
@@ -11,6 +12,7 @@ from tuya_sharing import (
SharingDeviceListener,
SharingTokenListener,
)
+from tuya_sharing.mq import SharingMQ, SharingMQConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
@@ -45,13 +47,81 @@ class HomeAssistantTuyaData(NamedTuple):
listener: SharingDeviceListener
+if TYPE_CHECKING:
+ import paho.mqtt.client as mqtt
+
+
+class ManagerCompat(Manager):
+ """Extended Manager class from the Tuya device sharing SDK.
+
+ The extension ensures compatibility a paho-mqtt client version >= 2.1.0.
+ It overrides extend refresh_mq method to ensure correct paho.mqtt client calls.
+
+ This code can be removed when a version of tuya-device-sharing with
+ https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available.
+ """
+
+ def refresh_mq(self):
+ """Refresh the MQTT connection."""
+ if self.mq is not None:
+ self.mq.stop()
+ self.mq = None
+
+ home_ids = [home.id for home in self.user_homes]
+ device = [
+ device
+ for device in self.device_map.values()
+ if hasattr(device, "id") and getattr(device, "set_up", False)
+ ]
+
+ sharing_mq = SharingMQCompat(self.customer_api, home_ids, device)
+ sharing_mq.start()
+ sharing_mq.add_message_listener(self.on_message)
+ self.mq = sharing_mq
+
+
+class SharingMQCompat(SharingMQ):
+ """Extended SharingMQ class from the Tuya device sharing SDK.
+
+ The extension ensures compatibility a paho-mqtt client version >= 2.1.0.
+ It overrides _start method to ensure correct paho.mqtt client calls.
+
+ This code can be removed when a version of tuya-device-sharing with
+ https://github.com/tuya/tuya-device-sharing-sdk/pull/25 is available.
+ """
+
+ def _start(self, mq_config: SharingMQConfig) -> mqtt.Client:
+ """Start the MQTT client."""
+ # We don't import on the top because some integrations
+ # should be able to optionally rely on MQTT.
+ import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel
+
+ mqttc = mqtt.Client(client_id=mq_config.client_id)
+ mqttc.username_pw_set(mq_config.username, mq_config.password)
+ mqttc.user_data_set({"mqConfig": mq_config})
+ mqttc.on_connect = self._on_connect
+ mqttc.on_message = self._on_message
+ mqttc.on_subscribe = self._on_subscribe
+ mqttc.on_log = self._on_log
+ mqttc.on_disconnect = self._on_disconnect
+
+ url = urlsplit(mq_config.url)
+ if url.scheme == "ssl":
+ mqttc.tls_set()
+
+ mqttc.connect(url.hostname, url.port)
+
+ mqttc.loop_start()
+ return mqttc
+
+
async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool:
"""Async setup hass config entry."""
if CONF_APP_TYPE in entry.data:
raise ConfigEntryAuthFailed("Authentication failed. Please re-authenticate.")
token_listener = TokenListener(hass, entry)
- manager = Manager(
+ manager = ManagerCompat(
TUYA_CLIENT_ID,
entry.data[CONF_USER_CODE],
entry.data[CONF_TERMINAL_ID],
diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py
index 56bccc73581..96f7d3a1e1c 100644
--- a/homeassistant/components/tuya/alarm_control_panel.py
+++ b/homeassistant/components/tuya/alarm_control_panel.py
@@ -14,7 +14,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
@@ -53,7 +53,9 @@ ALARM: dict[str, tuple[AlarmControlPanelEntityDescription, ...]] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya alarm dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py
index 12661a26fd1..486dd6e1387 100644
--- a/homeassistant/components/tuya/binary_sensor.py
+++ b/homeassistant/components/tuya/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode
@@ -256,7 +256,7 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
TuyaBinarySensorEntityDescription(
key=DPCode.WATERSENSOR_STATE,
device_class=BinarySensorDeviceClass.MOISTURE,
- on_value="alarm",
+ on_value={"1", "alarm"},
),
TAMPER_BINARY_SENSOR,
),
@@ -291,6 +291,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
# Temperature and Humidity Sensor
# https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34
"wsdcg": (TAMPER_BINARY_SENSOR,),
+ # Temperature and Humidity Sensor with External Probe
+ # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472
+ "qxj": (TAMPER_BINARY_SENSOR,),
# Pressure Sensor
# https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm
"ylcg": (
@@ -341,7 +344,9 @@ BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya binary sensor dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py
index f77fed776b0..8e538b07309 100644
--- a/homeassistant/components/tuya/button.py
+++ b/homeassistant/components/tuya/button.py
@@ -8,7 +8,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode
@@ -58,7 +58,9 @@ BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya buttons dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py
index 9e66531dd51..c04a8a043dc 100644
--- a/homeassistant/components/tuya/camera.py
+++ b/homeassistant/components/tuya/camera.py
@@ -8,7 +8,7 @@ from homeassistant.components import ffmpeg
from homeassistant.components.camera import Camera as CameraEntity, CameraEntityFeature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode
@@ -20,11 +20,16 @@ CAMERAS: tuple[str, ...] = (
# Smart Camera (including doorbells)
# https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu
"sp",
+ # Smart Camera - Low power consumption camera
+ # Undocumented, see https://github.com/home-assistant/core/issues/132844
+ "dghsxj",
)
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya cameras dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py
index 1780256a740..deccb08c5aa 100644
--- a/homeassistant/components/tuya/climate.py
+++ b/homeassistant/components/tuya/climate.py
@@ -21,7 +21,7 @@ from homeassistant.components.climate import (
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
@@ -84,7 +84,9 @@ CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya climate dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py
index 08bdef474ef..a40260ed787 100644
--- a/homeassistant/components/tuya/const.py
+++ b/homeassistant/components/tuya/const.py
@@ -333,6 +333,12 @@ class DPCode(StrEnum):
TEMP_CONTROLLER = "temp_controller"
TEMP_CURRENT = "temp_current" # Current temperature in °C
TEMP_CURRENT_F = "temp_current_f" # Current temperature in °F
+ TEMP_CURRENT_EXTERNAL = (
+ "temp_current_external" # Current external temperature in Celsius
+ )
+ TEMP_CURRENT_EXTERNAL_F = (
+ "temp_current_external_f" # Current external temperature in Fahrenheit
+ )
TEMP_INDOOR = "temp_indoor" # Indoor temperature in °C
TEMP_SET = "temp_set" # Set the temperature in °C
TEMP_SET_F = "temp_set_f" # Set the temperature in °F
diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py
index 9c3269c27f2..315075e7f37 100644
--- a/homeassistant/components/tuya/cover.py
+++ b/homeassistant/components/tuya/cover.py
@@ -17,7 +17,7 @@ from homeassistant.components.cover import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
@@ -142,7 +142,9 @@ COVERS: dict[str, tuple[TuyaCoverEntityDescription, ...]] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya cover dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py
index ffab9efdde8..3b951e75da1 100644
--- a/homeassistant/components/tuya/fan.py
+++ b/homeassistant/components/tuya/fan.py
@@ -14,7 +14,7 @@ from homeassistant.components.fan import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
@@ -34,7 +34,9 @@ TUYA_SUPPORT_TYPE = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tuya fan dynamically through tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py
index cb872d67719..6c47148eeda 100644
--- a/homeassistant/components/tuya/humidifier.py
+++ b/homeassistant/components/tuya/humidifier.py
@@ -14,7 +14,7 @@ from homeassistant.components.humidifier import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
@@ -55,7 +55,9 @@ HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya (de)humidifier dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py
index d7dffc16b58..67a94c4e267 100644
--- a/homeassistant/components/tuya/light.py
+++ b/homeassistant/components/tuya/light.py
@@ -20,7 +20,7 @@ from homeassistant.components.light import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from . import TuyaConfigEntry
@@ -327,6 +327,18 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = {
brightness_min=DPCode.BRIGHTNESS_MIN_1,
),
),
+ # Outdoor Flood Light
+ # Not documented
+ "tyd": (
+ TuyaLightEntityDescription(
+ key=DPCode.SWITCH_LED,
+ name=None,
+ color_mode=DPCode.WORK_MODE,
+ brightness=DPCode.BRIGHT_VALUE,
+ color_temp=DPCode.TEMP_VALUE,
+ color_data=DPCode.COLOUR_DATA,
+ ),
+ ),
# Solar Light
# https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98
"tyndj": (
@@ -392,6 +404,10 @@ LIGHTS["cz"] = LIGHTS["kg"]
# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
LIGHTS["pc"] = LIGHTS["kg"]
+# Smart Camera - Low power consumption camera (duplicate of `sp`)
+# Undocumented, see https://github.com/home-assistant/core/issues/132844
+LIGHTS["dghsxj"] = LIGHTS["sp"]
+
# Dimmer (duplicate of `tgq`)
# https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4
LIGHTS["tdq"] = LIGHTS["tgq"]
@@ -421,7 +437,9 @@ class ColorData:
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tuya light dynamically through tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py
index 8d5b5dbfa19..d4fe7836daa 100644
--- a/homeassistant/components/tuya/number.py
+++ b/homeassistant/components/tuya/number.py
@@ -12,7 +12,7 @@ from homeassistant.components.number import (
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType
@@ -305,9 +305,15 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = {
),
}
+# Smart Camera - Low power consumption camera (duplicate of `sp`)
+# Undocumented, see https://github.com/home-assistant/core/issues/132844
+NUMBERS["dghsxj"] = NUMBERS["sp"]
+
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya number dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py
index dbc849356b2..4ad027d39ee 100644
--- a/homeassistant/components/tuya/scene.py
+++ b/homeassistant/components/tuya/scene.py
@@ -9,14 +9,16 @@ from tuya_sharing import Manager, SharingScene
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import DOMAIN
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya scenes."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py
index 831d3cb3e0c..553191b7d45 100644
--- a/homeassistant/components/tuya/select.py
+++ b/homeassistant/components/tuya/select.py
@@ -8,7 +8,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
@@ -326,9 +326,15 @@ SELECTS["cz"] = SELECTS["kg"]
# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
SELECTS["pc"] = SELECTS["kg"]
+# Smart Camera - Low power consumption camera (duplicate of `sp`)
+# Undocumented, see https://github.com/home-assistant/core/issues/132844
+SELECTS["dghsxj"] = SELECTS["sp"]
+
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya select dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py
index 756564c6a03..9e40bda5d4d 100644
--- a/homeassistant/components/tuya/sensor.py
+++ b/homeassistant/components/tuya/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import TuyaConfigEntry
@@ -454,6 +454,37 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
+ TuyaSensorEntityDescription(
+ key=DPCode.VA_TEMPERATURE,
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.TEMP_CURRENT,
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.VA_HUMIDITY,
+ translation_key="humidity",
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.HUMIDITY_VALUE,
+ translation_key="humidity",
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.BRIGHT_VALUE,
+ translation_key="illuminance",
+ device_class=SensorDeviceClass.ILLUMINANCE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ *BATTERY_SENSORS,
),
# Luminance Sensor
# https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8
@@ -715,6 +746,47 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
),
*BATTERY_SENSORS,
),
+ # Temperature and Humidity Sensor with External Probe
+ # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472
+ "qxj": (
+ TuyaSensorEntityDescription(
+ key=DPCode.VA_TEMPERATURE,
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.TEMP_CURRENT,
+ translation_key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.TEMP_CURRENT_EXTERNAL,
+ translation_key="temperature_external",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.VA_HUMIDITY,
+ translation_key="humidity",
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.HUMIDITY_VALUE,
+ translation_key="humidity",
+ device_class=SensorDeviceClass.HUMIDITY,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ TuyaSensorEntityDescription(
+ key=DPCode.BRIGHT_VALUE,
+ translation_key="illuminance",
+ device_class=SensorDeviceClass.ILLUMINANCE,
+ state_class=SensorStateClass.MEASUREMENT,
+ ),
+ *BATTERY_SENSORS,
+ ),
# Pressure Sensor
# https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm
"ylcg": (
@@ -760,7 +832,6 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = {
translation_key="total_power",
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
- native_unit_of_measurement=UnitOfPower.KILO_WATT,
subkey="power",
),
TuyaSensorEntityDescription(
@@ -1220,9 +1291,15 @@ SENSORS["cz"] = SENSORS["kg"]
# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
SENSORS["pc"] = SENSORS["kg"]
+# Smart Camera - Low power consumption camera (duplicate of `sp`)
+# Undocumented, see https://github.com/home-assistant/core/issues/132844
+SENSORS["dghsxj"] = SENSORS["sp"]
+
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya sensor dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py
index 6f7dfe4c96c..039442dafe5 100644
--- a/homeassistant/components/tuya/siren.py
+++ b/homeassistant/components/tuya/siren.py
@@ -14,7 +14,7 @@ from homeassistant.components.siren import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode
@@ -54,9 +54,15 @@ SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = {
),
}
+# Smart Camera - Low power consumption camera (duplicate of `sp`)
+# Undocumented, see https://github.com/home-assistant/core/issues/132844
+SIRENS["dghsxj"] = SIRENS["sp"]
+
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya siren dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json
index 8ec61cc8aa5..c6f6bfe9776 100644
--- a/homeassistant/components/tuya/strings.json
+++ b/homeassistant/components/tuya/strings.json
@@ -14,7 +14,7 @@
}
},
"scan": {
- "description": "Use Smart Life app or Tuya Smart app to scan the following QR-code to complete the login.\n\nContinue to the next step once you have completed this step in the app."
+ "description": "Use the Smart Life app or Tuya Smart app to scan the following QR code to complete the login.\n\nContinue to the next step once you have completed this step in the app."
}
},
"error": {
@@ -288,9 +288,9 @@
"motion_sensitivity": {
"name": "Motion detection sensitivity",
"state": {
- "0": "Low sensitivity",
- "1": "Medium sensitivity",
- "2": "High sensitivity"
+ "0": "[%key:common::state::low%]",
+ "1": "[%key:common::state::medium%]",
+ "2": "[%key:common::state::high%]"
}
},
"record_mode": {
@@ -321,9 +321,9 @@
"vacuum_cistern": {
"name": "Water tank adjustment",
"state": {
- "low": "Low",
+ "low": "[%key:common::state::low%]",
"middle": "Middle",
- "high": "High",
+ "high": "[%key:common::state::high%]",
"closed": "[%key:common::state::closed%]"
}
},
@@ -404,7 +404,7 @@
"humidifier_spray_mode": {
"name": "Spray mode",
"state": {
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
"health": "Health",
"sleep": "Sleep",
"humidity": "Humidity",
@@ -469,6 +469,9 @@
"temperature": {
"name": "[%key:component::sensor::entity_component::temperature::name%]"
},
+ "temperature_external": {
+ "name": "Probe temperature"
+ },
"humidity": {
"name": "[%key:component::sensor::entity_component::humidity::name%]"
},
diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py
index 2b5e6fec4a6..4000e8d9b24 100644
--- a/homeassistant/components/tuya/switch.py
+++ b/homeassistant/components/tuya/switch.py
@@ -14,7 +14,7 @@ from homeassistant.components.switch import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode
@@ -612,6 +612,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
device_class=SwitchDeviceClass.OUTLET,
),
),
+ # SIREN: Siren (switch) with Temperature and Humidity Sensor with External Probe
+ # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472
+ "qxj": (
+ SwitchEntityDescription(
+ key=DPCode.SWITCH,
+ translation_key="switch",
+ device_class=SwitchDeviceClass.OUTLET,
+ ),
+ ),
# Ceiling Light
# https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r
"xdd": (
@@ -726,9 +735,15 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = {
# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s
SWITCHES["cz"] = SWITCHES["pc"]
+# Smart Camera - Low power consumption camera (duplicate of `sp`)
+# Undocumented, see https://github.com/home-assistant/core/issues/132844
+SWITCHES["dghsxj"] = SWITCHES["sp"]
+
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up tuya sensors dynamically through tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py
index bab9ac309ec..e36a682fa4e 100644
--- a/homeassistant/components/tuya/vacuum.py
+++ b/homeassistant/components/tuya/vacuum.py
@@ -13,7 +13,7 @@ from homeassistant.components.vacuum import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DPCode, DPType
@@ -48,7 +48,9 @@ TUYA_STATUS_TO_HA = {
async def async_setup_entry(
- hass: HomeAssistant, entry: TuyaConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: TuyaConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Tuya vacuum dynamically through Tuya discovery."""
hass_data = entry.runtime_data
diff --git a/homeassistant/components/twentemilieu/calendar.py b/homeassistant/components/twentemilieu/calendar.py
index 606fb4913d1..19e3f4f3337 100644
--- a/homeassistant/components/twentemilieu/calendar.py
+++ b/homeassistant/components/twentemilieu/calendar.py
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import WASTE_TYPE_TO_DESCRIPTION
@@ -18,7 +18,7 @@ from .entity import TwenteMilieuEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: TwenteMilieuConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Twente Milieu calendar based on a config entry."""
async_add_entities([TwenteMilieuCalendar(entry)])
diff --git a/homeassistant/components/twentemilieu/sensor.py b/homeassistant/components/twentemilieu/sensor.py
index 4605ede1f87..81751d10a81 100644
--- a/homeassistant/components/twentemilieu/sensor.py
+++ b/homeassistant/components/twentemilieu/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import TwenteMilieuConfigEntry
@@ -65,7 +65,7 @@ SENSORS: tuple[TwenteMilieuSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: TwenteMilieuConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Twente Milieu sensor based on a config entry."""
async_add_entities(
diff --git a/homeassistant/components/twilio/strings.json b/homeassistant/components/twilio/strings.json
index 871711ff087..f4b7dee707f 100644
--- a/homeassistant/components/twilio/strings.json
+++ b/homeassistant/components/twilio/strings.json
@@ -12,7 +12,7 @@
"webhook_not_internet_accessible": "[%key:common::config_flow::abort::webhook_not_internet_accessible%]"
},
"create_entry": {
- "default": "To send events to Home Assistant, you will need to setup [Webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
+ "default": "To send events to Home Assistant, you will need to set up [webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
}
}
}
diff --git a/homeassistant/components/twinkly/icons.json b/homeassistant/components/twinkly/icons.json
index 82c95aebce6..d57d54aa507 100644
--- a/homeassistant/components/twinkly/icons.json
+++ b/homeassistant/components/twinkly/icons.json
@@ -4,6 +4,11 @@
"light": {
"default": "mdi:string-lights"
}
+ },
+ "select": {
+ "mode": {
+ "default": "mdi:cogs"
+ }
}
}
}
diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py
index 5ce731d158f..c270421d8cd 100644
--- a/homeassistant/components/twinkly/light.py
+++ b/homeassistant/components/twinkly/light.py
@@ -15,7 +15,7 @@ from homeassistant.components.light import (
LightEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DEV_LED_PROFILE, DEV_PROFILE_RGB, DEV_PROFILE_RGBW
from .coordinator import TwinklyConfigEntry, TwinklyCoordinator
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TwinklyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Setups an entity from a config entry (UI config flow)."""
entity = TwinklyLight(config_entry.runtime_data)
diff --git a/homeassistant/components/twinkly/select.py b/homeassistant/components/twinkly/select.py
index a97424b4b8b..a5283b3f91d 100644
--- a/homeassistant/components/twinkly/select.py
+++ b/homeassistant/components/twinkly/select.py
@@ -8,7 +8,7 @@ from ttls.client import TWINKLY_MODES
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import TwinklyConfigEntry, TwinklyCoordinator
from .entity import TwinklyEntity
@@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: TwinklyConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a mode select from a config entry."""
entity = TwinklyModeSelect(config_entry.runtime_data)
@@ -29,7 +29,7 @@ async def async_setup_entry(
class TwinklyModeSelect(TwinklyEntity, SelectEntity):
"""Twinkly Mode Selection."""
- _attr_name = "Mode"
+ _attr_translation_key = "mode"
_attr_options = TWINKLY_MODES
def __init__(self, coordinator: TwinklyCoordinator) -> None:
diff --git a/homeassistant/components/twinkly/strings.json b/homeassistant/components/twinkly/strings.json
index bbc3d67373d..c2e0efef92c 100644
--- a/homeassistant/components/twinkly/strings.json
+++ b/homeassistant/components/twinkly/strings.json
@@ -20,5 +20,21 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
+ },
+ "entity": {
+ "select": {
+ "mode": {
+ "name": "Mode",
+ "state": {
+ "color": "Color",
+ "demo": "Demo",
+ "effect": "Effect",
+ "movie": "Uploaded effect",
+ "off": "[%key:common::state::off%]",
+ "playlist": "Playlist",
+ "rt": "Real time"
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py
index b407eae0319..deec319e5cf 100644
--- a/homeassistant/components/twitch/sensor.py
+++ b/homeassistant/components/twitch/sensor.py
@@ -6,7 +6,7 @@ from typing import Any
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -32,7 +32,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: TwitchConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize entries."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py
index b06d0e24891..a52750282df 100644
--- a/homeassistant/components/uk_transport/sensor.py
+++ b/homeassistant/components/uk_transport/sensor.py
@@ -32,6 +32,7 @@ ATTR_NEXT_BUSES = "next_buses"
ATTR_STATION_CODE = "station_code"
ATTR_CALLING_AT = "calling_at"
ATTR_NEXT_TRAINS = "next_trains"
+ATTR_LAST_UPDATED = "last_updated"
CONF_API_APP_KEY = "app_key"
CONF_API_APP_ID = "app_id"
@@ -162,7 +163,7 @@ class UkTransportLiveBusTimeSensor(UkTransportSensor):
self._destination_re = re.compile(f"{bus_direction}", re.IGNORECASE)
sensor_name = f"Next bus to {bus_direction}"
- stop_url = f"bus/stop/{stop_atcocode}/live.json"
+ stop_url = f"bus/stop/{stop_atcocode}.json"
UkTransportSensor.__init__(self, sensor_name, api_app_id, api_app_key, stop_url)
self.update = Throttle(interval)(self._update)
@@ -199,7 +200,9 @@ class UkTransportLiveBusTimeSensor(UkTransportSensor):
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return other details about the sensor state."""
if self._data is not None:
- attrs = {ATTR_NEXT_BUSES: self._next_buses}
+ attrs = {
+ ATTR_NEXT_BUSES: self._next_buses,
+ }
for key in (
ATTR_ATCOCODE,
ATTR_LOCALITY,
@@ -223,7 +226,7 @@ class UkTransportLiveTrainTimeSensor(UkTransportSensor):
self._next_trains = []
sensor_name = f"Next train to {calling_at}"
- query_url = f"train/station/{station_code}/live.json"
+ query_url = f"train/station/{station_code}.json"
UkTransportSensor.__init__(
self, sensor_name, api_app_id, api_app_key, query_url
@@ -272,6 +275,7 @@ class UkTransportLiveTrainTimeSensor(UkTransportSensor):
attrs = {
ATTR_STATION_CODE: self._station_code,
ATTR_CALLING_AT: self._calling_at,
+ ATTR_LAST_UPDATED: self._data[ATTR_REQUEST_TIME],
}
if self._next_trains:
attrs[ATTR_NEXT_TRAINS] = self._next_trains
diff --git a/homeassistant/components/ukraine_alarm/binary_sensor.py b/homeassistant/components/ukraine_alarm/binary_sensor.py
index 30cb8e0f553..9009031ea14 100644
--- a/homeassistant/components/ukraine_alarm/binary_sensor.py
+++ b/homeassistant/components/ukraine_alarm/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
@@ -64,7 +64,7 @@ BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Ukraine Alarm binary sensor entities based on a config entry."""
name = config_entry.data[CONF_NAME]
diff --git a/homeassistant/components/ukraine_alarm/coordinator.py b/homeassistant/components/ukraine_alarm/coordinator.py
index 267358e4aa6..b4e1decb1a1 100644
--- a/homeassistant/components/ukraine_alarm/coordinator.py
+++ b/homeassistant/components/ukraine_alarm/coordinator.py
@@ -52,7 +52,7 @@ class UkraineAlarmDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except aiohttp.ClientError as error:
raise UpdateFailed(f"Error fetching alerts from API: {error}") from error
- current = {alert_type: False for alert_type in ALERT_TYPES}
+ current = dict.fromkeys(ALERT_TYPES, False)
for alert in res[0]["activeAlerts"]:
current[alert["type"]] = True
diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py
index 25c6816d794..3e5ef62f49e 100644
--- a/homeassistant/components/unifi/button.py
+++ b/homeassistant/components/unifi/button.py
@@ -31,7 +31,7 @@ from homeassistant.components.button import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import UnifiConfigEntry
from .entity import (
@@ -135,7 +135,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiButtonEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: UnifiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button platform for UniFi Network integration."""
config_entry.runtime_data.entity_loader.register_platform(
diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py
index da5ca74fc37..a26232664a8 100644
--- a/homeassistant/components/unifi/device_tracker.py
+++ b/homeassistant/components/unifi/device_tracker.py
@@ -26,7 +26,7 @@ from homeassistant.components.device_tracker import (
from homeassistant.core import Event as core_Event, HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import UnifiConfigEntry
@@ -222,7 +222,7 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: UnifiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for UniFi Network integration."""
async_update_unique_id(hass, config_entry)
diff --git a/homeassistant/components/unifi/diagnostics.py b/homeassistant/components/unifi/diagnostics.py
index 21174342594..49a9b678b0f 100644
--- a/homeassistant/components/unifi/diagnostics.py
+++ b/homeassistant/components/unifi/diagnostics.py
@@ -27,7 +27,7 @@ REDACT_DEVICES = {
"x_ssh_hostkey_fingerprint",
"x_vwirekey",
}
-REDACT_WLANS = {"bc_filter_list", "x_passphrase"}
+REDACT_WLANS = {"bc_filter_list", "password", "x_passphrase"}
@callback
diff --git a/homeassistant/components/unifi/hub/entity_loader.py b/homeassistant/components/unifi/hub/entity_loader.py
index 64403152b0c..84948a92e98 100644
--- a/homeassistant/components/unifi/hub/entity_loader.py
+++ b/homeassistant/components/unifi/hub/entity_loader.py
@@ -46,6 +46,7 @@ class UnifiEntityLoader:
hub.api.port_forwarding.update,
hub.api.sites.update,
hub.api.system_information.update,
+ hub.api.firewall_policies.update,
hub.api.traffic_rules.update,
hub.api.traffic_routes.update,
hub.api.wlans.update,
diff --git a/homeassistant/components/unifi/icons.json b/homeassistant/components/unifi/icons.json
index 6874bb5ae03..616d7cb185f 100644
--- a/homeassistant/components/unifi/icons.json
+++ b/homeassistant/components/unifi/icons.json
@@ -55,6 +55,9 @@
"off": "mdi:network-off"
}
},
+ "firewall_policy_control": {
+ "default": "mdi:security-network"
+ },
"port_forward_control": {
"default": "mdi:upload-network"
},
diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py
index f1ada9a01e0..f3045d5fc1c 100644
--- a/homeassistant/components/unifi/image.py
+++ b/homeassistant/components/unifi/image.py
@@ -16,7 +16,7 @@ from aiounifi.models.wlan import Wlan
from homeassistant.components.image import ImageEntity, ImageEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import UnifiConfigEntry
@@ -67,7 +67,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiImageEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: UnifiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up image platform for UniFi Network integration."""
config_entry.runtime_data.entity_loader.register_platform(
diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json
index ce573592153..dd255c57c13 100644
--- a/homeassistant/components/unifi/manifest.json
+++ b/homeassistant/components/unifi/manifest.json
@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["aiounifi"],
- "requirements": ["aiounifi==81"],
+ "requirements": ["aiounifi==83"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py
index fd78c606043..47a2c2ba62e 100644
--- a/homeassistant/components/unifi/sensor.py
+++ b/homeassistant/components/unifi/sensor.py
@@ -46,7 +46,7 @@ from homeassistant.const import (
)
from homeassistant.core import Event as core_Event, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util, slugify
@@ -644,7 +644,7 @@ ENTITY_DESCRIPTIONS += make_wan_latency_sensors() + make_device_temperatur_senso
async def async_setup_entry(
hass: HomeAssistant,
config_entry: UnifiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for UniFi Network integration."""
config_entry.runtime_data.entity_loader.register_platform(
diff --git a/homeassistant/components/unifi/services.py b/homeassistant/components/unifi/services.py
index fc63c092d56..9d4d92839fc 100644
--- a/homeassistant/components/unifi/services.py
+++ b/homeassistant/components/unifi/services.py
@@ -6,7 +6,6 @@ from typing import Any
from aiounifi.models.client import ClientReconnectRequest, ClientRemoveRequest
import voluptuous as vol
-from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import ATTR_DEVICE_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import device_registry as dr
@@ -67,9 +66,9 @@ async def async_reconnect_client(hass: HomeAssistant, data: Mapping[str, Any]) -
if mac == "":
return
- for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN):
- if config_entry.state is not ConfigEntryState.LOADED or (
- ((hub := config_entry.runtime_data) and not hub.available)
+ for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN):
+ if (
+ (not (hub := config_entry.runtime_data).available)
or (client := hub.api.clients.get(mac)) is None
or client.is_wired
):
@@ -85,10 +84,8 @@ async def async_remove_clients(hass: HomeAssistant, data: Mapping[str, Any]) ->
- Total time between first seen and last seen is less than 15 minutes.
- Neither IP, hostname nor name is configured.
"""
- for config_entry in hass.config_entries.async_entries(UNIFI_DOMAIN):
- if config_entry.state is not ConfigEntryState.LOADED or (
- (hub := config_entry.runtime_data) and not hub.available
- ):
+ for config_entry in hass.config_entries.async_loaded_entries(UNIFI_DOMAIN):
+ if not (hub := config_entry.runtime_data).available:
continue
clients_to_remove = []
diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py
index 91e4a0222f6..282d0c9ae93 100644
--- a/homeassistant/components/unifi/switch.py
+++ b/homeassistant/components/unifi/switch.py
@@ -4,6 +4,7 @@ Support for controlling power supply of clients which are powered over Ethernet
Support for controlling network access of clients selected in option flow.
Support for controlling deep packet inspection (DPI) restriction groups.
Support for controlling WLAN availability.
+Support for controlling zone based traffic rules.
"""
from __future__ import annotations
@@ -17,6 +18,7 @@ import aiounifi
from aiounifi.interfaces.api_handlers import ItemEvent
from aiounifi.interfaces.clients import Clients
from aiounifi.interfaces.dpi_restriction_groups import DPIRestrictionGroups
+from aiounifi.interfaces.firewall_policies import FirewallPolicies
from aiounifi.interfaces.outlets import Outlets
from aiounifi.interfaces.port_forwarding import PortForwarding
from aiounifi.interfaces.ports import Ports
@@ -29,6 +31,7 @@ from aiounifi.models.device import DeviceSetOutletRelayRequest
from aiounifi.models.dpi_restriction_app import DPIRestrictionAppEnableRequest
from aiounifi.models.dpi_restriction_group import DPIRestrictionGroup
from aiounifi.models.event import Event, EventKey
+from aiounifi.models.firewall_policy import FirewallPolicy, FirewallPolicyUpdateRequest
from aiounifi.models.outlet import Outlet
from aiounifi.models.port import Port
from aiounifi.models.port_forward import PortForward, PortForwardEnableRequest
@@ -46,7 +49,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import UnifiConfigEntry
from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN
@@ -129,6 +132,24 @@ async def async_dpi_group_control_fn(hub: UnifiHub, obj_id: str, target: bool) -
)
+async def async_firewall_policy_control_fn(
+ hub: UnifiHub, obj_id: str, target: bool
+) -> None:
+ """Control firewall policy state."""
+ policy = hub.api.firewall_policies[obj_id].raw
+ policy["enabled"] = target
+ await hub.api.request(FirewallPolicyUpdateRequest.create(policy))
+ # Update the policies so the UI is updated appropriately
+ await hub.api.firewall_policies.update()
+
+
+@callback
+def async_firewall_policy_supported_fn(hub: UnifiHub, obj_id: str) -> bool:
+ """Check if firewall policy is able to be controlled. Predefined policies are unable to be turned off."""
+ policy = hub.api.firewall_policies[obj_id]
+ return not policy.predefined
+
+
@callback
def async_outlet_switching_supported_fn(hub: UnifiHub, obj_id: str) -> bool:
"""Determine if an outlet supports switching."""
@@ -236,6 +257,20 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = (
supported_fn=lambda hub, obj_id: bool(hub.api.dpi_groups[obj_id].dpiapp_ids),
unique_id_fn=lambda hub, obj_id: obj_id,
),
+ UnifiSwitchEntityDescription[FirewallPolicies, FirewallPolicy](
+ key="Firewall policy control",
+ translation_key="firewall_policy_control",
+ device_class=SwitchDeviceClass.SWITCH,
+ entity_category=EntityCategory.CONFIG,
+ api_handler_fn=lambda api: api.firewall_policies,
+ control_fn=async_firewall_policy_control_fn,
+ device_info_fn=async_unifi_network_device_info_fn,
+ is_on_fn=lambda hub, firewall_policy: firewall_policy.enabled,
+ name_fn=lambda firewall_policy: firewall_policy.name,
+ object_fn=lambda api, obj_id: api.firewall_policies[obj_id],
+ unique_id_fn=lambda hub, obj_id: f"firewall_policy-{obj_id}",
+ supported_fn=async_firewall_policy_supported_fn,
+ ),
UnifiSwitchEntityDescription[Outlets, Outlet](
key="Outlet control",
device_class=SwitchDeviceClass.OUTLET,
@@ -352,7 +387,7 @@ def async_update_unique_id(hass: HomeAssistant, config_entry: UnifiConfigEntry)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: UnifiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches for UniFi Network integration."""
async_update_unique_id(hass, config_entry)
diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py
index 65202045a05..589b2ff1215 100644
--- a/homeassistant/components/unifi/update.py
+++ b/homeassistant/components/unifi/update.py
@@ -19,7 +19,7 @@ from homeassistant.components.update import (
UpdateEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import UnifiConfigEntry
from .entity import (
@@ -68,7 +68,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiUpdateEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: UnifiConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up update entities for UniFi Network integration."""
config_entry.runtime_data.entity_loader.register_platform(
diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py
index a88d4b65678..0d904d3c3ba 100644
--- a/homeassistant/components/unifiprotect/binary_sensor.py
+++ b/homeassistant/components/unifiprotect/binary_sensor.py
@@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
from .entity import (
@@ -769,7 +769,7 @@ def _async_nvr_entities(
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensors for UniFi Protect integration."""
data = entry.runtime_data
diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py
index b24c90be3ec..7b766299946 100644
--- a/homeassistant/components/unifiprotect/button.py
+++ b/homeassistant/components/unifiprotect/button.py
@@ -19,7 +19,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DEVICES_THAT_ADOPT, DOMAIN
from .data import ProtectDeviceType, UFPConfigEntry
@@ -120,7 +120,7 @@ def _async_remove_adopt_button(
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Discover devices on a UniFi Protect NVR."""
data = entry.runtime_data
diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py
index 0b1c03b8dd6..3947324fd73 100644
--- a/homeassistant/components/unifiprotect/camera.py
+++ b/homeassistant/components/unifiprotect/camera.py
@@ -16,7 +16,7 @@ from homeassistant.components.camera import Camera, CameraEntityFeature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity
from .const import (
@@ -138,7 +138,7 @@ def _async_camera_entities(
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Discover cameras on a UniFi Protect NVR."""
data = entry.runtime_data
diff --git a/homeassistant/components/unifiprotect/event.py b/homeassistant/components/unifiprotect/event.py
index 78fdf7746de..cb9090dd530 100644
--- a/homeassistant/components/unifiprotect/event.py
+++ b/homeassistant/components/unifiprotect/event.py
@@ -10,7 +10,7 @@ from homeassistant.components.event import (
EventEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import Bootstrap
from .const import (
@@ -218,7 +218,7 @@ def _async_event_entities(
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up event entities for UniFi Protect integration."""
data = entry.runtime_data
diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py
index fcdfe5e85b8..873f715de58 100644
--- a/homeassistant/components/unifiprotect/light.py
+++ b/homeassistant/components/unifiprotect/light.py
@@ -9,7 +9,7 @@ from uiprotect.data import Light, ModelType, ProtectAdoptableDeviceModel
from homeassistant.components.light import ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .data import ProtectDeviceType, UFPConfigEntry
from .entity import ProtectDeviceEntity
@@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lights for UniFi Protect integration."""
data = entry.runtime_data
diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py
index 3e9372db0e5..79ed47a6c3b 100644
--- a/homeassistant/components/unifiprotect/lock.py
+++ b/homeassistant/components/unifiprotect/lock.py
@@ -14,7 +14,7 @@ from uiprotect.data import (
from homeassistant.components.lock import LockEntity, LockEntityDescription
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .data import ProtectDeviceType, UFPConfigEntry
from .entity import ProtectDeviceEntity
@@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up locks on a UniFi Protect NVR."""
data = entry.runtime_data
diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json
index a4bb6d20841..7cbb6128eef 100644
--- a/homeassistant/components/unifiprotect/manifest.json
+++ b/homeassistant/components/unifiprotect/manifest.json
@@ -40,7 +40,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
- "requirements": ["uiprotect==7.5.1", "unifi-discovery==1.2.0"],
+ "requirements": ["uiprotect==7.5.3", "unifi-discovery==1.2.0"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",
diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py
index 5f9991b257b..a1e60931026 100644
--- a/homeassistant/components/unifiprotect/media_player.py
+++ b/homeassistant/components/unifiprotect/media_player.py
@@ -21,7 +21,7 @@ from homeassistant.components.media_player import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .data import ProtectDeviceType, UFPConfigEntry
from .entity import ProtectDeviceEntity
@@ -36,7 +36,7 @@ _SPEAKER_DESCRIPTION = MediaPlayerEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Discover cameras with speakers on a UniFi Protect NVR."""
data = entry.runtime_data
diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py
index 767128337ba..5dbf9f2b00e 100644
--- a/homeassistant/components/unifiprotect/number.py
+++ b/homeassistant/components/unifiprotect/number.py
@@ -17,7 +17,7 @@ from uiprotect.data import (
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
from .entity import (
@@ -227,7 +227,7 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number entities for UniFi Protect integration."""
data = entry.runtime_data
diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py
index 00c277c957e..054c9430387 100644
--- a/homeassistant/components/unifiprotect/select.py
+++ b/homeassistant/components/unifiprotect/select.py
@@ -29,7 +29,7 @@ from uiprotect.data import (
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import TYPE_EMPTY_VALUE
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
@@ -334,7 +334,9 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: UFPConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: UFPConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number entities for UniFi Protect integration."""
data = entry.runtime_data
diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py
index 09187e023a1..a719f36c2b3 100644
--- a/homeassistant/components/unifiprotect/sensor.py
+++ b/homeassistant/components/unifiprotect/sensor.py
@@ -38,7 +38,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
from .entity import (
@@ -640,7 +640,7 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for UniFi Protect integration."""
data = entry.runtime_data
diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py
index fa960261cf2..fce92912a52 100644
--- a/homeassistant/components/unifiprotect/switch.py
+++ b/homeassistant/components/unifiprotect/switch.py
@@ -18,7 +18,7 @@ from uiprotect.data import (
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
@@ -568,7 +568,7 @@ class ProtectPrivacyModeSwitch(RestoreEntity, ProtectSwitch):
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for UniFi Protect integration."""
data = entry.runtime_data
diff --git a/homeassistant/components/unifiprotect/text.py b/homeassistant/components/unifiprotect/text.py
index 0c7e1322f23..1c468d44cc6 100644
--- a/homeassistant/components/unifiprotect/text.py
+++ b/homeassistant/components/unifiprotect/text.py
@@ -15,7 +15,7 @@ from uiprotect.data import (
from homeassistant.components.text import TextEntity, TextEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .data import ProtectDeviceType, UFPConfigEntry
from .entity import (
@@ -63,7 +63,7 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: UFPConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors for UniFi Protect integration."""
data = entry.runtime_data
diff --git a/homeassistant/components/upb/entity.py b/homeassistant/components/upb/entity.py
index 13037adf680..8a9afa453b1 100644
--- a/homeassistant/components/upb/entity.py
+++ b/homeassistant/components/upb/entity.py
@@ -30,7 +30,7 @@ class UpbEntity(Entity):
return self._element.as_dict()
@property
- def available(self):
+ def available(self) -> bool:
"""Is the entity available to be updated."""
return self._upb.is_connected()
@@ -43,7 +43,7 @@ class UpbEntity(Entity):
self._element_changed(element, changeset)
self.async_write_ha_state()
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Register callback for UPB changes and update entity state."""
self._element.add_callback(self._element_callback)
self._element_callback(self._element, {})
diff --git a/homeassistant/components/upb/light.py b/homeassistant/components/upb/light.py
index 07bd50b7d9f..0838ec3ef01 100644
--- a/homeassistant/components/upb/light.py
+++ b/homeassistant/components/upb/light.py
@@ -13,7 +13,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA
from .entity import UpbAttachedEntity
@@ -26,7 +26,7 @@ SERVICE_LIGHT_BLINK = "light_blink"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the UPB light based on a config entry."""
diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json
index e5da4c4d621..b40388be71b 100644
--- a/homeassistant/components/upb/manifest.json
+++ b/homeassistant/components/upb/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/upb",
"iot_class": "local_push",
"loggers": ["upb_lib"],
- "requirements": ["upb-lib==0.6.0"]
+ "requirements": ["upb-lib==0.6.1"]
}
diff --git a/homeassistant/components/upb/scene.py b/homeassistant/components/upb/scene.py
index 5a5e17b3e4c..45a1d664b15 100644
--- a/homeassistant/components/upb/scene.py
+++ b/homeassistant/components/upb/scene.py
@@ -6,7 +6,7 @@ from homeassistant.components.scene import Scene
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, UPB_BLINK_RATE_SCHEMA, UPB_BRIGHTNESS_RATE_SCHEMA
from .entity import UpbEntity
@@ -21,7 +21,7 @@ SERVICE_LINK_BLINK = "link_blink"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the UPB link based on a config entry."""
upb = hass.data[DOMAIN][config_entry.entry_id]["upb"]
diff --git a/homeassistant/components/upcloud/binary_sensor.py b/homeassistant/components/upcloud/binary_sensor.py
index bca313d306f..923d8f2d896 100644
--- a/homeassistant/components/upcloud/binary_sensor.py
+++ b/homeassistant/components/upcloud/binary_sensor.py
@@ -5,7 +5,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import UpCloudConfigEntry
from .entity import UpCloudServerEntity
@@ -14,7 +14,7 @@ from .entity import UpCloudServerEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: UpCloudConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the UpCloud server binary sensor."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/upcloud/switch.py b/homeassistant/components/upcloud/switch.py
index 97c08b19188..de180907919 100644
--- a/homeassistant/components/upcloud/switch.py
+++ b/homeassistant/components/upcloud/switch.py
@@ -6,7 +6,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import dispatcher_send
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import UpCloudConfigEntry
from .entity import UpCloudServerEntity
@@ -17,7 +17,7 @@ SIGNAL_UPDATE_UPCLOUD = "upcloud_update"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: UpCloudConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the UpCloud server switch."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py
index 0ff8c448197..47cc5aa369b 100644
--- a/homeassistant/components/update/__init__.py
+++ b/homeassistant/components/update/__init__.py
@@ -226,7 +226,7 @@ class UpdateEntity(
_attr_installed_version: str | None = None
_attr_device_class: UpdateDeviceClass | None
_attr_display_precision: int
- _attr_in_progress: bool | int = False
+ _attr_in_progress: bool = False
_attr_latest_version: str | None = None
_attr_release_summary: str | None = None
_attr_release_url: str | None = None
@@ -295,7 +295,7 @@ class UpdateEntity(
)
@cached_property
- def in_progress(self) -> bool | int | None:
+ def in_progress(self) -> bool | None:
"""Update installation progress.
Needs UpdateEntityFeature.PROGRESS flag to be set for it to be used.
@@ -442,7 +442,7 @@ class UpdateEntity(
in_progress = self.in_progress
update_percentage = self.update_percentage if in_progress else None
if type(in_progress) is not bool and isinstance(in_progress, int):
- update_percentage = in_progress
+ update_percentage = in_progress # type: ignore[unreachable]
in_progress = True
else:
in_progress = self.__in_progress
diff --git a/homeassistant/components/upnp/binary_sensor.py b/homeassistant/components/upnp/binary_sensor.py
index 1576cccac6a..0c7b7aa5dc2 100644
--- a/homeassistant/components/upnp/binary_sensor.py
+++ b/homeassistant/components/upnp/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER, WAN_STATUS
from .coordinator import UpnpConfigEntry, UpnpDataUpdateCoordinator
@@ -38,7 +38,7 @@ SENSOR_DESCRIPTIONS: tuple[UpnpBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: UpnpConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the UPnP/IGD sensors."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json
index df4daa8782c..62ee4ede7d9 100644
--- a/homeassistant/components/upnp/manifest.json
+++ b/homeassistant/components/upnp/manifest.json
@@ -8,7 +8,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["async_upnp_client"],
- "requirements": ["async-upnp-client==0.43.0", "getmac==0.9.5"],
+ "requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py
index c0e77315f77..c7e343d36b5 100644
--- a/homeassistant/components/upnp/sensor.py
+++ b/homeassistant/components/upnp/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
BYTES_RECEIVED,
@@ -153,7 +153,7 @@ SENSOR_DESCRIPTIONS: tuple[UpnpSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: UpnpConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the UPnP/IGD sensors."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py
index 25917d09096..488682a79c6 100644
--- a/homeassistant/components/uptime/sensor.py
+++ b/homeassistant/components/uptime/sensor.py
@@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import DOMAIN
@@ -15,7 +15,7 @@ from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform from config_entry."""
async_add_entities([UptimeSensor(entry)])
diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py
index b8619b1fe39..e5829882200 100644
--- a/homeassistant/components/uptimerobot/__init__.py
+++ b/homeassistant/components/uptimerobot/__init__.py
@@ -4,19 +4,17 @@ from __future__ import annotations
from pyuptimerobot import UptimeRobot
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from .const import DOMAIN, PLATFORMS
-from .coordinator import UptimeRobotDataUpdateCoordinator
+from .const import PLATFORMS
+from .coordinator import UptimeRobotConfigEntry, UptimeRobotDataUpdateCoordinator
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: UptimeRobotConfigEntry) -> bool:
"""Set up UptimeRobot from a config entry."""
- hass.data.setdefault(DOMAIN, {})
key: str = entry.data[CONF_API_KEY]
if key.startswith(("ur", "m")):
raise ConfigEntryAuthFailed(
@@ -24,7 +22,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
uptime_robot_api = UptimeRobot(key, async_get_clientsession(hass))
- hass.data[DOMAIN][entry.entry_id] = coordinator = UptimeRobotDataUpdateCoordinator(
+ coordinator = UptimeRobotDataUpdateCoordinator(
hass,
entry,
api=uptime_robot_api,
@@ -32,15 +30,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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: ConfigEntry) -> bool:
+async def async_unload_entry(
+ hass: HomeAssistant, entry: UptimeRobotConfigEntry
+) -> bool:
"""Unload a config entry."""
- unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if unload_ok:
- hass.data[DOMAIN].pop(entry.entry_id)
-
- return unload_ok
+ return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py
index 0c1bd972387..e8803b6ad89 100644
--- a/homeassistant/components/uptimerobot/binary_sensor.py
+++ b/homeassistant/components/uptimerobot/binary_sensor.py
@@ -7,22 +7,23 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import UptimeRobotDataUpdateCoordinator
+from .coordinator import UptimeRobotConfigEntry
from .entity import UptimeRobotEntity
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: UptimeRobotConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the UptimeRobot binary_sensors."""
- coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
UptimeRobotBinarySensor(
coordinator,
@@ -42,4 +43,4 @@ class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return True if the entity is on."""
- return self.monitor_available
+ return bool(self.monitor.status == 2)
diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py
index ffe3c3e4563..5fc165c0f27 100644
--- a/homeassistant/components/uptimerobot/config_flow.py
+++ b/homeassistant/components/uptimerobot/config_flow.py
@@ -44,11 +44,9 @@ class UptimeRobotConfigFlow(ConfigFlow, domain=DOMAIN):
try:
response = await uptime_robot_api.async_get_account_details()
- except UptimeRobotAuthenticationException as exception:
- LOGGER.error(exception)
+ except UptimeRobotAuthenticationException:
errors["base"] = "invalid_api_key"
- except UptimeRobotException as exception:
- LOGGER.error(exception)
+ except UptimeRobotException:
errors["base"] = "cannot_connect"
except Exception as exception: # noqa: BLE001
LOGGER.exception(exception)
diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py
index fbadc237965..2f6225fa498 100644
--- a/homeassistant/components/uptimerobot/coordinator.py
+++ b/homeassistant/components/uptimerobot/coordinator.py
@@ -17,16 +17,18 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER
+type UptimeRobotConfigEntry = ConfigEntry[UptimeRobotDataUpdateCoordinator]
+
class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]):
"""Data update coordinator for UptimeRobot."""
- config_entry: ConfigEntry
+ config_entry: UptimeRobotConfigEntry
def __init__(
self,
hass: HomeAssistant,
- config_entry: ConfigEntry,
+ config_entry: UptimeRobotConfigEntry,
api: UptimeRobot,
) -> None:
"""Initialize coordinator."""
diff --git a/homeassistant/components/uptimerobot/diagnostics.py b/homeassistant/components/uptimerobot/diagnostics.py
index 23c65373045..c3c2acbfbf1 100644
--- a/homeassistant/components/uptimerobot/diagnostics.py
+++ b/homeassistant/components/uptimerobot/diagnostics.py
@@ -6,19 +6,17 @@ from typing import Any
from pyuptimerobot import UptimeRobotException
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import UptimeRobotDataUpdateCoordinator
+from .coordinator import UptimeRobotConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
- entry: ConfigEntry,
+ entry: UptimeRobotConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
account: dict[str, Any] | str | None = None
try:
response = await coordinator.api.async_get_account_details()
diff --git a/homeassistant/components/uptimerobot/entity.py b/homeassistant/components/uptimerobot/entity.py
index 71f7a2f1c00..a27d4a6f80e 100644
--- a/homeassistant/components/uptimerobot/entity.py
+++ b/homeassistant/components/uptimerobot/entity.py
@@ -59,8 +59,3 @@ class UptimeRobotEntity(CoordinatorEntity[UptimeRobotDataUpdateCoordinator]):
),
self._monitor,
)
-
- @property
- def monitor_available(self) -> bool:
- """Returtn if the monitor is available."""
- return bool(self.monitor.status == 2)
diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml
new file mode 100644
index 00000000000..1ab2c117483
--- /dev/null
+++ b/homeassistant/components/uptimerobot/quality_scale.yaml
@@ -0,0 +1,92 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: no actions
+ appropriate-polling: done
+ brands: done
+ common-modules: done
+ config-flow-test-coverage:
+ status: todo
+ comment: fix name and docstring
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: no actions
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: no 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: todo
+ comment: we should not swallow the exception in switch.py
+ config-entry-unloading: done
+ docs-configuration-parameters: done
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: todo
+ comment: Change the type of the coordinator data to be a dict[str, UptimeRobotMonitor] so we can just do a dict look up instead of iterating over the whole list
+ integration-owner: done
+ log-when-unavailable: done
+ parallel-updates: done
+ reauthentication-flow: done
+ test-coverage:
+ status: todo
+ comment: recheck typos
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: device not discoverable
+ discovery:
+ status: exempt
+ comment: device not discoverable
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations:
+ status: exempt
+ comment: no known limitations, yet
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices:
+ status: todo
+ comment: create entities on runtime instead of triggering a reload
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: no known use case
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow:
+ status: todo
+ comment: handle API key change/update
+ repair-issues:
+ status: exempt
+ comment: no known use cases for repair issues or flows, yet
+ stale-devices:
+ status: todo
+ comment: We should remove the config entry from the device rather than remove the device
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing:
+ status: todo
+ comment: Requirement 'pyuptimerobot==22.2.0' appears untyped
diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py
index c5ff8abf5d9..3ed97d17508 100644
--- a/homeassistant/components/uptimerobot/sensor.py
+++ b/homeassistant/components/uptimerobot/sensor.py
@@ -7,13 +7,11 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
-from .coordinator import UptimeRobotDataUpdateCoordinator
+from .coordinator import UptimeRobotConfigEntry
from .entity import UptimeRobotEntity
SENSORS_INFO = {
@@ -24,14 +22,17 @@ SENSORS_INFO = {
9: "down",
}
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
+
async def async_setup_entry(
hass: HomeAssistant,
- entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ entry: UptimeRobotConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the UptimeRobot sensors."""
- coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
UptimeRobotSensor(
coordinator,
diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json
index 588dc3ebf5c..6bcd1554b16 100644
--- a/homeassistant/components/uptimerobot/strings.json
+++ b/homeassistant/components/uptimerobot/strings.json
@@ -2,16 +2,20 @@
"config": {
"step": {
"user": {
- "description": "You need to supply the 'main' API key from UptimeRobot",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "The 'main' API key for your UptimeRobot account"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
- "description": "You need to supply a new 'main' API key from UptimeRobot",
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
+ },
+ "data_description": {
+ "api_key": "[%key:component::uptimerobot::config::step::user::data_description::api_key%]"
}
}
},
diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py
index aa7d07e10fd..9b25570393a 100644
--- a/homeassistant/components/uptimerobot/switch.py
+++ b/homeassistant/components/uptimerobot/switch.py
@@ -11,20 +11,24 @@ from homeassistant.components.switch import (
SwitchEntity,
SwitchEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import API_ATTR_OK, DOMAIN, LOGGER
-from .coordinator import UptimeRobotDataUpdateCoordinator
+from .const import API_ATTR_OK, LOGGER
+from .coordinator import UptimeRobotConfigEntry
from .entity import UptimeRobotEntity
+# Limit the number of parallel updates to 1
+PARALLEL_UPDATES = 1
+
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: UptimeRobotConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the UptimeRobot switches."""
- coordinator: UptimeRobotDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
async_add_entities(
UptimeRobotSwitch(
coordinator,
diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py
index d68742522a0..90433b0f728 100644
--- a/homeassistant/components/usb/__init__.py
+++ b/homeassistant/components/usb/__init__.py
@@ -14,8 +14,6 @@ import sys
from typing import Any, overload
from aiousbwatcher import AIOUSBWatcher, InotifyNotAvailableError
-from serial.tools.list_ports import comports
-from serial.tools.list_ports_common import ListPortInfo
import voluptuous as vol
from homeassistant import config_entries
@@ -43,7 +41,10 @@ from homeassistant.loader import USBMatcher, async_get_usb
from .const import DOMAIN
from .models import USBDevice
-from .utils import usb_device_from_port
+from .utils import (
+ scan_serial_ports,
+ usb_device_from_port, # noqa: F401
+)
_LOGGER = logging.getLogger(__name__)
@@ -241,6 +242,13 @@ def _is_matching(device: USBDevice, matcher: USBMatcher | USBCallbackMatcher) ->
return True
+async def async_request_scan(hass: HomeAssistant) -> None:
+ """Request a USB scan."""
+ usb_discovery: USBDiscovery = hass.data[DOMAIN]
+ if not usb_discovery.observer_active:
+ await usb_discovery.async_request_scan()
+
+
class USBDiscovery:
"""Manage USB Discovery."""
@@ -265,8 +273,15 @@ class USBDiscovery:
async def async_setup(self) -> None:
"""Set up USB Discovery."""
- if self._async_supports_monitoring():
- await self._async_start_monitor()
+ try:
+ await self._async_start_aiousbwatcher()
+ except InotifyNotAvailableError as ex:
+ _LOGGER.info(
+ "Falling back to periodic filesystem polling for development, "
+ "aiousbwatcher is not available on this system: %s",
+ ex,
+ )
+ self._async_start_monitor_polling()
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, self.async_start)
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop)
@@ -281,22 +296,6 @@ class USBDiscovery:
if self._request_debouncer:
self._request_debouncer.async_shutdown()
- @hass_callback
- def _async_supports_monitoring(self) -> bool:
- return sys.platform == "linux"
-
- async def _async_start_monitor(self) -> None:
- """Start monitoring hardware."""
- try:
- await self._async_start_aiousbwatcher()
- except InotifyNotAvailableError as ex:
- _LOGGER.info(
- "Falling back to periodic filesystem polling for development, aiousbwatcher "
- "is not available on this system: %s",
- ex,
- )
- self._async_start_monitor_polling()
-
@hass_callback
def _async_start_monitor_polling(self) -> None:
"""Start monitoring hardware with polling (for development only!)."""
@@ -426,14 +425,8 @@ class USBDiscovery:
service_info,
)
- async def _async_process_ports(self, ports: Sequence[ListPortInfo]) -> None:
+ async def _async_process_ports(self, usb_devices: Sequence[USBDevice]) -> None:
"""Process each discovered port."""
- _LOGGER.debug("Processing ports: %r", ports)
- usb_devices = {
- usb_device_from_port(port)
- for port in ports
- if port.vid is not None or port.pid is not None
- }
_LOGGER.debug("USB devices: %r", usb_devices)
# CP2102N chips create *two* serial ports on macOS: `/dev/cu.usbserial-` and
@@ -445,7 +438,7 @@ class USBDiscovery:
if dev.device.startswith("/dev/cu.SLAB_USBtoUART")
}
- usb_devices = {
+ filtered_usb_devices = {
dev
for dev in usb_devices
if dev.serial_number not in silabs_serials
@@ -454,10 +447,12 @@ class USBDiscovery:
and dev.device.startswith("/dev/cu.SLAB_USBtoUART")
)
}
+ else:
+ filtered_usb_devices = set(usb_devices)
- added_devices = usb_devices - self._last_processed_devices
- removed_devices = self._last_processed_devices - usb_devices
- self._last_processed_devices = usb_devices
+ added_devices = filtered_usb_devices - self._last_processed_devices
+ removed_devices = self._last_processed_devices - filtered_usb_devices
+ self._last_processed_devices = filtered_usb_devices
_LOGGER.debug(
"Added devices: %r, removed devices: %r", added_devices, removed_devices
@@ -470,7 +465,7 @@ class USBDiscovery:
except Exception:
_LOGGER.exception("Error in USB port event callback")
- for usb_device in usb_devices:
+ for usb_device in filtered_usb_devices:
await self._async_process_discovered_usb_device(usb_device)
@hass_callback
@@ -492,7 +487,7 @@ class USBDiscovery:
_LOGGER.debug("Executing comports scan")
async with self._scan_lock:
await self._async_process_ports(
- await self.hass.async_add_executor_job(comports)
+ await self.hass.async_add_executor_job(scan_serial_ports)
)
if self.initial_scan_done:
return
@@ -530,9 +525,7 @@ async def websocket_usb_scan(
msg: dict[str, Any],
) -> None:
"""Scan for new usb devices."""
- usb_discovery: USBDiscovery = hass.data[DOMAIN]
- if not usb_discovery.observer_active:
- await usb_discovery.async_request_scan()
+ await async_request_scan(hass)
connection.send_result(msg["id"])
diff --git a/homeassistant/components/usb/utils.py b/homeassistant/components/usb/utils.py
index d1d6fb17f3c..1bb620ec5f7 100644
--- a/homeassistant/components/usb/utils.py
+++ b/homeassistant/components/usb/utils.py
@@ -2,6 +2,9 @@
from __future__ import annotations
+from collections.abc import Sequence
+
+from serial.tools.list_ports import comports
from serial.tools.list_ports_common import ListPortInfo
from .models import USBDevice
@@ -17,3 +20,12 @@ def usb_device_from_port(port: ListPortInfo) -> USBDevice:
manufacturer=port.manufacturer,
description=port.description,
)
+
+
+def scan_serial_ports() -> Sequence[USBDevice]:
+ """Scan serial ports for USB devices."""
+ return [
+ usb_device_from_port(port)
+ for port in comports()
+ if port.vid is not None or port.pid is not None
+ ]
diff --git a/homeassistant/components/utility_meter/select.py b/homeassistant/components/utility_meter/select.py
index 5815ce7ec95..0c818525c8d 100644
--- a/homeassistant/components/utility_meter/select.py
+++ b/homeassistant/components/utility_meter/select.py
@@ -10,7 +10,10 @@ from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -22,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Utility Meter config entry."""
name = config_entry.title
diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py
index cd65c42b22a..425dfa2c3fd 100644
--- a/homeassistant/components/utility_meter/sensor.py
+++ b/homeassistant/components/utility_meter/sensor.py
@@ -41,7 +41,10 @@ from homeassistant.core import (
from homeassistant.helpers import entity_platform, entity_registry as er
from homeassistant.helpers.device import async_device_info_to_link_from_entity
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.event import (
async_track_point_in_time,
async_track_state_change_event,
@@ -116,7 +119,7 @@ def validate_is_number(value):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize Utility Meter config entry."""
entry_id = config_entry.entry_id
diff --git a/homeassistant/components/v2c/binary_sensor.py b/homeassistant/components/v2c/binary_sensor.py
index 18724a4eada..85f03d6b4fb 100644
--- a/homeassistant/components/v2c/binary_sensor.py
+++ b/homeassistant/components/v2c/binary_sensor.py
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import V2CConfigEntry, V2CUpdateCoordinator
from .entity import V2CBaseEntity
@@ -50,7 +50,7 @@ TRYDAN_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: V2CConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up V2C binary sensor platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/v2c/number.py b/homeassistant/components/v2c/number.py
index 0d6401d194f..e52242f0ce0 100644
--- a/homeassistant/components/v2c/number.py
+++ b/homeassistant/components/v2c/number.py
@@ -15,7 +15,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import EntityCategory, UnitOfElectricCurrent
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import V2CConfigEntry, V2CUpdateCoordinator
from .entity import V2CBaseEntity
@@ -71,7 +71,7 @@ TRYDAN_NUMBER_SETTINGS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: V2CConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up V2C Trydan number platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/v2c/sensor.py b/homeassistant/components/v2c/sensor.py
index 5b02928385b..cfccaacda18 100644
--- a/homeassistant/components/v2c/sensor.py
+++ b/homeassistant/components/v2c/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import V2CConfigEntry, V2CUpdateCoordinator
@@ -142,7 +142,7 @@ TRYDAN_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: V2CConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up V2C sensor platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/v2c/switch.py b/homeassistant/components/v2c/switch.py
index d6ba6a3b13e..20bc3419757 100644
--- a/homeassistant/components/v2c/switch.py
+++ b/homeassistant/components/v2c/switch.py
@@ -18,7 +18,7 @@ from pytrydan.models.trydan import (
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import V2CConfigEntry, V2CUpdateCoordinator
from .entity import V2CBaseEntity
@@ -79,7 +79,7 @@ TRYDAN_SWITCHES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: V2CConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up V2C switch platform."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json
index 1efaf87e748..f9e7a2844cd 100644
--- a/homeassistant/components/vacuum/strings.json
+++ b/homeassistant/components/vacuum/strings.json
@@ -23,7 +23,7 @@
"state": {
"cleaning": "Cleaning",
"docked": "Docked",
- "error": "Error",
+ "error": "[%key:common::state::error%]",
"idle": "[%key:common::state::idle%]",
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
diff --git a/homeassistant/components/vallox/binary_sensor.py b/homeassistant/components/vallox/binary_sensor.py
index 4a0efc7b101..a205dd2039e 100644
--- a/homeassistant/components/vallox/binary_sensor.py
+++ b/homeassistant/components/vallox/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ValloxDataUpdateCoordinator
@@ -62,7 +62,7 @@ BINARY_SENSOR_ENTITIES: tuple[ValloxBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors."""
diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py
index 30d1d153d9e..c7e6af8891a 100644
--- a/homeassistant/components/vallox/config_flow.py
+++ b/homeassistant/components/vallox/config_flow.py
@@ -108,7 +108,7 @@ class ValloxConfigFlow(ConfigFlow, domain=DOMAIN):
errors[CONF_HOST] = "invalid_host"
except ValloxApiException:
errors[CONF_HOST] = "cannot_connect"
- except Exception: # pylint: disable=broad-except
+ except Exception:
_LOGGER.exception("Unexpected exception")
errors[CONF_HOST] = "unknown"
else:
diff --git a/homeassistant/components/vallox/date.py b/homeassistant/components/vallox/date.py
index 33c3ebb253c..da2906c02c2 100644
--- a/homeassistant/components/vallox/date.py
+++ b/homeassistant/components/vallox/date.py
@@ -10,7 +10,7 @@ from homeassistant.components.date import DateEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ValloxDataUpdateCoordinator
@@ -51,7 +51,7 @@ class ValloxFilterChangeDateEntity(ValloxEntity, DateEntity):
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Vallox filter change date entity."""
diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py
index 3a21ef060a7..8519b4cb913 100644
--- a/homeassistant/components/vallox/fan.py
+++ b/homeassistant/components/vallox/fan.py
@@ -11,7 +11,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
@@ -57,7 +57,9 @@ def _convert_to_int(value: StateType) -> int | None:
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the fan device."""
data = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/vallox/number.py b/homeassistant/components/vallox/number.py
index 96bc07b5a93..ce3b9c72a6d 100644
--- a/homeassistant/components/vallox/number.py
+++ b/homeassistant/components/vallox/number.py
@@ -14,7 +14,7 @@ from homeassistant.components.number import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ValloxDataUpdateCoordinator
@@ -102,7 +102,9 @@ NUMBER_ENTITIES: tuple[ValloxNumberEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors."""
data = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py
index 7165947861a..e9194a8254c 100644
--- a/homeassistant/components/vallox/sensor.py
+++ b/homeassistant/components/vallox/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
@@ -278,7 +278,9 @@ SENSOR_ENTITIES: tuple[ValloxSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors."""
name = hass.data[DOMAIN][entry.entry_id]["name"]
diff --git a/homeassistant/components/vallox/strings.json b/homeassistant/components/vallox/strings.json
index 8a30ed4ad01..2a074cf2015 100644
--- a/homeassistant/components/vallox/strings.json
+++ b/homeassistant/components/vallox/strings.json
@@ -110,7 +110,7 @@
"fields": {
"fan_speed": {
"name": "Fan speed",
- "description": "Fan speed."
+ "description": "Relative speed of the built-in fans."
}
}
},
@@ -119,7 +119,7 @@
"description": "Sets the fan speed of the Away profile.",
"fields": {
"fan_speed": {
- "name": "Fan speed",
+ "name": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::name%]",
"description": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::description%]"
}
}
@@ -129,7 +129,7 @@
"description": "Sets the fan speed of the Boost profile.",
"fields": {
"fan_speed": {
- "name": "Fan speed",
+ "name": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::name%]",
"description": "[%key:component::vallox::services::set_profile_fan_speed_home::fields::fan_speed::description%]"
}
}
@@ -152,8 +152,8 @@
"selector": {
"profile": {
"options": {
- "home": "Home",
- "away": "Away",
+ "home": "[%key:common::state::home%]",
+ "away": "[%key:common::state::not_home%]",
"boost": "Boost",
"fireplace": "Fireplace",
"extra": "Extra"
diff --git a/homeassistant/components/vallox/switch.py b/homeassistant/components/vallox/switch.py
index 20b270f8f18..9386f914f58 100644
--- a/homeassistant/components/vallox/switch.py
+++ b/homeassistant/components/vallox/switch.py
@@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity, SwitchEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ValloxDataUpdateCoordinator
@@ -82,7 +82,7 @@ SWITCH_ENTITIES: tuple[ValloxSwitchEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switches."""
diff --git a/homeassistant/components/valve/strings.json b/homeassistant/components/valve/strings.json
index b86ec371b34..39dc297fe7d 100644
--- a/homeassistant/components/valve/strings.json
+++ b/homeassistant/components/valve/strings.json
@@ -5,10 +5,10 @@
"name": "[%key:component::valve::title%]",
"state": {
"open": "[%key:common::state::open%]",
- "opening": "Opening",
+ "opening": "[%key:common::state::opening%]",
"closed": "[%key:common::state::closed%]",
- "closing": "Closing",
- "stopped": "Stopped"
+ "closing": "[%key:common::state::closing%]",
+ "stopped": "[%key:common::state::stopped%]"
},
"state_attributes": {
"current_position": {
diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py
index 41b8730eeb0..35c61892964 100644
--- a/homeassistant/components/velbus/__init__.py
+++ b/homeassistant/components/velbus/__init__.py
@@ -135,15 +135,39 @@ async def async_migrate_entry(
hass: HomeAssistant, config_entry: VelbusConfigEntry
) -> bool:
"""Migrate old entry."""
- _LOGGER.debug("Migrating from version %s", config_entry.version)
- cache_path = hass.config.path(STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/")
- if config_entry.version == 1:
- # This is the config entry migration for adding the new program selection
+ _LOGGER.error(
+ "Migrating from version %s.%s", config_entry.version, config_entry.minor_version
+ )
+
+ # This is the config entry migration for adding the new program selection
+ # migrate from 1.x to 2.1
+ if config_entry.version < 2:
# clean the velbusCache
+ cache_path = hass.config.path(
+ STORAGE_DIR, f"velbuscache-{config_entry.entry_id}/"
+ )
if os.path.isdir(cache_path):
await hass.async_add_executor_job(shutil.rmtree, cache_path)
- # set the new version
- hass.config_entries.async_update_entry(config_entry, version=2)
- _LOGGER.debug("Migration to version %s successful", config_entry.version)
+ # This is the config entry migration for swapping the usb unique id to the serial number
+ # migrate from 2.1 to 2.2
+ if (
+ config_entry.version < 3
+ and config_entry.minor_version == 1
+ and config_entry.unique_id is not None
+ ):
+ # not all velbus devices have a unique id, so handle this correctly
+ parts = config_entry.unique_id.split("_")
+ # old one should have 4 item
+ if len(parts) == 4:
+ hass.config_entries.async_update_entry(config_entry, unique_id=parts[1])
+
+ # update the config entry
+ hass.config_entries.async_update_entry(config_entry, version=2, minor_version=2)
+
+ _LOGGER.error(
+ "Migration to version %s.%s successful",
+ config_entry.version,
+ config_entry.minor_version,
+ )
return True
diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py
index 88dc994efe8..2ddf6605c19 100644
--- a/homeassistant/components/velbus/binary_sensor.py
+++ b/homeassistant/components/velbus/binary_sensor.py
@@ -4,7 +4,7 @@ from velbusaio.channels import Button as VelbusButton
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VelbusConfigEntry
from .entity import VelbusEntity
@@ -15,7 +15,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: VelbusConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
await entry.runtime_data.scan_task
diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py
index fc943159123..8f736dcd35b 100644
--- a/homeassistant/components/velbus/button.py
+++ b/homeassistant/components/velbus/button.py
@@ -10,7 +10,7 @@ from velbusaio.channels import (
from homeassistant.components.button import ButtonEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VelbusConfigEntry
from .entity import VelbusEntity, api_call
@@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: VelbusConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
await entry.runtime_data.scan_task
diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py
index b2f3077ecee..e31d9a97416 100644
--- a/homeassistant/components/velbus/climate.py
+++ b/homeassistant/components/velbus/climate.py
@@ -14,7 +14,7 @@ from homeassistant.components.climate import (
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VelbusConfigEntry
from .const import DOMAIN, PRESET_MODES
@@ -26,7 +26,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: VelbusConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
await entry.runtime_data.scan_task
diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py
index 9e99b2631d4..7c93d8784ad 100644
--- a/homeassistant/components/velbus/config_flow.py
+++ b/homeassistant/components/velbus/config_flow.py
@@ -4,22 +4,23 @@ from __future__ import annotations
from typing import Any
+import serial.tools.list_ports
import velbusaio.controller
from velbusaio.exceptions import VelbusConnectionFailed
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
-from homeassistant.const import CONF_NAME, CONF_PORT
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.helpers.service_info.usb import UsbServiceInfo
-from homeassistant.util import slugify
-from .const import DOMAIN
+from .const import CONF_TLS, DOMAIN
class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
VERSION = 2
+ MINOR_VERSION = 2
def __init__(self) -> None:
"""Initialize the velbus config flow."""
@@ -27,14 +28,16 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
self._device: str = ""
self._title: str = ""
- def _create_device(self, name: str, prt: str) -> ConfigFlowResult:
+ def _create_device(self) -> ConfigFlowResult:
"""Create an entry async."""
- return self.async_create_entry(title=name, data={CONF_PORT: prt})
+ return self.async_create_entry(
+ title=self._title, data={CONF_PORT: self._device}
+ )
- async def _test_connection(self, prt: str) -> bool:
+ async def _test_connection(self) -> bool:
"""Try to connect to the velbus with the port specified."""
try:
- controller = velbusaio.controller.Velbus(prt)
+ controller = velbusaio.controller.Velbus(self._device)
await controller.connect()
await controller.stop()
except VelbusConnectionFailed:
@@ -46,43 +49,86 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step when user initializes a integration."""
- self._errors = {}
+ return self.async_show_menu(
+ step_id="user", menu_options=["network", "usbselect"]
+ )
+
+ async def async_step_network(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle network step."""
if user_input is not None:
- name = slugify(user_input[CONF_NAME])
- prt = user_input[CONF_PORT]
- self._async_abort_entries_match({CONF_PORT: prt})
- if await self._test_connection(prt):
- return self._create_device(name, prt)
+ self._title = "Velbus Network"
+ if user_input[CONF_TLS]:
+ self._device = "tls://"
+ else:
+ self._device = ""
+ if CONF_PASSWORD in user_input and user_input[CONF_PASSWORD] != "":
+ self._device += f"{user_input[CONF_PASSWORD]}@"
+ self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
+ self._async_abort_entries_match({CONF_PORT: self._device})
+ if await self._test_connection():
+ return self._create_device()
+ else:
+ user_input = {
+ CONF_TLS: True,
+ CONF_PORT: 27015,
+ }
+
+ return self.async_show_form(
+ step_id="network",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema(
+ {
+ vol.Required(CONF_TLS): bool,
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_PORT): int,
+ vol.Optional(CONF_PASSWORD): str,
+ }
+ ),
+ suggested_values=user_input,
+ ),
+ errors=self._errors,
+ )
+
+ async def async_step_usbselect(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle usb select step."""
+ ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
+ list_of_ports = [
+ f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}"
+ + (f" - {p.manufacturer}" if p.manufacturer else "")
+ for p in ports
+ ]
+
+ if user_input is not None:
+ self._title = "Velbus USB"
+ self._device = ports[list_of_ports.index(user_input[CONF_PORT])].device
+ self._async_abort_entries_match({CONF_PORT: self._device})
+ if await self._test_connection():
+ return self._create_device()
else:
user_input = {}
- user_input[CONF_NAME] = ""
user_input[CONF_PORT] = ""
return self.async_show_form(
- step_id="user",
- data_schema=vol.Schema(
- {
- vol.Required(CONF_NAME, default=user_input[CONF_NAME]): str,
- vol.Required(CONF_PORT, default=user_input[CONF_PORT]): str,
- }
+ step_id="usbselect",
+ data_schema=self.add_suggested_values_to_schema(
+ vol.Schema({vol.Required(CONF_PORT): vol.In(list_of_ports)}),
+ suggested_values=user_input,
),
errors=self._errors,
)
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
"""Handle USB Discovery."""
- await self.async_set_unique_id(
- f"{discovery_info.vid}:{discovery_info.pid}_{discovery_info.serial_number}_{discovery_info.manufacturer}_{discovery_info.description}"
- )
- dev_path = discovery_info.device
- # check if this device is not already configured
- self._async_abort_entries_match({CONF_PORT: dev_path})
- # check if we can make a valid velbus connection
- if not await self._test_connection(dev_path):
- return self.async_abort(reason="cannot_connect")
- # store the data for the config step
- self._device = dev_path
+ await self.async_set_unique_id(discovery_info.serial_number)
+ self._device = discovery_info.device
self._title = "Velbus USB"
+ self._async_abort_entries_match({CONF_PORT: self._device})
+ if not await self._test_connection():
+ return self.async_abort(reason="cannot_connect")
# call the config step
self._set_confirm_only()
return await self.async_step_discovery_confirm()
@@ -92,7 +138,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle Discovery confirmation."""
if user_input is not None:
- return self._create_device(self._title, self._device)
+ return self._create_device()
return self.async_show_form(
step_id="discovery_confirm",
diff --git a/homeassistant/components/velbus/const.py b/homeassistant/components/velbus/const.py
index b40f64e8607..f42e449bdcc 100644
--- a/homeassistant/components/velbus/const.py
+++ b/homeassistant/components/velbus/const.py
@@ -14,6 +14,7 @@ DOMAIN: Final = "velbus"
CONF_CONFIG_ENTRY: Final = "config_entry"
CONF_INTERFACE: Final = "interface"
CONF_MEMO_TEXT: Final = "memo_text"
+CONF_TLS: Final = "tls"
SERVICE_SCAN: Final = "scan"
SERVICE_SYNC: Final = "sync_clock"
diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py
index 2ddea37f2d6..995b7e9d59c 100644
--- a/homeassistant/components/velbus/cover.py
+++ b/homeassistant/components/velbus/cover.py
@@ -12,7 +12,7 @@ from homeassistant.components.cover import (
CoverEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VelbusConfigEntry
from .entity import VelbusEntity, api_call
@@ -23,7 +23,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: VelbusConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
await entry.runtime_data.scan_task
diff --git a/homeassistant/components/velbus/light.py b/homeassistant/components/velbus/light.py
index c134095c2ff..5037e2b1ced 100644
--- a/homeassistant/components/velbus/light.py
+++ b/homeassistant/components/velbus/light.py
@@ -23,7 +23,7 @@ from homeassistant.components.light import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VelbusConfigEntry
from .entity import VelbusEntity, api_call
@@ -34,7 +34,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: VelbusConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
await entry.runtime_data.scan_task
diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json
index 960f127d16e..1cb540b22ec 100644
--- a/homeassistant/components/velbus/manifest.json
+++ b/homeassistant/components/velbus/manifest.json
@@ -13,7 +13,8 @@
"velbus-packet",
"velbus-protocol"
],
- "requirements": ["velbus-aio==2025.1.1"],
+ "quality_scale": "bronze",
+ "requirements": ["velbus-aio==2025.3.1"],
"usb": [
{
"vid": "10CF",
diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml
index 0ad3e3ce485..829f48e6f52 100644
--- a/homeassistant/components/velbus/quality_scale.yaml
+++ b/homeassistant/components/velbus/quality_scale.yaml
@@ -8,10 +8,7 @@ rules:
brands: done
common-modules: done
config-flow-test-coverage: done
- config-flow:
- status: todo
- comment: |
- Dynamically build up the port parameter based on inputs provided by the user, do not fill-in a name parameter, build it up in the config flow
+ config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
diff --git a/homeassistant/components/velbus/select.py b/homeassistant/components/velbus/select.py
index 6c2dfe0a3b1..1d52b8d4afc 100644
--- a/homeassistant/components/velbus/select.py
+++ b/homeassistant/components/velbus/select.py
@@ -5,7 +5,7 @@ from velbusaio.channels import SelectedProgram
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VelbusConfigEntry
from .entity import VelbusEntity, api_call
@@ -16,7 +16,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: VelbusConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Velbus select based on config_entry."""
await entry.runtime_data.scan_task
diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py
index 77833da3ee1..96ef91e8174 100644
--- a/homeassistant/components/velbus/sensor.py
+++ b/homeassistant/components/velbus/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VelbusConfigEntry
from .entity import VelbusEntity
@@ -21,7 +21,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: VelbusConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
await entry.runtime_data.scan_task
diff --git a/homeassistant/components/velbus/strings.json b/homeassistant/components/velbus/strings.json
index 69fc3d661e9..35f94e54470 100644
--- a/homeassistant/components/velbus/strings.json
+++ b/homeassistant/components/velbus/strings.json
@@ -2,11 +2,38 @@
"config": {
"step": {
"user": {
- "title": "Define the Velbus connection type",
- "data": {
- "name": "The name for this Velbus connection",
- "port": "Connection string"
+ "title": "Define the Velbus connection",
+ "description": "How do you want to configure the Velbus hub?",
+ "menu_options": {
+ "network": "Via network connection",
+ "usbselect": "Via USB device"
}
+ },
+ "network": {
+ "title": "TCP/IP configuration",
+ "data": {
+ "tls": "Use TLS (secure connection)",
+ "host": "[%key:common::config_flow::data::host%]",
+ "port": "[%key:common::config_flow::data::port%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "tls": "Enable this if you use a secure connection to your Velbus interface, like a Signum.",
+ "host": "The IP address or hostname of the Velbus interface.",
+ "port": "The port number of the Velbus interface.",
+ "password": "The password of the Velbus interface, this is only needed if the interface is password protected."
+ },
+ "description": "TCP/IP configuration, in case you use a Signum, VelServ, velbus-tcp or any other Velbus to TCP/IP interface."
+ },
+ "usbselect": {
+ "title": "USB configuration",
+ "data": {
+ "port": "[%key:common::config_flow::data::port%]"
+ },
+ "data_description": {
+ "port": "Select the serial port for your Velbus USB interface."
+ },
+ "description": "Select the serial port for your Velbus USB interface."
}
},
"error": {
diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py
index 8256e716d4f..40dc3c09f73 100644
--- a/homeassistant/components/velbus/switch.py
+++ b/homeassistant/components/velbus/switch.py
@@ -6,7 +6,7 @@ from velbusaio.channels import Relay as VelbusRelay
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VelbusConfigEntry
from .entity import VelbusEntity, api_call
@@ -17,7 +17,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: VelbusConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Velbus switch based on config_entry."""
await entry.runtime_data.scan_task
diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py
index 90745f601b4..d6bf8905d91 100644
--- a/homeassistant/components/velux/cover.py
+++ b/homeassistant/components/velux/cover.py
@@ -16,7 +16,7 @@ from homeassistant.components.cover import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import VeluxEntity
@@ -25,7 +25,9 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
- hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up cover(s) for Velux platform."""
module = hass.data[DOMAIN][config.entry_id]
diff --git a/homeassistant/components/velux/entity.py b/homeassistant/components/velux/entity.py
index 674ba5dde45..1231a98e0a8 100644
--- a/homeassistant/components/velux/entity.py
+++ b/homeassistant/components/velux/entity.py
@@ -31,6 +31,6 @@ class VeluxEntity(Entity):
self.node.register_device_updated_cb(after_update_callback)
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Store register state change callback."""
self.async_register_callbacks()
diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py
index 14f12a01060..b991239b7a4 100644
--- a/homeassistant/components/velux/light.py
+++ b/homeassistant/components/velux/light.py
@@ -9,7 +9,7 @@ from pyvlx import Intensity, LighteningDevice
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import VeluxEntity
@@ -18,7 +18,9 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
- hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up light(s) for Velux platform."""
module = hass.data[DOMAIN][config.entry_id]
diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py
index 54888413613..636ab82e819 100644
--- a/homeassistant/components/velux/scene.py
+++ b/homeassistant/components/velux/scene.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.scene import Scene
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
@@ -15,7 +15,9 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
- hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ config: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the scenes for Velux platform."""
module = hass.data[DOMAIN][config.entry_id]
diff --git a/homeassistant/components/venstar/binary_sensor.py b/homeassistant/components/venstar/binary_sensor.py
index 315df09b625..672db463791 100644
--- a/homeassistant/components/venstar/binary_sensor.py
+++ b/homeassistant/components/venstar/binary_sensor.py
@@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import VenstarEntity
@@ -15,7 +15,7 @@ from .entity import VenstarEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Vensar device binary_sensors based on a config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py
index 50f6508e7ed..ade86e8dd71 100644
--- a/homeassistant/components/venstar/climate.py
+++ b/homeassistant/components/venstar/climate.py
@@ -33,7 +33,10 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import (
@@ -66,7 +69,7 @@ PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Venstar thermostat."""
venstar_data_coordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py
index 94180f6ad79..14e7103a83f 100644
--- a/homeassistant/components/venstar/sensor.py
+++ b/homeassistant/components/venstar/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import VenstarDataUpdateCoordinator
@@ -81,7 +81,7 @@ class VenstarSensorEntityDescription(SensorEntityDescription):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Venstar device sensors based on a config entry."""
coordinator: VenstarDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/venstar/strings.json b/homeassistant/components/venstar/strings.json
index fdc75162651..1d916d0b8f6 100644
--- a/homeassistant/components/venstar/strings.json
+++ b/homeassistant/components/venstar/strings.json
@@ -32,7 +32,7 @@
"name": "Filter usage"
},
"schedule_part": {
- "name": "Schedule Part",
+ "name": "Schedule part",
"state": {
"morning": "Morning",
"day": "Day",
@@ -44,7 +44,7 @@
"active_stage": {
"name": "Active stage",
"state": {
- "idle": "Idle",
+ "idle": "[%key:common::state::idle%]",
"first_stage": "First stage",
"second_stage": "Second stage"
}
diff --git a/homeassistant/components/vera/binary_sensor.py b/homeassistant/components/vera/binary_sensor.py
index 3438ee81d4a..00780fec8ce 100644
--- a/homeassistant/components/vera/binary_sensor.py
+++ b/homeassistant/components/vera/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySenso
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import ControllerData, get_controller_data
from .entity import VeraEntity
@@ -17,7 +17,7 @@ from .entity import VeraEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
controller_data = get_controller_data(hass, entry)
diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py
index eb2a5206f30..084725f484e 100644
--- a/homeassistant/components/vera/climate.py
+++ b/homeassistant/components/vera/climate.py
@@ -17,7 +17,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import ControllerData, get_controller_data
from .entity import VeraEntity
@@ -30,7 +30,7 @@ SUPPORT_HVAC = [HVACMode.COOL, HVACMode.HEAT, HVACMode.HEAT_COOL, HVACMode.OFF]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
controller_data = get_controller_data(hass, entry)
diff --git a/homeassistant/components/vera/cover.py b/homeassistant/components/vera/cover.py
index b5b57f43c0c..8256804b8a3 100644
--- a/homeassistant/components/vera/cover.py
+++ b/homeassistant/components/vera/cover.py
@@ -10,7 +10,7 @@ from homeassistant.components.cover import ATTR_POSITION, ENTITY_ID_FORMAT, Cove
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import ControllerData, get_controller_data
from .entity import VeraEntity
@@ -19,7 +19,7 @@ from .entity import VeraEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
controller_data = get_controller_data(hass, entry)
diff --git a/homeassistant/components/vera/entity.py b/homeassistant/components/vera/entity.py
index 84e21e54983..b3013c288c1 100644
--- a/homeassistant/components/vera/entity.py
+++ b/homeassistant/components/vera/entity.py
@@ -52,7 +52,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity):
"""Update the state."""
self.schedule_update_ha_state(True)
- def update(self):
+ def update(self) -> None:
"""Force a refresh from the device if the device is unavailable."""
refresh_needed = self.vera_device.should_poll or not self.available
_LOGGER.debug("%s: update called (refresh=%s)", self._name, refresh_needed)
@@ -90,7 +90,7 @@ class VeraEntity[_DeviceTypeT: veraApi.VeraDevice](Entity):
return attr
@property
- def available(self):
+ def available(self) -> bool:
"""If device communications have failed return false."""
return not self.vera_device.comm_failure
diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py
index 9b8ae42f620..f573fcd94ea 100644
--- a/homeassistant/components/vera/light.py
+++ b/homeassistant/components/vera/light.py
@@ -16,7 +16,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .common import ControllerData, get_controller_data
@@ -26,7 +26,7 @@ from .entity import VeraEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
controller_data = get_controller_data(hass, entry)
diff --git a/homeassistant/components/vera/lock.py b/homeassistant/components/vera/lock.py
index 18f0b9de3e2..3f76f3a6106 100644
--- a/homeassistant/components/vera/lock.py
+++ b/homeassistant/components/vera/lock.py
@@ -10,7 +10,7 @@ from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import ControllerData, get_controller_data
from .entity import VeraEntity
@@ -22,7 +22,7 @@ ATTR_LOW_BATTERY = "low_battery"
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
controller_data = get_controller_data(hass, entry)
diff --git a/homeassistant/components/vera/scene.py b/homeassistant/components/vera/scene.py
index 22061f98929..0e504b12303 100644
--- a/homeassistant/components/vera/scene.py
+++ b/homeassistant/components/vera/scene.py
@@ -9,7 +9,7 @@ import pyvera as veraApi
from homeassistant.components.scene import Scene
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import slugify
from .common import ControllerData, get_controller_data
@@ -19,7 +19,7 @@ from .const import VERA_ID_FORMAT
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
controller_data = get_controller_data(hass, entry)
diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py
index 95f1fa0bd89..d778b4c2e5d 100644
--- a/homeassistant/components/vera/sensor.py
+++ b/homeassistant/components/vera/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import ControllerData, get_controller_data
from .entity import VeraEntity
@@ -32,7 +32,7 @@ SCAN_INTERVAL = timedelta(seconds=5)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
controller_data = get_controller_data(hass, entry)
diff --git a/homeassistant/components/vera/switch.py b/homeassistant/components/vera/switch.py
index ad7fbe68458..67be4a7849a 100644
--- a/homeassistant/components/vera/switch.py
+++ b/homeassistant/components/vera/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import ControllerData, get_controller_data
from .entity import VeraEntity
@@ -19,7 +19,7 @@ from .entity import VeraEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
controller_data = get_controller_data(hass, entry)
diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py
index 2b9ae7b60b6..7ead1f014c8 100644
--- a/homeassistant/components/verisure/alarm_control_panel.py
+++ b/homeassistant/components/verisure/alarm_control_panel.py
@@ -13,7 +13,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ALARM_STATE_TO_HA, CONF_GIID, DOMAIN, LOGGER
@@ -23,7 +23,7 @@ from .coordinator import VerisureDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Verisure alarm control panel from a config entry."""
async_add_entities([VerisureAlarm(coordinator=hass.data[DOMAIN][entry.entry_id])])
diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py
index 94a44550d47..4d9221c3ca9 100644
--- a/homeassistant/components/verisure/binary_sensor.py
+++ b/homeassistant/components/verisure/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.const import ATTR_LAST_TRIP_TIME, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import dt as dt_util
@@ -22,7 +22,7 @@ from .coordinator import VerisureDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Verisure binary sensors based on a config entry."""
coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py
index 7f49f917d83..1f5d48ea197 100644
--- a/homeassistant/components/verisure/camera.py
+++ b/homeassistant/components/verisure/camera.py
@@ -13,7 +13,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -25,7 +25,7 @@ from .coordinator import VerisureDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Verisure sensors based on a config entry."""
coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py
index 16c69ecf2e2..76aeedd05fa 100644
--- a/homeassistant/components/verisure/lock.py
+++ b/homeassistant/components/verisure/lock.py
@@ -13,7 +13,7 @@ from homeassistant.const import ATTR_CODE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -33,7 +33,7 @@ from .coordinator import VerisureDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Verisure alarm control panel from a config entry."""
coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py
index 77a576caad8..6ed4784bffb 100644
--- a/homeassistant/components/verisure/sensor.py
+++ b/homeassistant/components/verisure/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_GIID, DEVICE_TYPE_NAME, DOMAIN
@@ -22,7 +22,7 @@ from .coordinator import VerisureDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Verisure sensors based on a config entry."""
coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py
index 838e0222087..0deb1da5e95 100644
--- a/homeassistant/components/verisure/switch.py
+++ b/homeassistant/components/verisure/switch.py
@@ -9,7 +9,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_GIID, DOMAIN
@@ -19,7 +19,7 @@ from .coordinator import VerisureDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Verisure alarm control panel from a config entry."""
coordinator: VerisureDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/version/binary_sensor.py b/homeassistant/components/version/binary_sensor.py
index 6fafd219417..900daa7aba1 100644
--- a/homeassistant/components/version/binary_sensor.py
+++ b/homeassistant/components/version/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import CONF_NAME, EntityCategory, __version__ as HA_VERSION
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_SOURCE, DEFAULT_NAME
from .coordinator import VersionConfigEntry
@@ -23,7 +23,7 @@ HA_VERSION_OBJECT = AwesomeVersion(HA_VERSION)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: VersionConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up version binary_sensors."""
coordinator = config_entry.runtime_data
diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py
index d44625d38f8..7e173b46d36 100644
--- a/homeassistant/components/version/sensor.py
+++ b/homeassistant/components/version/sensor.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import CONF_SOURCE, DEFAULT_NAME
@@ -18,7 +18,7 @@ from .entity import VersionEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: VersionConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up version sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py
index 4951bdb2dc1..dddf7857545 100644
--- a/homeassistant/components/vesync/__init__.py
+++ b/homeassistant/components/vesync/__init__.py
@@ -5,8 +5,14 @@ import logging
from pyvesync import VeSync
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
-from homeassistant.core import HomeAssistant, ServiceCall
+from homeassistant.const import (
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ EVENT_LOGGING_CHANGED,
+ Platform,
+)
+from homeassistant.core import Event, HomeAssistant, ServiceCall, callback
+from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -17,6 +23,7 @@ from .const import (
VS_COORDINATOR,
VS_DEVICES,
VS_DISCOVERY,
+ VS_LISTENERS,
VS_MANAGER,
)
from .coordinator import VeSyncDataCoordinator
@@ -27,6 +34,7 @@ PLATFORMS = [
Platform.HUMIDIFIER,
Platform.LIGHT,
Platform.NUMBER,
+ Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
@@ -41,13 +49,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
time_zone = str(hass.config.time_zone)
- manager = VeSync(username, password, time_zone)
+ manager = VeSync(
+ username=username,
+ password=password,
+ time_zone=time_zone,
+ debug=logging.getLogger("pyvesync.vesync").level == logging.DEBUG,
+ redact=True,
+ )
login = await hass.async_add_executor_job(manager.login)
if not login:
- _LOGGER.error("Unable to login to the VeSync server")
- return False
+ raise ConfigEntryAuthFailed
hass.data[DOMAIN] = {}
hass.data[DOMAIN][VS_MANAGER] = manager
@@ -61,6 +74,17 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
+ @callback
+ def _async_handle_logging_changed(_event: Event) -> None:
+ """Handle when the logging level changes."""
+ manager.debug = logging.getLogger("pyvesync.vesync").level == logging.DEBUG
+
+ cleanup = hass.bus.async_listen(
+ EVENT_LOGGING_CHANGED, _async_handle_logging_changed
+ )
+
+ hass.data[DOMAIN][VS_LISTENERS] = cleanup
+
async def async_new_device_discovery(service: ServiceCall) -> None:
"""Discover if new devices should be added."""
manager = hass.data[DOMAIN][VS_MANAGER]
@@ -86,7 +110,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
-
+ hass.data[DOMAIN][VS_LISTENERS]()
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data.pop(DOMAIN)
diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py
index dd1b6398c06..7b6f14e04dc 100644
--- a/homeassistant/components/vesync/binary_sensor.py
+++ b/homeassistant/components/vesync/binary_sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import rgetattr
from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
@@ -52,7 +52,7 @@ SENSOR_DESCRIPTIONS: tuple[VeSyncBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary_sensor platform."""
@@ -102,5 +102,4 @@ class VeSyncBinarySensor(BinarySensorEntity, VeSyncBaseEntity):
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
- _LOGGER.debug(rgetattr(self.device, self.entity_description.key))
return self.entity_description.is_on(self.device)
diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py
index 07543440e91..e5537d8fcc9 100644
--- a/homeassistant/components/vesync/config_flow.py
+++ b/homeassistant/components/vesync/config_flow.py
@@ -1,5 +1,6 @@
"""Config flow utilities."""
+from collections.abc import Mapping
from typing import Any
from pyvesync import VeSync
@@ -57,3 +58,36 @@ class VeSyncFlowHandler(ConfigFlow, domain=DOMAIN):
title=username,
data={CONF_USERNAME: username, CONF_PASSWORD: password},
)
+
+ async def async_step_reauth(
+ self, entry_data: Mapping[str, Any]
+ ) -> ConfigFlowResult:
+ """Handle re-authentication with vesync."""
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm re-authentication with vesync."""
+
+ if user_input:
+ username = user_input[CONF_USERNAME]
+ password = user_input[CONF_PASSWORD]
+
+ manager = VeSync(username, password)
+ login = await self.hass.async_add_executor_job(manager.login)
+ if login:
+ return self.async_update_reload_and_abort(
+ self._get_reauth_entry(),
+ data_updates={
+ CONF_USERNAME: username,
+ CONF_PASSWORD: password,
+ },
+ )
+
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ data_schema=DATA_SCHEMA,
+ description_placeholders={"name": "VeSync"},
+ errors={"base": "invalid_auth"},
+ )
diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py
index 34454081567..4e39fe40f2d 100644
--- a/homeassistant/components/vesync/const.py
+++ b/homeassistant/components/vesync/const.py
@@ -22,6 +22,7 @@ exceeds the quota of 7700.
VS_DEVICES = "devices"
VS_COORDINATOR = "coordinator"
VS_MANAGER = "manager"
+VS_LISTENERS = "listeners"
VS_NUMBERS = "numbers"
VS_HUMIDIFIER_MODE_AUTO = "auto"
@@ -29,6 +30,14 @@ VS_HUMIDIFIER_MODE_HUMIDITY = "humidity"
VS_HUMIDIFIER_MODE_MANUAL = "manual"
VS_HUMIDIFIER_MODE_SLEEP = "sleep"
+FAN_NIGHT_LIGHT_LEVEL_DIM = "dim"
+FAN_NIGHT_LIGHT_LEVEL_OFF = "off"
+FAN_NIGHT_LIGHT_LEVEL_ON = "on"
+
+HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT = "bright"
+HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim"
+HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF = "off"
+
VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S
"""Humidifier device types"""
@@ -59,6 +68,7 @@ SKU_TO_BASE_DEVICE = {
# Air Purifiers
"LV-PUR131S": "LV-PUR131S",
"LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S
+ "LV-RH131S-WM": "LV-PUR131S", # Alt ID Model LV-PUR131S
"Core200S": "Core200S",
"LAP-C201S-AUSR": "Core200S", # Alt ID Model Core200S
"LAP-C202S-WUSR": "Core200S", # Alt ID Model Core200S
diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py
index 21a92a22db2..daf734d50a8 100644
--- a/homeassistant/components/vesync/fan.py
+++ b/homeassistant/components/vesync/fan.py
@@ -12,7 +12,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -72,7 +72,7 @@ SPEED_RANGE = { # off is not included
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the VeSync fan platform."""
diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py
index 5afe7360673..9a98a39aa8c 100644
--- a/homeassistant/components/vesync/humidifier.py
+++ b/homeassistant/components/vesync/humidifier.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import is_humidifier
from .const import (
@@ -50,7 +50,7 @@ VS_TO_HA_MODE_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the VeSync humidifier platform."""
@@ -71,7 +71,7 @@ async def async_setup_entry(
@callback
def _setup_entities(
devices: list[VeSyncBaseDevice],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
coordinator: VeSyncDataCoordinator,
):
"""Add humidifier entities."""
diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py
index 40f68986145..887400b2cf0 100644
--- a/homeassistant/components/vesync/light.py
+++ b/homeassistant/components/vesync/light.py
@@ -14,7 +14,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
@@ -29,7 +29,7 @@ MIN_MIREDS = 153 # 1,000,000 divided by 6500 Kelvin = 153 Mireds
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up lights."""
diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json
index b3697844f19..571c6ee0036 100644
--- a/homeassistant/components/vesync/manifest.json
+++ b/homeassistant/components/vesync/manifest.json
@@ -11,6 +11,6 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/vesync",
"iot_class": "cloud_polling",
- "loggers": ["pyvesync"],
- "requirements": ["pyvesync==2.1.17"]
+ "loggers": ["pyvesync.vesync"],
+ "requirements": ["pyvesync==2.1.18"]
}
diff --git a/homeassistant/components/vesync/number.py b/homeassistant/components/vesync/number.py
index 3c43cce28cf..707dd6ab30e 100644
--- a/homeassistant/components/vesync/number.py
+++ b/homeassistant/components/vesync/number.py
@@ -14,7 +14,7 @@ from homeassistant.components.number import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import is_humidifier
from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
@@ -51,7 +51,7 @@ NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up number entities."""
@@ -72,7 +72,7 @@ async def async_setup_entry(
@callback
def _setup_entities(
devices: list[VeSyncBaseDevice],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
coordinator: VeSyncDataCoordinator,
):
"""Add number entities."""
diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py
new file mode 100644
index 00000000000..a9d2e1b533a
--- /dev/null
+++ b/homeassistant/components/vesync/select.py
@@ -0,0 +1,155 @@
+"""Support for VeSync numeric entities."""
+
+from collections.abc import Callable
+from dataclasses import dataclass
+import logging
+
+from pyvesync.vesyncbasedevice import VeSyncBaseDevice
+
+from homeassistant.components.select import SelectEntity, SelectEntityDescription
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from .common import rgetattr
+from .const import (
+ DOMAIN,
+ FAN_NIGHT_LIGHT_LEVEL_DIM,
+ FAN_NIGHT_LIGHT_LEVEL_OFF,
+ FAN_NIGHT_LIGHT_LEVEL_ON,
+ HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT,
+ HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM,
+ HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF,
+ VS_COORDINATOR,
+ VS_DEVICES,
+ VS_DISCOVERY,
+)
+from .coordinator import VeSyncDataCoordinator
+from .entity import VeSyncBaseEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP = {
+ 100: HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT,
+ 50: HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM,
+ 0: HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF,
+}
+
+HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP = {
+ v: k for k, v in VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.items()
+}
+
+
+@dataclass(frozen=True, kw_only=True)
+class VeSyncSelectEntityDescription(SelectEntityDescription):
+ """Class to describe a Vesync select entity."""
+
+ exists_fn: Callable[[VeSyncBaseDevice], bool]
+ current_option_fn: Callable[[VeSyncBaseDevice], str]
+ select_option_fn: Callable[[VeSyncBaseDevice, str], bool]
+
+
+SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [
+ # night_light for humidifier
+ VeSyncSelectEntityDescription(
+ key="night_light_level",
+ translation_key="night_light_level",
+ options=list(VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.values()),
+ icon="mdi:brightness-6",
+ exists_fn=lambda device: rgetattr(device, "set_night_light_brightness"),
+ # The select_option service framework ensures that only options specified are
+ # accepted. ServiceValidationError gets raised for invalid value.
+ select_option_fn=lambda device, value: device.set_night_light_brightness(
+ HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get(value, 0)
+ ),
+ # Reporting "off" as the choice for unhandled level.
+ current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get(
+ device.details.get("night_light_brightness"),
+ HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF,
+ ),
+ ),
+ # night_light for fan devices based on pyvesync.VeSyncAirBypass
+ VeSyncSelectEntityDescription(
+ key="night_light_level",
+ translation_key="night_light_level",
+ options=[
+ FAN_NIGHT_LIGHT_LEVEL_OFF,
+ FAN_NIGHT_LIGHT_LEVEL_DIM,
+ FAN_NIGHT_LIGHT_LEVEL_ON,
+ ],
+ icon="mdi:brightness-6",
+ exists_fn=lambda device: rgetattr(device, "set_night_light"),
+ select_option_fn=lambda device, value: device.set_night_light(value),
+ current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get(
+ device.details.get("night_light"),
+ FAN_NIGHT_LIGHT_LEVEL_OFF,
+ ),
+ ),
+]
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up select entities."""
+
+ coordinator = hass.data[DOMAIN][VS_COORDINATOR]
+
+ @callback
+ def discover(devices):
+ """Add new devices to platform."""
+ _setup_entities(devices, async_add_entities, coordinator)
+
+ config_entry.async_on_unload(
+ async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover)
+ )
+
+ _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator)
+
+
+@callback
+def _setup_entities(
+ devices: list[VeSyncBaseDevice],
+ async_add_entities: AddConfigEntryEntitiesCallback,
+ coordinator: VeSyncDataCoordinator,
+):
+ """Add select entities."""
+
+ async_add_entities(
+ VeSyncSelectEntity(dev, description, coordinator)
+ for dev in devices
+ for description in SELECT_DESCRIPTIONS
+ if description.exists_fn(dev)
+ )
+
+
+class VeSyncSelectEntity(VeSyncBaseEntity, SelectEntity):
+ """A class to set numeric options on Vesync device."""
+
+ entity_description: VeSyncSelectEntityDescription
+
+ def __init__(
+ self,
+ device: VeSyncBaseDevice,
+ description: VeSyncSelectEntityDescription,
+ coordinator: VeSyncDataCoordinator,
+ ) -> None:
+ """Initialize the VeSync select device."""
+ super().__init__(device, coordinator)
+ self.entity_description = description
+ self._attr_unique_id = f"{super().unique_id}-{description.key}"
+
+ @property
+ def current_option(self) -> str | None:
+ """Return an option."""
+ return self.entity_description.current_option_fn(self.device)
+
+ async def async_select_option(self, option: str) -> None:
+ """Set an option."""
+ if await self.hass.async_add_executor_job(
+ self.entity_description.select_option_fn, self.device, option
+ ):
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py
index bf52050d745..3bc6608989a 100644
--- a/homeassistant/components/vesync/sensor.py
+++ b/homeassistant/components/vesync/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .common import is_humidifier
@@ -194,7 +194,7 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switches."""
@@ -215,7 +215,7 @@ async def async_setup_entry(
@callback
def _setup_entities(
devices: list[VeSyncBaseDevice],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
coordinator: VeSyncDataCoordinator,
):
"""Check if device is online and add entity."""
diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json
index 3eb2a0c3fd5..b74ebc4f00e 100644
--- a/homeassistant/components/vesync/strings.json
+++ b/homeassistant/components/vesync/strings.json
@@ -2,7 +2,15 @@
"config": {
"step": {
"user": {
- "title": "Enter Username and Password",
+ "title": "Enter username and password",
+ "data": {
+ "username": "[%key:common::config_flow::data::email%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ }
+ },
+ "reauth_confirm": {
+ "title": "[%key:common::config_flow::title::reauth%]",
+ "description": "The VeSync integration needs to re-authenticate your account",
"data": {
"username": "[%key:common::config_flow::data::email%]",
"password": "[%key:common::config_flow::data::password%]"
@@ -13,7 +21,8 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"abort": {
- "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
+ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
+ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"entity": {
@@ -56,12 +65,28 @@
"name": "Mist level"
}
},
+ "switch": {
+ "display": {
+ "name": "Display"
+ }
+ },
+ "select": {
+ "night_light_level": {
+ "name": "Night light level",
+ "state": {
+ "bright": "Bright",
+ "dim": "Dim",
+ "off": "[%key:common::state::off%]",
+ "on": "[%key:common::state::on%]"
+ }
+ }
+ },
"fan": {
"vesync": {
"state_attributes": {
"preset_mode": {
"state": {
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
"sleep": "Sleep",
"advanced_sleep": "Advanced sleep",
"pet": "Pet",
diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py
index 3d2dc8a8e96..06fbd3606bd 100644
--- a/homeassistant/components/vesync/switch.py
+++ b/homeassistant/components/vesync/switch.py
@@ -14,10 +14,11 @@ from homeassistant.components.switch import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .common import is_outlet, is_wall_switch
+from .common import is_outlet, is_wall_switch, rgetattr
from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY
from .coordinator import VeSyncDataCoordinator
from .entity import VeSyncBaseEntity
@@ -45,13 +46,21 @@ SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = (
on_fn=lambda device: device.turn_on(),
off_fn=lambda device: device.turn_off(),
),
+ VeSyncSwitchEntityDescription(
+ key="display",
+ is_on=lambda device: device.display_state,
+ exists_fn=lambda device: rgetattr(device, "display_state") is not None,
+ translation_key="display",
+ on_fn=lambda device: device.turn_on_display(),
+ off_fn=lambda device: device.turn_off_display(),
+ ),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up switch platform."""
@@ -111,10 +120,14 @@ class VeSyncSwitchEntity(SwitchEntity, VeSyncBaseEntity):
def turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
- if self.entity_description.off_fn(self.device):
- self.schedule_update_ha_state()
+ if not self.entity_description.off_fn(self.device):
+ raise HomeAssistantError("An error occurred while turning off.")
+
+ self.schedule_update_ha_state()
def turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
- if self.entity_description.on_fn(self.device):
- self.schedule_update_ha_state()
+ if not self.entity_description.on_fn(self.device):
+ raise HomeAssistantError("An error occurred while turning on.")
+
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py
index 9d216404156..a032b1fbbcb 100644
--- a/homeassistant/components/vicare/binary_sensor.py
+++ b/homeassistant/components/vicare/binary_sensor.py
@@ -25,7 +25,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ViCareEntity
from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixin
@@ -112,6 +112,11 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = (
device_class=BinarySensorDeviceClass.RUNNING,
value_getter=lambda api: api.getOneTimeCharge(),
),
+ ViCareBinarySensorEntityDescription(
+ key="device_error",
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ value_getter=lambda api: len(api.getDeviceErrors()) > 0,
+ ),
)
@@ -157,7 +162,7 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ViCareConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the ViCare binary sensor devices."""
async_add_entities(
diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py
index 65182990bfb..9c30a9e68ee 100644
--- a/homeassistant/components/vicare/button.py
+++ b/homeassistant/components/vicare/button.py
@@ -18,7 +18,7 @@ import requests
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ViCareEntity
from .types import ViCareConfigEntry, ViCareDevice, ViCareRequiredKeysMixinWithSet
@@ -66,7 +66,7 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ViCareConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the ViCare button entities."""
async_add_entities(
diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py
index f62fdc363a6..9fba83c5700 100644
--- a/homeassistant/components/vicare/climate.py
+++ b/homeassistant/components/vicare/climate.py
@@ -33,7 +33,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import ViCareEntity
@@ -98,7 +98,7 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ViCareConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ViCare climate platform."""
diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py
index 11955a94b94..7b73d2e5ba3 100644
--- a/homeassistant/components/vicare/entity.py
+++ b/homeassistant/components/vicare/entity.py
@@ -28,6 +28,7 @@ class ViCareEntity(Entity):
"""Initialize the entity."""
gateway_serial = device_config.getConfig().serial
device_id = device_config.getId()
+ model = device_config.getModel().replace("_", " ")
identifier = (
f"{gateway_serial}_{device_serial.replace('zigbee-', 'zigbee_')}"
@@ -45,8 +46,8 @@ class ViCareEntity(Entity):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, identifier)},
serial_number=device_serial,
- name=device_config.getModel(),
+ name=model,
manufacturer="Viessmann",
- model=device_config.getModel(),
+ model=model,
configuration_url="https://developer.viessmann.com/",
)
diff --git a/homeassistant/components/vicare/fan.py b/homeassistant/components/vicare/fan.py
index c5e24f46c33..88d42503a03 100644
--- a/homeassistant/components/vicare/fan.py
+++ b/homeassistant/components/vicare/fan.py
@@ -18,7 +18,7 @@ from requests.exceptions import ConnectionError as RequestConnectionError
from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
@@ -111,7 +111,7 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ViCareConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ViCare fan platform."""
async_add_entities(
@@ -127,6 +127,7 @@ class ViCareFan(ViCareEntity, FanEntity):
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
_attr_translation_key = "ventilation"
+ _attributes: dict[str, Any] = {}
def __init__(
self,
@@ -155,7 +156,7 @@ class ViCareFan(ViCareEntity, FanEntity):
self._attr_supported_features |= FanEntityFeature.SET_SPEED
# evaluate quickmodes
- quickmodes: list[str] = (
+ self._attributes["vicare_quickmodes"] = quickmodes = list[str](
device.getVentilationQuickmodes()
if is_supported(
"getVentilationQuickmodes",
@@ -196,20 +197,23 @@ class ViCareFan(ViCareEntity, FanEntity):
@property
def is_on(self) -> bool | None:
"""Return true if the entity is on."""
- if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
+ if VentilationQuickmode.STANDBY in self._attributes[
+ "vicare_quickmodes"
+ ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
return False
return self.percentage is not None and self.percentage > 0
def turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
-
self._api.activateVentilationQuickmode(str(VentilationQuickmode.STANDBY))
@property
def icon(self) -> str | None:
"""Return the icon to use in the frontend."""
- if self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
+ if VentilationQuickmode.STANDBY in self._attributes[
+ "vicare_quickmodes"
+ ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
return "mdi:fan-off"
if hasattr(self, "_attr_preset_mode"):
if self._attr_preset_mode == VentilationMode.VENTILATION:
@@ -236,7 +240,9 @@ class ViCareFan(ViCareEntity, FanEntity):
"""Set the speed of the fan, as a percentage."""
if self._attr_preset_mode != str(VentilationMode.PERMANENT):
self.set_preset_mode(VentilationMode.PERMANENT)
- elif self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
+ elif VentilationQuickmode.STANDBY in self._attributes[
+ "vicare_quickmodes"
+ ] and self._api.getVentilationQuickmode(VentilationQuickmode.STANDBY):
self._api.deactivateVentilationQuickmode(str(VentilationQuickmode.STANDBY))
level = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage)
@@ -248,3 +254,8 @@ class ViCareFan(ViCareEntity, FanEntity):
target_mode = VentilationMode.to_vicare_mode(preset_mode)
_LOGGER.debug("changing ventilation mode to %s", target_mode)
self._api.activateVentilationMode(target_mode)
+
+ @property
+ def extra_state_attributes(self) -> dict[str, Any]:
+ """Show Device Attributes."""
+ return self._attributes
diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json
index 489d4accb8a..fed777e6435 100644
--- a/homeassistant/components/vicare/manifest.json
+++ b/homeassistant/components/vicare/manifest.json
@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/vicare",
"iot_class": "cloud_polling",
"loggers": ["PyViCare"],
- "requirements": ["PyViCare==2.42.0"]
+ "requirements": ["PyViCare==2.44.0"]
}
diff --git a/homeassistant/components/vicare/number.py b/homeassistant/components/vicare/number.py
index 534c0752cc1..04c4088bd3e 100644
--- a/homeassistant/components/vicare/number.py
+++ b/homeassistant/components/vicare/number.py
@@ -27,7 +27,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import EntityCategory, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ViCareEntity
from .types import (
@@ -374,7 +374,7 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ViCareConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the ViCare number devices."""
async_add_entities(
diff --git a/homeassistant/components/vicare/quality_scale.yaml b/homeassistant/components/vicare/quality_scale.yaml
index 55b7590a092..81b03364142 100644
--- a/homeassistant/components/vicare/quality_scale.yaml
+++ b/homeassistant/components/vicare/quality_scale.yaml
@@ -1,43 +1,70 @@
rules:
# Bronze
- config-flow: done
- test-before-configure: done
- unique-config-entry:
- status: todo
- comment: Uniqueness is not checked yet.
- config-flow-test-coverage: done
- runtime-data: done
- test-before-setup: done
- appropriate-polling: done
- entity-unique-id: done
- has-entity-name: done
- entity-event-setup:
- status: exempt
- comment: Entities of this integration does not explicitly subscribe to events.
- dependency-transparency: done
action-setup:
status: todo
comment: service registered in climate async_setup_entry.
+ appropriate-polling: done
+ brands: done
common-modules:
status: done
comment: No coordinator is used, data update is centrally handled by the library.
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
- docs-actions: done
- brands: done
+ entity-event-setup:
+ status: exempt
+ comment: Entities of this integration does 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:
+ status: todo
+ comment: Uniqueness is not checked yet.
+
# Silver
- integration-owner: done
- reauthentication-flow: done
+ action-exceptions: todo
config-entry-unloading: done
+ docs-configuration-parameters: todo
+ docs-installation-parameters: todo
+ entity-unavailable: todo
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage: todo
+
# Gold
devices: done
diagnostics: done
- entity-category: done
+ discovery-update-info: todo
+ discovery: todo
+ 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: done
+ entity-category: done
entity-device-class: done
- entity-translations: done
entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations: todo
+ reconfiguration-flow: todo
repair-issues:
status: exempt
comment: This integration does not raise any repairable issues.
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: todo
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py
index c99e7857d9b..cddc5ca021a 100644
--- a/homeassistant/components/vicare/sensor.py
+++ b/homeassistant/components/vicare/sensor.py
@@ -29,6 +29,7 @@ from homeassistant.const import (
PERCENTAGE,
EntityCategory,
UnitOfEnergy,
+ UnitOfMass,
UnitOfPower,
UnitOfPressure,
UnitOfTemperature,
@@ -37,7 +38,7 @@ from homeassistant.const import (
UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
VICARE_CUBIC_METER,
@@ -635,6 +636,38 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
+ ViCareSensorEntityDescription(
+ key="buffer_mid_top_temperature",
+ translation_key="buffer_mid_top_temperature",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ value_getter=lambda api: api.getBufferMidTopTemperature(),
+ ),
+ ViCareSensorEntityDescription(
+ key="buffer_middle_temperature",
+ translation_key="buffer_middle_temperature",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ value_getter=lambda api: api.getBufferMiddleTemperature(),
+ ),
+ ViCareSensorEntityDescription(
+ key="buffer_mid_bottom_temperature",
+ translation_key="buffer_mid_bottom_temperature",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ value_getter=lambda api: api.getBufferMidBottomTemperature(),
+ ),
+ ViCareSensorEntityDescription(
+ key="buffer_bottom_temperature",
+ translation_key="buffer_bottom_temperature",
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ value_getter=lambda api: api.getBufferBottomTemperature(),
+ ),
ViCareSensorEntityDescription(
key="buffer main temperature",
translation_key="buffer_main_temperature",
@@ -883,6 +916,23 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
value_getter=lambda api: api.getSeasonalPerformanceFactorHeating(),
),
+ ViCareSensorEntityDescription(
+ key="battery_level",
+ native_unit_of_measurement=PERCENTAGE,
+ state_class=SensorStateClass.MEASUREMENT,
+ device_class=SensorDeviceClass.BATTERY,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ value_getter=lambda api: api.getBatteryLevel(),
+ ),
+ ViCareSensorEntityDescription(
+ key="fuel_need",
+ translation_key="fuel_need",
+ state_class=SensorStateClass.MEASUREMENT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ native_unit_of_measurement=UnitOfMass.KILOGRAMS,
+ value_getter=lambda api: api.getFuelNeed(),
+ unit_getter=lambda api: api.getFuelUnit(),
+ ),
)
CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
@@ -1033,7 +1083,7 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ViCareConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create the ViCare sensor devices."""
async_add_entities(
diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json
index 50eeaf038e0..dd8d93e609a 100644
--- a/homeassistant/components/vicare/strings.json
+++ b/homeassistant/components/vicare/strings.json
@@ -1,6 +1,6 @@
{
"config": {
- "flow_title": "{name} ({host})",
+ "flow_title": "{name}",
"step": {
"user": {
"description": "Set up ViCare integration. To generate client ID go to https://app.developer.viessmann.com",
@@ -11,8 +11,8 @@
"heating_type": "Heating type"
},
"data_description": {
- "username": "The email address to login to your ViCare account.",
- "password": "The password to login to your ViCare account.",
+ "username": "The email address to log in to your ViCare account.",
+ "password": "The password to log in to your ViCare account.",
"client_id": "The ID of the API client created in the Viessmann developer portal.",
"heating_type": "Allows to overrule the device auto detection."
}
@@ -338,6 +338,18 @@
"buffer_top_temperature": {
"name": "Buffer top temperature"
},
+ "buffer_mid_top_temperature": {
+ "name": "Buffer mid top temperature"
+ },
+ "buffer_middle_temperature": {
+ "name": "Buffer middle temperature"
+ },
+ "buffer_mid_bottom_temperature": {
+ "name": "Buffer mid bottom temperature"
+ },
+ "buffer_bottom_temperature": {
+ "name": "Buffer bottom temperature"
+ },
"buffer_main_temperature": {
"name": "Buffer main temperature"
},
@@ -350,9 +362,9 @@
"ess_state": {
"name": "Battery state",
"state": {
- "charge": "Charging",
- "discharge": "Discharging",
- "standby": "Standby"
+ "charge": "[%key:common::state::charging%]",
+ "discharge": "[%key:common::state::discharging%]",
+ "standby": "[%key:common::state::standby%]"
}
},
"ess_discharge_today": {
@@ -400,7 +412,7 @@
"photovoltaic_status": {
"name": "PV state",
"state": {
- "ready": "Standby",
+ "ready": "[%key:common::state::standby%]",
"production": "Producing"
}
},
@@ -478,6 +490,9 @@
},
"spf_heating": {
"name": "Seasonal performance factor - heating"
+ },
+ "fuel_need": {
+ "name": "Fuel need"
}
},
"water_heater": {
@@ -500,11 +515,11 @@
"services": {
"set_vicare_mode": {
"name": "Set ViCare mode",
- "description": "Set a ViCare mode.",
+ "description": "Sets the mode of the climate device as defined by Viessmann.",
"fields": {
"vicare_mode": {
"name": "ViCare mode",
- "description": "ViCare mode."
+ "description": "For supported values, see the `vicare_modes` attribute of the climate entity."
}
}
}
diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py
index 114ff620c3f..f92c9e3e1af 100644
--- a/homeassistant/components/vicare/water_heater.py
+++ b/homeassistant/components/vicare/water_heater.py
@@ -22,7 +22,7 @@ from homeassistant.components.water_heater import (
)
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ViCareEntity
from .types import ViCareConfigEntry, ViCareDevice
@@ -80,7 +80,7 @@ def _build_entities(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ViCareConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ViCare water heater platform."""
async_add_entities(
diff --git a/homeassistant/components/vilfo/config_flow.py b/homeassistant/components/vilfo/config_flow.py
index cdba7f1b8c2..5612591c595 100644
--- a/homeassistant/components/vilfo/config_flow.py
+++ b/homeassistant/components/vilfo/config_flow.py
@@ -114,8 +114,8 @@ class DomainConfigFlow(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
- except Exception as err: # noqa: BLE001
- _LOGGER.error("Unexpected exception: %s", err)
+ except Exception:
+ _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(info[CONF_ID])
diff --git a/homeassistant/components/vilfo/sensor.py b/homeassistant/components/vilfo/sensor.py
index 77a7df7a0a8..fa2d5cae196 100644
--- a/homeassistant/components/vilfo/sensor.py
+++ b/homeassistant/components/vilfo/sensor.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_API_DATA_FIELD_BOOT_TIME,
@@ -51,7 +51,7 @@ SENSOR_TYPES: tuple[VilfoSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add Vilfo Router entities from a config_entry."""
vilfo = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py
index 27a7fa2cd97..10a71695e05 100644
--- a/homeassistant/components/vizio/__init__.py
+++ b/homeassistant/components/vizio/__init__.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.media_player import MediaPlayerDeviceClass
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE_CLASS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.storage import Store
@@ -39,12 +39,9 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
- # Exclude this config entry because its not unloaded yet
if not any(
- entry.state is ConfigEntryState.LOADED
- and entry.entry_id != config_entry.entry_id
- and entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
- for entry in hass.config_entries.async_entries(DOMAIN)
+ entry.data[CONF_DEVICE_CLASS] == MediaPlayerDeviceClass.TV
+ for entry in hass.config_entries.async_loaded_entries(DOMAIN)
):
hass.data[DOMAIN].pop(CONF_APPS, None)
diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py
index 5711d8fbac9..d44db5e45ee 100644
--- a/homeassistant/components/vizio/media_player.py
+++ b/homeassistant/components/vizio/media_player.py
@@ -32,7 +32,7 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_ADDITIONAL_CONFIGS,
@@ -63,7 +63,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Vizio media player entry."""
host = config_entry.data[CONF_HOST]
diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py
index 9597c706570..6ae9fbb9f5a 100644
--- a/homeassistant/components/vlc_telnet/media_player.py
+++ b/homeassistant/components/vlc_telnet/media_player.py
@@ -22,7 +22,7 @@ from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import VlcConfigEntry
@@ -39,7 +39,9 @@ def _get_str(data: dict, key: str) -> str | None:
async def async_setup_entry(
- hass: HomeAssistant, entry: VlcConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: VlcConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the vlc platform."""
# CONF_NAME is only present in imported YAML.
diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py
index 871afe09a2e..9f118fe4fbd 100644
--- a/homeassistant/components/vodafone_station/__init__.py
+++ b/homeassistant/components/vodafone_station/__init__.py
@@ -1,16 +1,15 @@
"""Vodafone Station integration."""
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
-from .coordinator import VodafoneStationRouter
+from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR]
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_setup_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool:
"""Set up Vodafone Station platform."""
coordinator = VodafoneStationRouter(
hass,
@@ -22,7 +21,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
- hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
+ entry.runtime_data = coordinator
entry.async_on_unload(entry.add_update_listener(update_listener))
@@ -31,10 +30,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
-async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+async def async_unload_entry(hass: HomeAssistant, entry: VodafoneConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
- coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
await coordinator.api.logout()
await coordinator.api.close()
hass.data[DOMAIN].pop(entry.entry_id)
@@ -42,7 +41,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
-async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
+async def update_listener(hass: HomeAssistant, entry: VodafoneConfigEntry) -> None:
"""Update when config_entry options update."""
if entry.options:
await hass.config_entries.async_reload(entry.entry_id)
diff --git a/homeassistant/components/vodafone_station/button.py b/homeassistant/components/vodafone_station/button.py
index efea011a541..8dda4d49c7b 100644
--- a/homeassistant/components/vodafone_station/button.py
+++ b/homeassistant/components/vodafone_station/button.py
@@ -4,21 +4,32 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
+from json.decoder import JSONDecodeError
from typing import Any, Final
+from aiovodafone.exceptions import (
+ AlreadyLogged,
+ CannotAuthenticate,
+ CannotConnect,
+ GenericLoginError,
+)
+
from homeassistant.components.button import (
ButtonDeviceClass,
ButtonEntity,
ButtonEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import _LOGGER, DOMAIN
-from .coordinator import VodafoneStationRouter
+from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
@@ -67,12 +78,14 @@ BUTTON_TYPES: Final = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: VodafoneConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
_LOGGER.debug("Setting up Vodafone Station buttons")
- coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
sensors_data = coordinator.data.sensors
@@ -104,4 +117,25 @@ class VodafoneStationSensorEntity(
async def async_press(self) -> None:
"""Triggers the Shelly button press service."""
- await self.entity_description.press_action(self.coordinator)
+
+ try:
+ await self.entity_description.press_action(self.coordinator)
+ except CannotAuthenticate as err:
+ self.coordinator.config_entry.async_start_reauth(self.hass)
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="cannot_authenticate",
+ translation_placeholders={"error": repr(err)},
+ ) from err
+ except (
+ CannotConnect,
+ AlreadyLogged,
+ GenericLoginError,
+ JSONDecodeError,
+ ) as err:
+ self.coordinator.last_update_success = False
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="cannot_execute_action",
+ translation_placeholders={"error": repr(err)},
+ ) from err
diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py
index 7a80244f8d6..c21796d4064 100644
--- a/homeassistant/components/vodafone_station/config_flow.py
+++ b/homeassistant/components/vodafone_station/config_flow.py
@@ -12,16 +12,12 @@ from homeassistant.components.device_tracker import (
CONF_CONSIDER_HOME,
DEFAULT_CONSIDER_HOME,
)
-from homeassistant.config_entries import (
- ConfigEntry,
- ConfigFlow,
- ConfigFlowResult,
- OptionsFlow,
-)
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult, OptionsFlow
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from .const import _LOGGER, DEFAULT_HOST, DEFAULT_USERNAME, DOMAIN
+from .coordinator import VodafoneConfigEntry
def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema:
@@ -63,7 +59,7 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
@callback
def async_get_options_flow(
- config_entry: ConfigEntry,
+ config_entry: VodafoneConfigEntry,
) -> VodafoneStationOptionsFlowHandler:
"""Get the options flow for this handler."""
return VodafoneStationOptionsFlowHandler()
@@ -143,6 +139,45 @@ class VodafoneStationConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
+ async def async_step_reconfigure(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Handle reconfiguration of the device."""
+ reconfigure_entry = self._get_reconfigure_entry()
+ if not user_input:
+ return self.async_show_form(
+ step_id="reconfigure", data_schema=user_form_schema(user_input)
+ )
+
+ updated_host = user_input[CONF_HOST]
+
+ if reconfigure_entry.data[CONF_HOST] != updated_host:
+ self._async_abort_entries_match({CONF_HOST: updated_host})
+
+ errors: dict[str, str] = {}
+
+ try:
+ await validate_input(self.hass, user_input)
+ except aiovodafone_exceptions.AlreadyLogged:
+ errors["base"] = "already_logged"
+ except aiovodafone_exceptions.CannotConnect:
+ errors["base"] = "cannot_connect"
+ except aiovodafone_exceptions.CannotAuthenticate:
+ errors["base"] = "invalid_auth"
+ except Exception: # noqa: BLE001
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_update_reload_and_abort(
+ reconfigure_entry, data_updates={CONF_HOST: updated_host}
+ )
+
+ return self.async_show_form(
+ step_id="reconfigure",
+ data_schema=user_form_schema(user_input),
+ errors=errors,
+ )
+
class VodafoneStationOptionsFlowHandler(OptionsFlow):
"""Handle a option flow."""
diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py
index cd640d10cb6..cee66bd2e7c 100644
--- a/homeassistant/components/vodafone_station/coordinator.py
+++ b/homeassistant/components/vodafone_station/coordinator.py
@@ -3,7 +3,7 @@
from dataclasses import dataclass
from datetime import datetime, timedelta
from json.decoder import JSONDecodeError
-from typing import Any
+from typing import Any, cast
from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions
@@ -21,6 +21,8 @@ from .helpers import cleanup_device_tracker
CONSIDER_HOME_SECONDS = DEFAULT_CONSIDER_HOME.total_seconds()
+type VodafoneConfigEntry = ConfigEntry[VodafoneStationRouter]
+
@dataclass(slots=True)
class VodafoneStationDeviceInfo:
@@ -42,7 +44,7 @@ class UpdateCoordinatorDataType:
class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
"""Queries router running Vodafone Station firmware."""
- config_entry: ConfigEntry
+ config_entry: VodafoneConfigEntry
def __init__(
self,
@@ -50,7 +52,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
host: str,
username: str,
password: str,
- config_entry: ConfigEntry,
+ config_entry: VodafoneConfigEntry,
) -> None:
"""Initialize the scanner."""
@@ -120,14 +122,22 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
data_sensors = await self.api.get_sensor_data()
await self.api.logout()
except exceptions.CannotAuthenticate as err:
- raise ConfigEntryAuthFailed from err
+ raise ConfigEntryAuthFailed(
+ translation_domain=DOMAIN,
+ translation_key="cannot_authenticate",
+ translation_placeholders={"error": repr(err)},
+ ) from err
except (
exceptions.CannotConnect,
exceptions.AlreadyLogged,
exceptions.GenericLoginError,
JSONDecodeError,
) as err:
- raise UpdateFailed(f"Error fetching data: {err!r}") from err
+ raise UpdateFailed(
+ translation_domain=DOMAIN,
+ translation_key="update_failed",
+ translation_placeholders={"error": repr(err)},
+ ) from err
except (ConfigEntryAuthFailed, UpdateFailed):
await self.api.close()
raise
@@ -164,7 +174,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]):
@property
def serial_number(self) -> str:
"""Device serial number."""
- return self.data.sensors["sys_serial_number"]
+ return cast(str, self.data.sensors["sys_serial_number"])
@property
def device_info(self) -> DeviceInfo:
diff --git a/homeassistant/components/vodafone_station/device_tracker.py b/homeassistant/components/vodafone_station/device_tracker.py
index 4af0b85e003..4efa26cda8c 100644
--- a/homeassistant/components/vodafone_station/device_tracker.py
+++ b/homeassistant/components/vodafone_station/device_tracker.py
@@ -3,23 +3,31 @@
from __future__ import annotations
from homeassistant.components.device_tracker import ScannerEntity
-from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import _LOGGER, DOMAIN
-from .coordinator import VodafoneStationDeviceInfo, VodafoneStationRouter
+from .const import _LOGGER
+from .coordinator import (
+ VodafoneConfigEntry,
+ VodafoneStationDeviceInfo,
+ VodafoneStationRouter,
+)
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: VodafoneConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker for Vodafone Station component."""
_LOGGER.debug("Start device trackers setup")
- coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
tracked: set = set()
@@ -40,7 +48,7 @@ async def async_setup_entry(
@callback
def async_add_new_tracked_entities(
coordinator: VodafoneStationRouter,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
tracked: set[str],
) -> None:
"""Add new tracker entities from the router."""
diff --git a/homeassistant/components/vodafone_station/diagnostics.py b/homeassistant/components/vodafone_station/diagnostics.py
index e306d6caca2..4778e7d5a4e 100644
--- a/homeassistant/components/vodafone_station/diagnostics.py
+++ b/homeassistant/components/vodafone_station/diagnostics.py
@@ -5,22 +5,20 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
-from .const import DOMAIN
-from .coordinator import VodafoneStationRouter
+from .coordinator import VodafoneConfigEntry
TO_REDACT = {CONF_USERNAME, CONF_PASSWORD}
async def async_get_config_entry_diagnostics(
- hass: HomeAssistant, entry: ConfigEntry
+ hass: HomeAssistant, entry: VodafoneConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
sensors_data = coordinator.data.sensors
return {
diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json
index 4acafc8df3a..a36af1466d6 100644
--- a/homeassistant/components/vodafone_station/manifest.json
+++ b/homeassistant/components/vodafone_station/manifest.json
@@ -7,5 +7,6 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aiovodafone"],
+ "quality_scale": "platinum",
"requirements": ["aiovodafone==0.6.1"]
}
diff --git a/homeassistant/components/vodafone_station/quality_scale.yaml b/homeassistant/components/vodafone_station/quality_scale.yaml
new file mode 100644
index 00000000000..d60020f5e47
--- /dev/null
+++ b/homeassistant/components/vodafone_station/quality_scale.yaml
@@ -0,0 +1,76 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: no 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: no actions
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: no 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: done
+ 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: done
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: device not discoverable
+ discovery:
+ status: exempt
+ comment: device not discoverable
+ docs-data-update: done
+ docs-examples: done
+ docs-known-limitations:
+ status: exempt
+ comment: no known limitations, yet
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: done
+ docs-use-cases: done
+ dynamic-devices: done
+ entity-category: done
+ entity-device-class: done
+ entity-disabled-by-default:
+ status: exempt
+ comment: no known use case
+ entity-translations: done
+ exception-translations: done
+ icon-translations: done
+ reconfiguration-flow: done
+ repair-issues:
+ status: exempt
+ comment: no known use cases for repair issues or flows, yet
+ stale-devices: done
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: done
diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py
index 307fcaf0ea8..2573864330d 100644
--- a/homeassistant/components/vodafone_station/sensor.py
+++ b/homeassistant/components/vodafone_station/sensor.py
@@ -12,14 +12,16 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
-from .const import _LOGGER, DOMAIN, LINE_TYPES
-from .coordinator import VodafoneStationRouter
+from .const import _LOGGER, LINE_TYPES
+from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
+
+# Coordinator is used to centralize the data updates
+PARALLEL_UPDATES = 0
NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"]
UPTIME_DEVIATION = 60
@@ -165,12 +167,14 @@ SENSOR_TYPES: Final = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: VodafoneConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
_LOGGER.debug("Setting up Vodafone Station sensors")
- coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id]
+ coordinator = entry.runtime_data
sensors_data = coordinator.data.sensors
diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json
index 8910d7178b7..958b774a485 100644
--- a/homeassistant/components/vodafone_station/strings.json
+++ b/homeassistant/components/vodafone_station/strings.json
@@ -3,9 +3,11 @@
"flow_title": "{host}",
"step": {
"reauth_confirm": {
- "description": "Please enter the correct password for host: {host}",
"data": {
"password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "password": "[%key:component::vodafone_station::config::step::user::data_description::password%]"
}
},
"user": {
@@ -15,7 +17,21 @@
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
- "host": "The hostname or IP address of your Vodafone Station."
+ "host": "The hostname or IP address of your Vodafone Station.",
+ "username": "The username for your Vodafone Station.",
+ "password": "The password for your Vodafone Station."
+ }
+ },
+ "reconfigure": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]",
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ },
+ "data_description": {
+ "host": "[%key:component::vodafone_station::config::step::user::data_description::host%]",
+ "username": "[%key:component::vodafone_station::config::step::user::data_description::username%]",
+ "password": "[%key:component::vodafone_station::config::step::user::data_description::password%]"
}
}
},
@@ -23,16 +39,17 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"already_logged": "User already logged-in, please try again later.",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
+ "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"model_not_supported": "The device model is currently unsupported.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
- "already_logged": "User already logged-in, please try again later.",
+ "already_logged": "[%key:component::vodafone_station::config::abort::already_logged%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
- "model_not_supported": "The device model is currently unsupported.",
+ "model_not_supported": "[%key:component::vodafone_station::config::abort::model_not_supported%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}
},
@@ -41,20 +58,35 @@
"init": {
"data": {
"consider_home": "Seconds to consider a device at 'home'"
+ },
+ "data_description": {
+ "consider_home": "The number of seconds to wait until marking a device as not home after it disconnects from the network."
}
}
}
},
"entity": {
"button": {
- "dsl_reconnect": { "name": "DSL reconnect" },
- "fiber_reconnect": { "name": "Fiber reconnect" },
- "internet_key_reconnect": { "name": "Internet key reconnect" }
+ "dsl_reconnect": {
+ "name": "DSL reconnect"
+ },
+ "fiber_reconnect": {
+ "name": "Fiber reconnect"
+ },
+ "internet_key_reconnect": {
+ "name": "Internet key reconnect"
+ }
},
"sensor": {
- "external_ipv4": { "name": "WAN IPv4 address" },
- "external_ipv6": { "name": "WAN IPv6 address" },
- "external_ip_key": { "name": "WAN internet key address" },
+ "external_ipv4": {
+ "name": "WAN IPv4 address"
+ },
+ "external_ipv6": {
+ "name": "WAN IPv6 address"
+ },
+ "external_ip_key": {
+ "name": "WAN internet key address"
+ },
"active_connection": {
"name": "Active connection",
"state": {
@@ -64,15 +96,44 @@
"internet_key": "Internet key"
}
},
- "down_stream": { "name": "WAN download rate" },
- "up_stream": { "name": "WAN upload rate" },
- "fw_version": { "name": "Firmware version" },
- "phone_num1": { "name": "Phone number (1)" },
- "phone_num2": { "name": "Phone number (2)" },
- "sys_uptime": { "name": "Uptime" },
- "sys_cpu_usage": { "name": "CPU usage" },
- "sys_memory_usage": { "name": "Memory usage" },
- "sys_reboot_cause": { "name": "Reboot cause" }
+ "down_stream": {
+ "name": "WAN download rate"
+ },
+ "up_stream": {
+ "name": "WAN upload rate"
+ },
+ "fw_version": {
+ "name": "Firmware version"
+ },
+ "phone_num1": {
+ "name": "Phone number (1)"
+ },
+ "phone_num2": {
+ "name": "Phone number (2)"
+ },
+ "sys_uptime": {
+ "name": "Uptime"
+ },
+ "sys_cpu_usage": {
+ "name": "CPU usage"
+ },
+ "sys_memory_usage": {
+ "name": "Memory usage"
+ },
+ "sys_reboot_cause": {
+ "name": "Reboot cause"
+ }
+ }
+ },
+ "exceptions": {
+ "update_failed": {
+ "message": "Error fetching data: {error}"
+ },
+ "cannot_execute_action": {
+ "message": "Cannot execute requested action: {error}"
+ },
+ "cannot_authenticate": {
+ "message": "Error authenticating: {error}"
}
}
}
diff --git a/homeassistant/components/voip/assist_satellite.py b/homeassistant/components/voip/assist_satellite.py
index 1877b8c655c..2c0a3b9641a 100644
--- a/homeassistant/components/voip/assist_satellite.py
+++ b/homeassistant/components/voip/assist_satellite.py
@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
+from datetime import timedelta
from enum import IntFlag
from functools import partial
import io
@@ -16,7 +17,7 @@ import wave
from voip_utils import SIP_PORT, RtpDatagramProtocol
from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint
-from homeassistant.components import tts
+from homeassistant.components import intent, tts
from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType
from homeassistant.components.assist_satellite import (
AssistSatelliteAnnouncement,
@@ -25,12 +26,22 @@ from homeassistant.components.assist_satellite import (
AssistSatelliteEntityDescription,
AssistSatelliteEntityFeature,
)
+from homeassistant.components.intent import TimerEventType, TimerInfo
from homeassistant.components.network import async_get_source_ip
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Context, HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import CHANNELS, CONF_SIP_PORT, DOMAIN, RATE, RTP_AUDIO_SETTINGS, WIDTH
+from .const import (
+ CHANNELS,
+ CONF_SIP_PORT,
+ CONF_SIP_USER,
+ DOMAIN,
+ RATE,
+ RTP_AUDIO_SETTINGS,
+ WIDTH,
+)
from .devices import VoIPDevice
from .entity import VoIPEntity
@@ -64,7 +75,7 @@ _TONE_FILENAMES: dict[Tones, str] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up VoIP Assist satellite entity."""
domain_data: DomainData = hass.data[DOMAIN]
@@ -152,6 +163,13 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
await super().async_added_to_hass()
self.voip_device.protocol = self
+ assert self.device_entry is not None
+ self.async_on_remove(
+ intent.async_register_timer_handler(
+ self.hass, self.device_entry.id, self.async_handle_timer_event
+ )
+ )
+
async def async_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass."""
await super().async_will_remove_from_hass()
@@ -165,6 +183,29 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
"""Get the current satellite configuration."""
raise NotImplementedError
+ @callback
+ def async_handle_timer_event(
+ self,
+ event_type: TimerEventType,
+ timer_info: TimerInfo,
+ ) -> None:
+ """Handle timer event."""
+ if event_type != TimerEventType.FINISHED:
+ return
+
+ if timer_info.name:
+ message = f"{timer_info.name} finished"
+ else:
+ message = f"{timedelta(seconds=timer_info.created_seconds)} timer finished"
+
+ async def announce_message():
+ announcement = await self._resolve_announcement_media_id(message, None)
+ await self.async_announce(announcement)
+
+ self.config_entry.async_create_background_task(
+ self.hass, announce_message(), "voip_announce_timer"
+ )
+
async def async_set_configuration(
self, config: AssistSatelliteConfiguration
) -> None:
@@ -185,6 +226,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
Optionally run a voice pipeline after the announcement has finished.
"""
+ if announcement.media_id_source != "tts":
+ raise HomeAssistantError(
+ translation_domain=DOMAIN,
+ translation_key="non_tts_announcement",
+ )
+
self._announcement_future = asyncio.Future()
self._run_pipeline_after_announce = run_pipeline_after
@@ -199,7 +246,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
# HA SIP server
source_ip = await async_get_source_ip(self.hass)
sip_port = self.config_entry.options.get(CONF_SIP_PORT, SIP_PORT)
- source_endpoint = get_sip_endpoint(host=source_ip, port=sip_port)
+ sip_user = self.config_entry.options.get(CONF_SIP_USER)
+ source_endpoint = get_sip_endpoint(
+ host=source_ip, port=sip_port, username=sip_user
+ )
try:
# VoIP ID is SIP header
diff --git a/homeassistant/components/voip/binary_sensor.py b/homeassistant/components/voip/binary_sensor.py
index f38b228c46c..34dac4b6068 100644
--- a/homeassistant/components/voip/binary_sensor.py
+++ b/homeassistant/components/voip/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import issue_registry as ir
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .devices import VoIPDevice
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up VoIP binary sensor entities."""
domain_data: DomainData = hass.data[DOMAIN]
diff --git a/homeassistant/components/voip/config_flow.py b/homeassistant/components/voip/config_flow.py
index 63dcb8f86ee..7ae603f0f6a 100644
--- a/homeassistant/components/voip/config_flow.py
+++ b/homeassistant/components/voip/config_flow.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import (
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
-from .const import CONF_SIP_PORT, DOMAIN
+from .const import CONF_SIP_PORT, CONF_SIP_USER, DOMAIN
class VoIPConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -58,7 +58,15 @@ class VoipOptionsFlowHandler(OptionsFlow):
) -> ConfigFlowResult:
"""Manage the options."""
if user_input is not None:
- return self.async_create_entry(title="", data=user_input)
+ if CONF_SIP_USER in user_input and not user_input[CONF_SIP_USER]:
+ del user_input[CONF_SIP_USER]
+ self.hass.config_entries.async_update_entry(
+ self.config_entry, options=user_input
+ )
+ return self.async_create_entry(
+ title="",
+ data=user_input,
+ )
return self.async_show_form(
step_id="init",
@@ -70,7 +78,15 @@ class VoipOptionsFlowHandler(OptionsFlow):
CONF_SIP_PORT,
SIP_PORT,
),
- ): cv.port
+ ): cv.port,
+ vol.Optional(
+ CONF_SIP_USER,
+ description={
+ "suggested_value": self.config_entry.options.get(
+ CONF_SIP_USER, None
+ )
+ },
+ ): vol.Any(None, cv.string),
}
),
)
diff --git a/homeassistant/components/voip/const.py b/homeassistant/components/voip/const.py
index b4ee5d8ce7a..9a4403f9df2 100644
--- a/homeassistant/components/voip/const.py
+++ b/homeassistant/components/voip/const.py
@@ -13,3 +13,4 @@ RTP_AUDIO_SETTINGS = {
}
CONF_SIP_PORT = "sip_port"
+CONF_SIP_USER = "sip_user"
diff --git a/homeassistant/components/voip/manifest.json b/homeassistant/components/voip/manifest.json
index e3b2861dbe5..dfd397fde14 100644
--- a/homeassistant/components/voip/manifest.json
+++ b/homeassistant/components/voip/manifest.json
@@ -3,9 +3,10 @@
"name": "Voice over IP",
"codeowners": ["@balloob", "@synesthesiam"],
"config_flow": true,
- "dependencies": ["assist_pipeline", "assist_satellite", "network"],
+ "dependencies": ["assist_pipeline", "assist_satellite", "intent", "network"],
"documentation": "https://www.home-assistant.io/integrations/voip",
"iot_class": "local_push",
+ "loggers": ["voip_utils"],
"quality_scale": "internal",
"requirements": ["voip-utils==0.3.1"]
}
diff --git a/homeassistant/components/voip/select.py b/homeassistant/components/voip/select.py
index f145f866ae3..bfce112d0c5 100644
--- a/homeassistant/components/voip/select.py
+++ b/homeassistant/components/voip/select.py
@@ -10,7 +10,7 @@ from homeassistant.components.assist_pipeline.select import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .devices import VoIPDevice
@@ -23,7 +23,7 @@ if TYPE_CHECKING:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up VoIP switch entities."""
domain_data: DomainData = hass.data[DOMAIN]
diff --git a/homeassistant/components/voip/strings.json b/homeassistant/components/voip/strings.json
index c25c22f3f80..4f37ad1d6f7 100644
--- a/homeassistant/components/voip/strings.json
+++ b/homeassistant/components/voip/strings.json
@@ -53,9 +53,15 @@
"step": {
"init": {
"data": {
- "sip_port": "SIP port"
+ "sip_port": "SIP port",
+ "sip_user": "SIP user"
}
}
}
+ },
+ "exceptions": {
+ "non_tts_announcement": {
+ "message": "VoIP does not currently support non-TTS announcements"
+ }
}
}
diff --git a/homeassistant/components/voip/switch.py b/homeassistant/components/voip/switch.py
index f8484241fc5..7690b8f125c 100644
--- a/homeassistant/components/voip/switch.py
+++ b/homeassistant/components/voip/switch.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import restore_state
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .devices import VoIPDevice
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up VoIP switch entities."""
domain_data: DomainData = hass.data[DOMAIN]
diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py
index 5ba67d7974f..773a125d483 100644
--- a/homeassistant/components/volumio/media_player.py
+++ b/homeassistant/components/volumio/media_player.py
@@ -21,7 +21,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from .browse_media import browse_node, browse_top_level
@@ -33,7 +33,7 @@ PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=15)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Volumio media player platform."""
@@ -70,7 +70,6 @@ class Volumio(MediaPlayerEntity):
| MediaPlayerEntityFeature.CLEAR_PLAYLIST
| MediaPlayerEntityFeature.BROWSE_MEDIA
)
- _attr_source_list = []
def __init__(self, volumio, uid, name, info):
"""Initialize the media player."""
@@ -78,6 +77,7 @@ class Volumio(MediaPlayerEntity):
unique_id = uid
self._state = {}
self.thumbnail_cache = {}
+ self._attr_source_list = []
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
diff --git a/homeassistant/components/volvooncall/binary_sensor.py b/homeassistant/components/volvooncall/binary_sensor.py
index e6104f8d87c..2ba8d19e3db 100644
--- a/homeassistant/components/volvooncall/binary_sensor.py
+++ b/homeassistant/components/volvooncall/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, VOLVO_DISCOVERY_NEW
from .coordinator import VolvoUpdateCoordinator
@@ -24,7 +24,7 @@ from .entity import VolvoEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Configure binary_sensors from a config entry created in the integrations UI."""
coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py
index 96fe5a644bb..018acb02d49 100644
--- a/homeassistant/components/volvooncall/device_tracker.py
+++ b/homeassistant/components/volvooncall/device_tracker.py
@@ -8,7 +8,7 @@ from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, VOLVO_DISCOVERY_NEW
from .coordinator import VolvoUpdateCoordinator
@@ -18,7 +18,7 @@ from .entity import VolvoEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Configure device_trackers from a config entry created in the integrations UI."""
coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/volvooncall/entity.py b/homeassistant/components/volvooncall/entity.py
index 6ebc4bdc754..5a1194e8b1a 100644
--- a/homeassistant/components/volvooncall/entity.py
+++ b/homeassistant/components/volvooncall/entity.py
@@ -57,7 +57,7 @@ class VolvoEntity(CoordinatorEntity[VolvoUpdateCoordinator]):
return f"{self._vehicle_name} {self._entity_name}"
@property
- def assumed_state(self):
+ def assumed_state(self) -> bool:
"""Return true if unable to access real state of entity."""
return True
diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py
index cff5df35750..75b54e9dbbc 100644
--- a/homeassistant/components/volvooncall/lock.py
+++ b/homeassistant/components/volvooncall/lock.py
@@ -10,7 +10,7 @@ from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, VOLVO_DISCOVERY_NEW
from .coordinator import VolvoUpdateCoordinator
@@ -20,7 +20,7 @@ from .entity import VolvoEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Configure locks from a config entry created in the integrations UI."""
coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py
index 9916d37197b..feb7248ccaf 100644
--- a/homeassistant/components/volvooncall/sensor.py
+++ b/homeassistant/components/volvooncall/sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, VOLVO_DISCOVERY_NEW
from .coordinator import VolvoUpdateCoordinator
@@ -18,7 +18,7 @@ from .entity import VolvoEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Configure sensors from a config entry created in the integrations UI."""
coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/volvooncall/switch.py b/homeassistant/components/volvooncall/switch.py
index 7e60f47fb44..ff321577348 100644
--- a/homeassistant/components/volvooncall/switch.py
+++ b/homeassistant/components/volvooncall/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, VOLVO_DISCOVERY_NEW
from .coordinator import VolvoUpdateCoordinator
@@ -20,7 +20,7 @@ from .entity import VolvoEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Configure binary_sensors from a config entry created in the integrations UI."""
coordinator: VolvoUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py
index a89b6b4a116..c2ef8b70d46 100644
--- a/homeassistant/components/vulcan/calendar.py
+++ b/homeassistant/components/vulcan/calendar.py
@@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import generate_entity_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .fetch_data import get_lessons, get_student_info
@@ -31,7 +31,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the calendar platform for entity."""
client = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/wake_on_lan/button.py b/homeassistant/components/wake_on_lan/button.py
index 4d6b19bdd8e..e9cf69b1fe7 100644
--- a/homeassistant/components/wake_on_lan/button.py
+++ b/homeassistant/components/wake_on_lan/button.py
@@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_BROADCAST_ADDRESS, CONF_BROADCAST_PORT, CONF_MAC
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
_LOGGER = logging.getLogger(__name__)
@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Wake on LAN button entry."""
broadcast_address: str | None = entry.options.get(CONF_BROADCAST_ADDRESS)
diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py
index 4853a9104f2..ef35734ed7e 100644
--- a/homeassistant/components/wallbox/lock.py
+++ b/homeassistant/components/wallbox/lock.py
@@ -8,7 +8,7 @@ from homeassistant.components.lock import LockEntity, LockEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CHARGER_DATA_KEY,
@@ -28,7 +28,9 @@ LOCK_TYPES: dict[str, LockEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create wallbox lock entities in HASS."""
coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json
index 63102646508..d217a018303 100644
--- a/homeassistant/components/wallbox/manifest.json
+++ b/homeassistant/components/wallbox/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/wallbox",
"iot_class": "cloud_polling",
"loggers": ["wallbox"],
- "requirements": ["wallbox==0.7.0"]
+ "requirements": ["wallbox==0.8.0"]
}
diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py
index 24cdd16f99d..a5880f6e0f7 100644
--- a/homeassistant/components/wallbox/number.py
+++ b/homeassistant/components/wallbox/number.py
@@ -13,7 +13,7 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
BIDIRECTIONAL_MODEL_PREFIXES,
@@ -71,9 +71,7 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = {
CHARGER_MAX_ICP_CURRENT_KEY: WallboxNumberEntityDescription(
key=CHARGER_MAX_ICP_CURRENT_KEY,
translation_key="maximum_icp_current",
- max_value_fn=lambda coordinator: cast(
- float, coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY]
- ),
+ max_value_fn=lambda _: 255,
min_value_fn=lambda _: 6,
set_value_fn=lambda coordinator: coordinator.async_set_icp_current,
native_step=1,
@@ -82,7 +80,9 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create wallbox number entities in HASS."""
coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py
index 18d8afb5612..78b26520bec 100644
--- a/homeassistant/components/wallbox/sensor.py
+++ b/homeassistant/components/wallbox/sensor.py
@@ -21,7 +21,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
@@ -157,7 +157,9 @@ SENSOR_TYPES: dict[str, WallboxSensorEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create wallbox sensor entities in HASS."""
coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py
index 06c2674579d..30275951ab2 100644
--- a/homeassistant/components/wallbox/switch.py
+++ b/homeassistant/components/wallbox/switch.py
@@ -7,7 +7,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CHARGER_DATA_KEY,
@@ -29,7 +29,9 @@ SWITCH_TYPES: dict[str, SwitchEntityDescription] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Create wallbox sensor entities in HASS."""
coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py
index 4c921c68336..59daf60392e 100644
--- a/homeassistant/components/waqi/sensor.py
+++ b/homeassistant/components/waqi/sensor.py
@@ -19,7 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, UnitOfPressure, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -138,7 +138,9 @@ SENSORS: list[WAQISensorEntityDescription] = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the WAQI sensor."""
coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json
index a1feb217249..f455e3ead33 100644
--- a/homeassistant/components/waqi/strings.json
+++ b/homeassistant/components/waqi/strings.json
@@ -57,7 +57,7 @@
"name": "[%key:component::sensor::entity_component::pm25::name%]"
},
"neph": {
- "name": "Visbility using nephelometry"
+ "name": "Visibility using nephelometry"
},
"dominant_pollutant": {
"name": "Dominant pollutant",
diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py
index c9155950680..f2038def79c 100644
--- a/homeassistant/components/water_heater/__init__.py
+++ b/homeassistant/components/water_heater/__init__.py
@@ -77,6 +77,7 @@ ATTR_OPERATION_MODE = "operation_mode"
ATTR_OPERATION_LIST = "operation_list"
ATTR_TARGET_TEMP_HIGH = "target_temp_high"
ATTR_TARGET_TEMP_LOW = "target_temp_low"
+ATTR_TARGET_TEMP_STEP = "target_temp_step"
ATTR_CURRENT_TEMPERATURE = "current_temperature"
CONVERTIBLE_ATTRIBUTE = [ATTR_TEMPERATURE]
@@ -154,6 +155,7 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"target_temperature",
"target_temperature_high",
"target_temperature_low",
+ "target_temperature_step",
"is_away_mode_on",
}
@@ -162,7 +164,12 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Base class for water heater entities."""
_entity_component_unrecorded_attributes = frozenset(
- {ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP}
+ {
+ ATTR_OPERATION_LIST,
+ ATTR_MIN_TEMP,
+ ATTR_MAX_TEMP,
+ ATTR_TARGET_TEMP_STEP,
+ }
)
entity_description: WaterHeaterEntityDescription
@@ -179,6 +186,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
_attr_target_temperature_low: float | None = None
_attr_target_temperature: float | None = None
_attr_temperature_unit: str
+ _attr_target_temperature_step: float | None = None
@final
@property
@@ -206,6 +214,8 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
self.hass, self.max_temp, self.temperature_unit, self.precision
),
}
+ if target_temperature_step := self.target_temperature_step:
+ data[ATTR_TARGET_TEMP_STEP] = target_temperature_step
if WaterHeaterEntityFeature.OPERATION_MODE in self.supported_features:
data[ATTR_OPERATION_LIST] = self.operation_list
@@ -289,6 +299,11 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""Return the lowbound target temperature we try to reach."""
return self._attr_target_temperature_low
+ @cached_property
+ def target_temperature_step(self) -> float | None:
+ """Return the supported step of target temperature."""
+ return self._attr_target_temperature_step
+
@cached_property
def is_away_mode_on(self) -> bool | None:
"""Return true if away mode is on."""
diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json
index 07e132a0b5b..9cc3a84c3cd 100644
--- a/homeassistant/components/water_heater/strings.json
+++ b/homeassistant/components/water_heater/strings.json
@@ -14,8 +14,8 @@
"eco": "Eco",
"electric": "Electric",
"gas": "Gas",
- "high_demand": "High Demand",
- "heat_pump": "Heat Pump",
+ "high_demand": "High demand",
+ "heat_pump": "Heat pump",
"performance": "Performance"
},
"state_attributes": {
diff --git a/homeassistant/components/watergate/__init__.py b/homeassistant/components/watergate/__init__.py
index c1747af1f11..4f075a57228 100644
--- a/homeassistant/components/watergate/__init__.py
+++ b/homeassistant/components/watergate/__init__.py
@@ -15,11 +15,13 @@ from homeassistant.components.webhook import (
Response,
async_generate_url,
async_register,
+ async_unregister,
)
from homeassistant.const import CONF_IP_ADDRESS, CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
+from homeassistant.helpers.dispatcher import async_dispatcher_send
-from .const import DOMAIN
+from .const import AUTO_SHUT_OFF_EVENT_NAME, DOMAIN
from .coordinator import WatergateConfigEntry, WatergateDataCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -28,8 +30,10 @@ WEBHOOK_TELEMETRY_TYPE = "telemetry"
WEBHOOK_VALVE_TYPE = "valve"
WEBHOOK_WIFI_CHANGED_TYPE = "wifi-changed"
WEBHOOK_POWER_SUPPLY_CHANGED_TYPE = "power-supply-changed"
+WEBHOOK_AUTO_SHUT_OFF = "auto-shut-off-report"
PLATFORMS: list[Platform] = [
+ Platform.EVENT,
Platform.SENSOR,
Platform.VALVE,
]
@@ -72,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: WatergateConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: WatergateConfigEntry) -> bool:
"""Unload a config entry."""
webhook_id = entry.data[CONF_WEBHOOK_ID]
- hass.components.webhook.async_unregister(webhook_id)
+ async_unregister(hass, webhook_id)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -120,6 +124,10 @@ def get_webhook_handler(
coordinator_data.networking.rssi = data.rssi
elif body_type == WEBHOOK_POWER_SUPPLY_CHANGED_TYPE:
coordinator_data.state.power_supply = data.supply
+ elif body_type == WEBHOOK_AUTO_SHUT_OFF:
+ async_dispatcher_send(
+ hass, AUTO_SHUT_OFF_EVENT_NAME.format(data.type.lower()), data
+ )
coordinator.async_set_updated_data(coordinator_data)
diff --git a/homeassistant/components/watergate/const.py b/homeassistant/components/watergate/const.py
index 22a14330af9..c6726d9185f 100644
--- a/homeassistant/components/watergate/const.py
+++ b/homeassistant/components/watergate/const.py
@@ -3,3 +3,5 @@
DOMAIN = "watergate"
MANUFACTURER = "Watergate"
+
+AUTO_SHUT_OFF_EVENT_NAME = "watergate_{}"
diff --git a/homeassistant/components/watergate/event.py b/homeassistant/components/watergate/event.py
new file mode 100644
index 00000000000..cf2447df4b3
--- /dev/null
+++ b/homeassistant/components/watergate/event.py
@@ -0,0 +1,78 @@
+"""Module contains the AutoShutOffEvent class for handling auto shut off events."""
+
+from watergate_local_api.models.auto_shut_off_report import AutoShutOffReport
+
+from homeassistant.components.event import EventEntity, EventEntityDescription
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
+
+from . import WatergateConfigEntry
+from .const import AUTO_SHUT_OFF_EVENT_NAME
+from .coordinator import WatergateDataCoordinator
+from .entity import WatergateEntity
+
+VOLUME_AUTO_SHUT_OFF = "volume_threshold"
+DURATION_AUTO_SHUT_OFF = "duration_threshold"
+
+
+DESCRIPTIONS: list[EventEntityDescription] = [
+ EventEntityDescription(
+ translation_key="auto_shut_off_volume",
+ key="auto_shut_off_volume",
+ event_types=[VOLUME_AUTO_SHUT_OFF],
+ ),
+ EventEntityDescription(
+ translation_key="auto_shut_off_duration",
+ key="auto_shut_off_duration",
+ event_types=[DURATION_AUTO_SHUT_OFF],
+ ),
+]
+
+PARALLEL_UPDATES = 0
+
+
+async def async_setup_entry(
+ hass: HomeAssistant,
+ config_entry: WatergateConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
+) -> None:
+ """Set up Event entities from config entry."""
+
+ coordinator = config_entry.runtime_data
+
+ async_add_entities(
+ AutoShutOffEvent(coordinator, description) for description in DESCRIPTIONS
+ )
+
+
+class AutoShutOffEvent(WatergateEntity, EventEntity):
+ """Event for Auto Shut Off."""
+
+ def __init__(
+ self,
+ coordinator: WatergateDataCoordinator,
+ entity_description: EventEntityDescription,
+ ) -> None:
+ """Initialize Auto Shut Off Entity."""
+ super().__init__(coordinator, entity_description.key)
+ self.entity_description = entity_description
+
+ async def async_added_to_hass(self):
+ """Register the callback for event handling when the entity is added."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ async_dispatcher_connect(
+ self.hass,
+ AUTO_SHUT_OFF_EVENT_NAME.format(self.event_types[0]),
+ self._async_handle_event,
+ )
+ )
+
+ @callback
+ def _async_handle_event(self, event: AutoShutOffReport) -> None:
+ self._trigger_event(
+ event.type.lower(),
+ {"volume": event.volume, "duration": event.duration},
+ )
+ self.async_write_ha_state()
diff --git a/homeassistant/components/watergate/icons.json b/homeassistant/components/watergate/icons.json
new file mode 100644
index 00000000000..28a0bfbc825
--- /dev/null
+++ b/homeassistant/components/watergate/icons.json
@@ -0,0 +1,12 @@
+{
+ "entity": {
+ "event": {
+ "auto_shut_off_volume": {
+ "default": "mdi:water"
+ },
+ "auto_shut_off_duration": {
+ "default": "mdi:timelapse"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/watergate/quality_scale.yaml b/homeassistant/components/watergate/quality_scale.yaml
index b116eff970e..73a39bd5264 100644
--- a/homeassistant/components/watergate/quality_scale.yaml
+++ b/homeassistant/components/watergate/quality_scale.yaml
@@ -17,10 +17,7 @@ rules:
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
- entity-event-setup:
- status: exempt
- comment: |
- Entities of this integration does not explicitly subscribe to events.
+ entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
diff --git a/homeassistant/components/watergate/sensor.py b/homeassistant/components/watergate/sensor.py
index 44630d2f587..5aced8b7488 100644
--- a/homeassistant/components/watergate/sensor.py
+++ b/homeassistant/components/watergate/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfVolume,
UnitOfVolumeFlowRate,
)
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
@@ -182,7 +182,7 @@ DESCRIPTIONS: list[WatergateSensorEntityDescription] = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: WatergateConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all entries for Watergate Platform."""
diff --git a/homeassistant/components/watergate/strings.json b/homeassistant/components/watergate/strings.json
index c312525e420..634e05e7973 100644
--- a/homeassistant/components/watergate/strings.json
+++ b/homeassistant/components/watergate/strings.json
@@ -19,6 +19,42 @@
}
},
"entity": {
+ "event": {
+ "auto_shut_off_volume": {
+ "name": "Volume auto shut-off",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "volume_threshold": "Volume",
+ "duration_threshold": "Duration"
+ }
+ },
+ "volume": {
+ "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]"
+ },
+ "duration": {
+ "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]"
+ }
+ }
+ },
+ "auto_shut_off_duration": {
+ "name": "Duration auto shut-off",
+ "state_attributes": {
+ "event_type": {
+ "state": {
+ "volume_threshold": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]",
+ "duration_threshold": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]"
+ }
+ },
+ "volume": {
+ "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::volume_threshold%]"
+ },
+ "duration": {
+ "name": "[%key:component::watergate::entity::event::auto_shut_off_volume::state_attributes::event_type::state::duration_threshold%]"
+ }
+ }
+ }
+ },
"sensor": {
"water_meter_volume": {
"name": "Water meter volume"
diff --git a/homeassistant/components/watergate/valve.py b/homeassistant/components/watergate/valve.py
index ce914ebbb55..cb6bfa7bd59 100644
--- a/homeassistant/components/watergate/valve.py
+++ b/homeassistant/components/watergate/valve.py
@@ -8,7 +8,7 @@ from homeassistant.components.valve import (
ValveState,
)
from homeassistant.core import callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import WatergateConfigEntry, WatergateDataCoordinator
from .entity import WatergateEntity
@@ -20,7 +20,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: WatergateConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all entries for Watergate Platform."""
diff --git a/homeassistant/components/watttime/sensor.py b/homeassistant/components/watttime/sensor.py
index c6cc81580d7..d3aa9d8f895 100644
--- a/homeassistant/components/watttime/sensor.py
+++ b/homeassistant/components/watttime/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
@@ -52,7 +52,9 @@ REALTIME_EMISSIONS_SENSOR_DESCRIPTIONS = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WattTime sensors based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py
index 34f22c9218f..3a91690ef07 100644
--- a/homeassistant/components/waze_travel_time/__init__.py
+++ b/homeassistant/components/waze_travel_time/__init__.py
@@ -17,6 +17,7 @@ from homeassistant.core import (
SupportsResponse,
)
from homeassistant.helpers.httpx_client import get_async_client
+from homeassistant.helpers.location import find_coordinates
from homeassistant.helpers.selector import (
BooleanSelector,
SelectSelector,
@@ -115,10 +116,21 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
client = WazeRouteCalculator(
region=service.data[CONF_REGION].upper(), client=httpx_client
)
+
+ origin_coordinates = find_coordinates(hass, service.data[CONF_ORIGIN])
+ destination_coordinates = find_coordinates(hass, service.data[CONF_DESTINATION])
+
+ origin = origin_coordinates if origin_coordinates else service.data[CONF_ORIGIN]
+ destination = (
+ destination_coordinates
+ if destination_coordinates
+ else service.data[CONF_DESTINATION]
+ )
+
response = await async_get_travel_times(
client=client,
- origin=service.data[CONF_ORIGIN],
- destination=service.data[CONF_DESTINATION],
+ origin=origin,
+ destination=destination,
vehicle_type=service.data[CONF_VEHICLE_TYPE],
avoid_toll_roads=service.data[CONF_AVOID_TOLL_ROADS],
avoid_subscription_roads=service.data[CONF_AVOID_SUBSCRIPTION_ROADS],
diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py
index a216a02f61e..1f21cc2ea78 100644
--- a/homeassistant/components/waze_travel_time/sensor.py
+++ b/homeassistant/components/waze_travel_time/sensor.py
@@ -24,7 +24,7 @@ from homeassistant.const import (
)
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.httpx_client import get_async_client
from homeassistant.helpers.location import find_coordinates
@@ -57,7 +57,7 @@ SECONDS_BETWEEN_API_CALLS = 0.5
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a Waze travel time sensor entry."""
destination = config_entry.data[CONF_DESTINATION]
diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json
index 85d331f5bd0..31e644b32e3 100644
--- a/homeassistant/components/weather/strings.json
+++ b/homeassistant/components/weather/strings.json
@@ -90,17 +90,17 @@
"services": {
"get_forecasts": {
"name": "Get forecasts",
- "description": "Get weather forecasts.",
+ "description": "Retrieves the forecast from selected weather services.",
"fields": {
"type": {
"name": "Forecast type",
- "description": "Forecast type: daily, hourly or twice daily."
+ "description": "The scope of the weather forecast."
}
}
},
"get_forecast": {
"name": "Get forecast",
- "description": "Get weather forecast.",
+ "description": "Retrieves the forecast from a selected weather service.",
"fields": {
"type": {
"name": "[%key:component::weather::services::get_forecasts::fields::type::name%]",
@@ -111,12 +111,12 @@
},
"issues": {
"deprecated_service_weather_get_forecast": {
- "title": "Detected use of deprecated service weather.get_forecast",
+ "title": "Detected use of deprecated action weather.get_forecast",
"fix_flow": {
"step": {
"confirm": {
"title": "[%key:component::weather::issues::deprecated_service_weather_get_forecast::title%]",
- "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this service and adjust your automations and scripts and select **Submit** to close this issue."
+ "description": "Use `weather.get_forecasts` instead which supports multiple entities.\n\nPlease replace this action and adjust your automations and scripts and select **Submit** to close this issue."
}
}
}
diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py
index cacede55c42..10c04b3283b 100644
--- a/homeassistant/components/weatherflow/sensor.py
+++ b/homeassistant/components/weatherflow/sensor.py
@@ -40,7 +40,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.unit_system import METRIC_SYSTEM
@@ -267,16 +267,17 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = (
WeatherFlowSensorEntityDescription(
key="wind_direction",
translation_key="wind_direction",
+ device_class=SensorDeviceClass.WIND_DIRECTION,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
native_unit_of_measurement=DEGREE,
- state_class=SensorStateClass.MEASUREMENT,
event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION],
raw_data_conv_fn=lambda raw_data: raw_data.magnitude,
),
WeatherFlowSensorEntityDescription(
key="wind_direction_average",
translation_key="wind_direction_average",
+ device_class=SensorDeviceClass.WIND_DIRECTION,
native_unit_of_measurement=DEGREE,
- state_class=SensorStateClass.MEASUREMENT,
raw_data_conv_fn=lambda raw_data: raw_data.magnitude,
),
)
@@ -285,7 +286,7 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WeatherFlow sensors using config entry."""
diff --git a/homeassistant/components/weatherflow_cloud/manifest.json b/homeassistant/components/weatherflow_cloud/manifest.json
index 98c98cfbac7..9ffa457a355 100644
--- a/homeassistant/components/weatherflow_cloud/manifest.json
+++ b/homeassistant/components/weatherflow_cloud/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/weatherflow_cloud",
"iot_class": "cloud_polling",
"loggers": ["weatherflow4py"],
- "requirements": ["weatherflow4py==1.0.6"]
+ "requirements": ["weatherflow4py==1.3.1"]
}
diff --git a/homeassistant/components/weatherflow_cloud/sensor.py b/homeassistant/components/weatherflow_cloud/sensor.py
index aeab955878f..d2c62b5f281 100644
--- a/homeassistant/components/weatherflow_cloud/sensor.py
+++ b/homeassistant/components/weatherflow_cloud/sensor.py
@@ -17,7 +17,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfLength, UnitOfPressure, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
@@ -172,7 +172,7 @@ WF_SENSORS: tuple[WeatherFlowCloudSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WeatherFlow sensors based on a config entry."""
diff --git a/homeassistant/components/weatherflow_cloud/weather.py b/homeassistant/components/weatherflow_cloud/weather.py
index c475f2974a9..3cb1f477095 100644
--- a/homeassistant/components/weatherflow_cloud/weather.py
+++ b/homeassistant/components/weatherflow_cloud/weather.py
@@ -17,7 +17,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, STATE_MAP
from .coordinator import WeatherFlowCloudDataUpdateCoordinator
@@ -27,7 +27,7 @@ from .entity import WeatherFlowCloudEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
coordinator: WeatherFlowCloudDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/weatherkit/sensor.py b/homeassistant/components/weatherkit/sensor.py
index d9c17bb855a..b3639fa5356 100644
--- a/homeassistant/components/weatherkit/sensor.py
+++ b/homeassistant/components/weatherkit/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfVolumetricFlux
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -36,7 +36,7 @@ SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add sensor entities from a config_entry."""
coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py
index 98816d520ba..b57e488d06a 100644
--- a/homeassistant/components/weatherkit/weather.py
+++ b/homeassistant/components/weatherkit/weather.py
@@ -29,7 +29,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_CURRENT_WEATHER,
@@ -45,7 +45,7 @@ from .entity import WeatherKitEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add a weather entity from a config_entry."""
coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][
diff --git a/homeassistant/components/webdav/__init__.py b/homeassistant/components/webdav/__init__.py
new file mode 100644
index 00000000000..36a03dce4d7
--- /dev/null
+++ b/homeassistant/components/webdav/__init__.py
@@ -0,0 +1,75 @@
+"""The WebDAV integration."""
+
+from __future__ import annotations
+
+import logging
+
+from aiowebdav2.client import Client
+from aiowebdav2.exceptions import UnauthorizedError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
+
+from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+from .helpers import (
+ async_create_client,
+ async_ensure_path_exists,
+ async_migrate_wrong_folder_path,
+)
+
+type WebDavConfigEntry = ConfigEntry[Client]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool:
+ """Set up WebDAV from a config entry."""
+ client = async_create_client(
+ hass=hass,
+ url=entry.data[CONF_URL],
+ username=entry.data[CONF_USERNAME],
+ password=entry.data[CONF_PASSWORD],
+ verify_ssl=entry.data.get(CONF_VERIFY_SSL, True),
+ )
+
+ try:
+ result = await client.check()
+ except UnauthorizedError as err:
+ raise ConfigEntryError(
+ translation_domain=DOMAIN,
+ translation_key="invalid_username_password",
+ ) from err
+
+ # Check if we can connect to the WebDAV server
+ # and access the root directory
+ if not result:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_connect",
+ )
+
+ path = entry.data.get(CONF_BACKUP_PATH, "/")
+ await async_migrate_wrong_folder_path(client, path)
+
+ # Ensure the backup directory exists
+ if not await async_ensure_path_exists(client, path):
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="cannot_access_or_create_backup_path",
+ )
+
+ entry.runtime_data = client
+
+ def async_notify_backup_listeners() -> None:
+ for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
+ listener()
+
+ entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: WebDavConfigEntry) -> bool:
+ """Unload a WebDAV config entry."""
+ return True
diff --git a/homeassistant/components/webdav/backup.py b/homeassistant/components/webdav/backup.py
new file mode 100644
index 00000000000..a9afb5fe930
--- /dev/null
+++ b/homeassistant/components/webdav/backup.py
@@ -0,0 +1,248 @@
+"""Support for WebDAV backup."""
+
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Callable, Coroutine
+from functools import wraps
+import logging
+from time import time
+from typing import Any, Concatenate
+
+from aiohttp import ClientTimeout
+from aiowebdav2.exceptions import UnauthorizedError, WebDavError
+from propcache.api import cached_property
+
+from homeassistant.components.backup import (
+ AgentBackup,
+ BackupAgent,
+ BackupAgentError,
+ BackupNotFound,
+ suggested_filename,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.json import json_dumps
+from homeassistant.util.json import json_loads_object
+
+from . import WebDavConfigEntry
+from .const import CONF_BACKUP_PATH, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+BACKUP_TIMEOUT = ClientTimeout(connect=10, total=43200)
+CACHE_TTL = 300
+
+
+async def async_get_backup_agents(
+ hass: HomeAssistant,
+) -> list[BackupAgent]:
+ """Return a list of backup agents."""
+ entries: list[WebDavConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN)
+ return [WebDavBackupAgent(hass, entry) for entry in entries]
+
+
+@callback
+def async_register_backup_agents_listener(
+ hass: HomeAssistant,
+ *,
+ listener: Callable[[], None],
+ **kwargs: Any,
+) -> Callable[[], None]:
+ """Register a listener to be called when agents are added or removed.
+
+ :return: A function to unregister the listener.
+ """
+ hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
+
+ @callback
+ def remove_listener() -> None:
+ """Remove the listener."""
+ hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
+ if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
+ del hass.data[DATA_BACKUP_AGENT_LISTENERS]
+
+ return remove_listener
+
+
+def handle_backup_errors[_R, **P](
+ func: Callable[Concatenate[WebDavBackupAgent, P], Coroutine[Any, Any, _R]],
+) -> Callable[Concatenate[WebDavBackupAgent, P], Coroutine[Any, Any, _R]]:
+ """Handle backup errors."""
+
+ @wraps(func)
+ async def wrapper(self: WebDavBackupAgent, *args: P.args, **kwargs: P.kwargs) -> _R:
+ try:
+ return await func(self, *args, **kwargs)
+ except UnauthorizedError as err:
+ raise BackupAgentError("Authentication error") from err
+ except WebDavError as err:
+ _LOGGER.debug("Full error: %s", err, exc_info=True)
+ raise BackupAgentError(
+ f"Backup operation failed: {err}",
+ ) from err
+ except TimeoutError as err:
+ _LOGGER.error(
+ "Error during backup in %s: Timeout",
+ func.__name__,
+ )
+ raise BackupAgentError("Backup operation timed out") from err
+
+ return wrapper
+
+
+def suggested_filenames(backup: AgentBackup) -> tuple[str, str]:
+ """Return the suggested filenames for the backup and metadata."""
+ base_name = suggested_filename(backup).rsplit(".", 1)[0]
+ return f"{base_name}.tar", f"{base_name}.metadata.json"
+
+
+class WebDavBackupAgent(BackupAgent):
+ """Backup agent interface."""
+
+ domain = DOMAIN
+
+ def __init__(self, hass: HomeAssistant, entry: WebDavConfigEntry) -> None:
+ """Initialize the WebDAV backup agent."""
+ super().__init__()
+ self._hass = hass
+ self._entry = entry
+ self._client = entry.runtime_data
+ self.name = entry.title
+ self.unique_id = entry.entry_id
+ self._cache_metadata_files: dict[str, AgentBackup] = {}
+ self._cache_expiration = time()
+
+ @cached_property
+ def _backup_path(self) -> str:
+ """Return the path to the backup."""
+ return self._entry.data.get(CONF_BACKUP_PATH, "")
+
+ @handle_backup_errors
+ async def async_download_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AsyncIterator[bytes]:
+ """Download a backup file.
+
+ :param backup_id: The ID of the backup that was returned in async_list_backups.
+ :return: An async iterator that yields bytes.
+ """
+ backup = await self._find_backup_by_id(backup_id)
+
+ return await self._client.download_iter(
+ f"{self._backup_path}/{suggested_filename(backup)}",
+ timeout=BACKUP_TIMEOUT,
+ )
+
+ @handle_backup_errors
+ async def async_upload_backup(
+ self,
+ *,
+ open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
+ backup: AgentBackup,
+ **kwargs: Any,
+ ) -> None:
+ """Upload a backup.
+
+ :param open_stream: A function returning an async iterator that yields bytes.
+ :param backup: Metadata about the backup that should be uploaded.
+ """
+ (filename_tar, filename_meta) = suggested_filenames(backup)
+
+ await self._client.upload_iter(
+ await open_stream(),
+ f"{self._backup_path}/{filename_tar}",
+ timeout=BACKUP_TIMEOUT,
+ content_length=backup.size,
+ )
+
+ _LOGGER.debug(
+ "Uploaded backup to %s",
+ f"{self._backup_path}/{filename_tar}",
+ )
+
+ await self._client.upload_iter(
+ json_dumps(backup.as_dict()),
+ f"{self._backup_path}/{filename_meta}",
+ )
+
+ _LOGGER.debug(
+ "Uploaded metadata file for %s",
+ f"{self._backup_path}/{filename_meta}",
+ )
+
+ # reset cache
+ self._cache_expiration = time()
+
+ @handle_backup_errors
+ async def async_delete_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> None:
+ """Delete a backup file.
+
+ :param backup_id: The ID of the backup that was returned in async_list_backups.
+ """
+ backup = await self._find_backup_by_id(backup_id)
+
+ (filename_tar, filename_meta) = suggested_filenames(backup)
+ backup_path = f"{self._backup_path}/{filename_tar}"
+
+ await self._client.clean(backup_path)
+ await self._client.clean(f"{self._backup_path}/{filename_meta}")
+
+ _LOGGER.debug(
+ "Deleted backup at %s",
+ backup_path,
+ )
+
+ # reset cache
+ self._cache_expiration = time()
+
+ @handle_backup_errors
+ async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
+ """List backups."""
+ return list((await self._list_cached_metadata_files()).values())
+
+ @handle_backup_errors
+ async def async_get_backup(
+ self,
+ backup_id: str,
+ **kwargs: Any,
+ ) -> AgentBackup:
+ """Return a backup."""
+ return await self._find_backup_by_id(backup_id)
+
+ async def _list_cached_metadata_files(self) -> dict[str, AgentBackup]:
+ """List metadata files with a cache."""
+ if time() <= self._cache_expiration:
+ return self._cache_metadata_files
+
+ async def _download_metadata(path: str) -> AgentBackup:
+ """Download metadata file."""
+ iterator = await self._client.download_iter(path)
+ metadata = await anext(iterator)
+ return AgentBackup.from_dict(json_loads_object(metadata))
+
+ async def _list_metadata_files() -> dict[str, AgentBackup]:
+ """List metadata files."""
+ files = await self._client.list_files(self._backup_path)
+ return {
+ metadata_content.backup_id: metadata_content
+ for file_name in files
+ if file_name.endswith(".metadata.json")
+ if (metadata_content := await _download_metadata(file_name))
+ }
+
+ self._cache_metadata_files = await _list_metadata_files()
+ self._cache_expiration = time() + CACHE_TTL
+ return self._cache_metadata_files
+
+ async def _find_backup_by_id(self, backup_id: str) -> AgentBackup:
+ """Find a backup by its backup ID on remote."""
+ metadata_files = await self._list_cached_metadata_files()
+ if metadata_file := metadata_files.get(backup_id):
+ return metadata_file
+
+ raise BackupNotFound(f"Backup {backup_id} not found")
diff --git a/homeassistant/components/webdav/config_flow.py b/homeassistant/components/webdav/config_flow.py
new file mode 100644
index 00000000000..e3e46d2575a
--- /dev/null
+++ b/homeassistant/components/webdav/config_flow.py
@@ -0,0 +1,92 @@
+"""Config flow for the WebDAV integration."""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+
+from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError
+import voluptuous as vol
+import yarl
+
+from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
+from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL
+from homeassistant.helpers.selector import (
+ TextSelector,
+ TextSelectorConfig,
+ TextSelectorType,
+)
+
+from .const import CONF_BACKUP_PATH, DOMAIN
+from .helpers import async_create_client
+
+_LOGGER = logging.getLogger(__name__)
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_URL): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.URL,
+ )
+ ),
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): TextSelector(
+ TextSelectorConfig(
+ type=TextSelectorType.PASSWORD,
+ )
+ ),
+ vol.Optional(CONF_BACKUP_PATH, default="/"): str,
+ vol.Optional(CONF_VERIFY_SSL, default=True): bool,
+ }
+)
+
+
+class WebDavConfigFlow(ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for WebDAV."""
+
+ 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:
+ client = async_create_client(
+ hass=self.hass,
+ url=user_input[CONF_URL],
+ username=user_input[CONF_USERNAME],
+ password=user_input[CONF_PASSWORD],
+ verify_ssl=user_input.get(CONF_VERIFY_SSL, True),
+ )
+
+ # Check if we can connect to the WebDAV server
+ # .check() already does the most of the error handling and will return True
+ # if we can access the root directory
+ try:
+ result = await client.check()
+ except UnauthorizedError:
+ errors["base"] = "invalid_auth"
+ except MethodNotSupportedError:
+ errors["base"] = "invalid_method"
+ except Exception:
+ _LOGGER.exception("Unexpected error")
+ errors["base"] = "unknown"
+ else:
+ if result:
+ self._async_abort_entries_match(
+ {
+ CONF_URL: user_input[CONF_URL],
+ CONF_USERNAME: user_input[CONF_USERNAME],
+ }
+ )
+
+ parsed_url = yarl.URL(user_input[CONF_URL])
+ return self.async_create_entry(
+ title=f"{user_input[CONF_USERNAME]}@{parsed_url.host}",
+ data=user_input,
+ )
+
+ errors["base"] = "cannot_connect"
+
+ return self.async_show_form(
+ step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
+ )
diff --git a/homeassistant/components/webdav/const.py b/homeassistant/components/webdav/const.py
new file mode 100644
index 00000000000..faf8ce77ca5
--- /dev/null
+++ b/homeassistant/components/webdav/const.py
@@ -0,0 +1,13 @@
+"""Constants for the WebDAV integration."""
+
+from collections.abc import Callable
+
+from homeassistant.util.hass_dict import HassKey
+
+DOMAIN = "webdav"
+
+DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
+ f"{DOMAIN}.backup_agent_listeners"
+)
+
+CONF_BACKUP_PATH = "backup_path"
diff --git a/homeassistant/components/webdav/helpers.py b/homeassistant/components/webdav/helpers.py
new file mode 100644
index 00000000000..442f69b4d3c
--- /dev/null
+++ b/homeassistant/components/webdav/helpers.py
@@ -0,0 +1,68 @@
+"""Helper functions for the WebDAV component."""
+
+import logging
+
+from aiowebdav2.client import Client, ClientOptions
+from aiowebdav2.exceptions import WebDavError
+
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def async_create_client(
+ *,
+ hass: HomeAssistant,
+ url: str,
+ username: str,
+ password: str,
+ verify_ssl: bool = False,
+) -> Client:
+ """Create a WebDAV client."""
+ return Client(
+ url=url,
+ username=username,
+ password=password,
+ options=ClientOptions(
+ verify_ssl=verify_ssl,
+ session=async_get_clientsession(hass),
+ ),
+ )
+
+
+async def async_ensure_path_exists(client: Client, path: str) -> bool:
+ """Ensure that a path exists recursively on the WebDAV server."""
+ parts = path.strip("/").split("/")
+ for i in range(1, len(parts) + 1):
+ sub_path = "/".join(parts[:i])
+ if not await client.check(sub_path) and not await client.mkdir(sub_path):
+ return False
+
+ return True
+
+
+async def async_migrate_wrong_folder_path(client: Client, path: str) -> None:
+ """Migrate the wrong encoded folder path to the correct one."""
+ wrong_path = path.replace(" ", "%20")
+ # migrate folder when the old folder exists
+ if wrong_path != path and await client.check(wrong_path):
+ try:
+ await client.move(wrong_path, path)
+ except WebDavError as err:
+ raise ConfigEntryNotReady(
+ translation_domain=DOMAIN,
+ translation_key="failed_to_migrate_folder",
+ translation_placeholders={
+ "wrong_path": wrong_path,
+ "correct_path": path,
+ },
+ ) from err
+
+ _LOGGER.debug(
+ "Migrated wrong encoded folder path from %s to %s", wrong_path, path
+ )
diff --git a/homeassistant/components/webdav/manifest.json b/homeassistant/components/webdav/manifest.json
new file mode 100644
index 00000000000..63d093745d1
--- /dev/null
+++ b/homeassistant/components/webdav/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "webdav",
+ "name": "WebDAV",
+ "codeowners": ["@jpbede"],
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/webdav",
+ "integration_type": "service",
+ "iot_class": "cloud_polling",
+ "loggers": ["aiowebdav2"],
+ "quality_scale": "bronze",
+ "requirements": ["aiowebdav2==0.4.5"]
+}
diff --git a/homeassistant/components/webdav/quality_scale.yaml b/homeassistant/components/webdav/quality_scale.yaml
new file mode 100644
index 00000000000..560626fda7e
--- /dev/null
+++ b/homeassistant/components/webdav/quality_scale.yaml
@@ -0,0 +1,145 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: Integration does not register custom actions.
+ appropriate-polling:
+ status: exempt
+ comment: |
+ This integration does not poll.
+ brands: done
+ common-modules: done
+ config-flow-test-coverage: done
+ config-flow: done
+ dependency-transparency: done
+ docs-actions:
+ status: exempt
+ comment: |
+ This integration does not have any custom actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup:
+ status: exempt
+ comment: |
+ Entities of this integration does not explicitly subscribe to events.
+ entity-unique-id:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ has-entity-name:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup: done
+ unique-config-entry: done
+
+ # Silver
+ action-exceptions:
+ status: exempt
+ comment: Integration does not register custom actions.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: |
+ No Options flow.
+ docs-installation-parameters: done
+ entity-unavailable:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ integration-owner: done
+ log-when-unavailable:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ parallel-updates:
+ status: exempt
+ comment: |
+ This integration does not have platforms.
+ reauthentication-flow: todo
+ test-coverage: todo
+
+ # Gold
+ devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+ diagnostics:
+ status: exempt
+ comment: |
+ There is no data to diagnose.
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration is a cloud service and does not support discovery.
+ discovery:
+ status: exempt
+ comment: |
+ This integration is a cloud service and does not support discovery.
+ docs-data-update:
+ status: exempt
+ comment: |
+ This integration does not poll or push.
+ docs-examples:
+ status: exempt
+ comment: |
+ This integration only serves backup.
+ docs-known-limitations:
+ status: done
+ comment: |
+ No known limitations.
+ docs-supported-devices:
+ status: exempt
+ comment: |
+ This integration is a cloud service.
+ docs-supported-functions:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ docs-troubleshooting:
+ status: exempt
+ comment: |
+ No issues known to troubleshoot.
+ docs-use-cases: todo
+ dynamic-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+ entity-category:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-device-class:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-disabled-by-default:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ entity-translations:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ exception-translations: done
+ icon-translations:
+ status: exempt
+ comment: |
+ This integration does not have entities.
+ reconfiguration-flow:
+ status: exempt
+ comment: |
+ Nothing to reconfigure.
+ repair-issues: todo
+ stale-devices:
+ status: exempt
+ comment: |
+ This integration connects to a single service.
+
+ # Platinum
+ async-dependency: done
+ inject-websession: todo
+ strict-typing: todo
diff --git a/homeassistant/components/webdav/strings.json b/homeassistant/components/webdav/strings.json
new file mode 100644
index 00000000000..ac6418f1239
--- /dev/null
+++ b/homeassistant/components/webdav/strings.json
@@ -0,0 +1,45 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "url": "[%key:common::config_flow::data::url%]",
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]",
+ "backup_path": "Backup path",
+ "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
+ },
+ "data_description": {
+ "url": "The URL of the WebDAV server. Check with your provider for the correct URL.",
+ "username": "The username for the WebDAV server.",
+ "password": "The password for the WebDAV server.",
+ "backup_path": "Define the path where the backups should be located (will be created automatically if it does not exist).",
+ "verify_ssl": "Whether to verify the SSL certificate of the server. If you are using a self-signed certificate, do not select this option."
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "invalid_method": "The server does not support the required methods. Please check whether you have the correct URL. Check with your provider for the correct URL.",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
+ }
+ },
+ "exceptions": {
+ "invalid_username_password": {
+ "message": "Invalid username or password"
+ },
+ "cannot_connect": {
+ "message": "Cannot connect to WebDAV server"
+ },
+ "cannot_access_or_create_backup_path": {
+ "message": "Cannot access or create backup path. Please check the path and permissions."
+ },
+ "failed_to_migrate_folder": {
+ "message": "Failed to migrate wrong encoded folder \"{wrong_path}\" to \"{correct_path}\"."
+ }
+ }
+}
diff --git a/homeassistant/components/webmin/sensor.py b/homeassistant/components/webmin/sensor.py
index 785140393a2..a21c73bed13 100644
--- a/homeassistant/components/webmin/sensor.py
+++ b/homeassistant/components/webmin/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import PERCENTAGE, UnitOfInformation
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import WebminConfigEntry
@@ -200,7 +200,7 @@ def generate_filesystem_sensor_description(
async def async_setup_entry(
hass: HomeAssistant,
entry: WebminConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Webmin sensors based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/webmin/strings.json b/homeassistant/components/webmin/strings.json
index 9a6d6d4fbe4..b92986f917a 100644
--- a/homeassistant/components/webmin/strings.json
+++ b/homeassistant/components/webmin/strings.json
@@ -29,13 +29,13 @@
"entity": {
"sensor": {
"load_1m": {
- "name": "Load (1m)"
+ "name": "Load (1 min)"
},
"load_5m": {
- "name": "Load (5m)"
+ "name": "Load (5 min)"
},
"load_15m": {
- "name": "Load (15m)"
+ "name": "Load (15 min)"
},
"mem_total": {
"name": "Memory total"
diff --git a/homeassistant/components/webostv/config_flow.py b/homeassistant/components/webostv/config_flow.py
index fbc3eb958dd..80c8fb7f8f2 100644
--- a/homeassistant/components/webostv/config_flow.py
+++ b/homeassistant/components/webostv/config_flow.py
@@ -92,13 +92,13 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
else:
await self.async_set_unique_id(
- client.hello_info["deviceUUID"], raise_on_progress=False
+ client.tv_info.hello["deviceUUID"], raise_on_progress=False
)
self._abort_if_unique_id_configured({CONF_HOST: self._host})
data = {CONF_HOST: self._host, CONF_CLIENT_SECRET: client.client_key}
if not self._name:
- self._name = f"{DEFAULT_NAME} {client.system_info['modelName']}"
+ self._name = f"{DEFAULT_NAME} {client.tv_info.system['modelName']}"
return self.async_create_entry(title=self._name, data=data)
return self.async_show_form(step_id="pairing", errors=errors)
@@ -176,7 +176,7 @@ class FlowHandler(ConfigFlow, domain=DOMAIN):
except WEBOSTV_EXCEPTIONS:
errors["base"] = "cannot_connect"
else:
- await self.async_set_unique_id(client.hello_info["deviceUUID"])
+ await self.async_set_unique_id(client.tv_info.hello["deviceUUID"])
self._abort_if_unique_id_mismatch(reason="wrong_device")
data = {CONF_HOST: host, CONF_CLIENT_SECRET: client.client_key}
return self.async_update_reload_and_abort(reconfigure_entry, data=data)
@@ -214,7 +214,7 @@ class OptionsFlowHandler(OptionsFlow):
sources_list = []
try:
client = await async_control_connect(self.hass, self.host, self.key)
- sources_list = get_sources(client)
+ sources_list = get_sources(client.tv_state)
except WebOsTvPairError:
errors["base"] = "error_pairing"
except WEBOSTV_EXCEPTIONS:
diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py
index e505611db52..118ea7b32db 100644
--- a/homeassistant/components/webostv/const.py
+++ b/homeassistant/components/webostv/const.py
@@ -31,6 +31,7 @@ WEBOSTV_EXCEPTIONS = (
WebOsTvCommandError,
aiohttp.ClientConnectorError,
aiohttp.ServerDisconnectedError,
+ aiohttp.WSMessageTypeError,
asyncio.CancelledError,
asyncio.TimeoutError,
)
diff --git a/homeassistant/components/webostv/diagnostics.py b/homeassistant/components/webostv/diagnostics.py
index 7fb64a2cb8f..e4ea38064a8 100644
--- a/homeassistant/components/webostv/diagnostics.py
+++ b/homeassistant/components/webostv/diagnostics.py
@@ -32,15 +32,8 @@ async def async_get_config_entry_diagnostics(
client_data = {
"is_registered": client.is_registered(),
"is_connected": client.is_connected(),
- "current_app_id": client.current_app_id,
- "current_channel": client.current_channel,
- "apps": client.apps,
- "inputs": client.inputs,
- "system_info": client.system_info,
- "software_info": client.software_info,
- "hello_info": client.hello_info,
- "sound_output": client.sound_output,
- "is_on": client.is_on,
+ "tv_info": client.tv_info.__dict__,
+ "tv_state": client.tv_state.__dict__,
}
return async_redact_data(
diff --git a/homeassistant/components/webostv/helpers.py b/homeassistant/components/webostv/helpers.py
index 3c509a56d1e..f70f250f91d 100644
--- a/homeassistant/components/webostv/helpers.py
+++ b/homeassistant/components/webostv/helpers.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import logging
-from aiowebostv import WebOsClient
+from aiowebostv import WebOsClient, WebOsTvState
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import CONF_CLIENT_SECRET, CONF_HOST
@@ -83,16 +83,16 @@ def async_get_client_by_device_entry(
)
-def get_sources(client: WebOsClient) -> list[str]:
+def get_sources(tv_state: WebOsTvState) -> list[str]:
"""Construct sources list."""
sources = []
found_live_tv = False
- for app in client.apps.values():
+ for app in tv_state.apps.values():
sources.append(app["title"])
if app["id"] == LIVE_TV_APP_ID:
found_live_tv = True
- for source in client.inputs.values():
+ for source in tv_state.inputs.values():
sources.append(source["label"])
if source["appId"] == LIVE_TV_APP_ID:
found_live_tv = True
diff --git a/homeassistant/components/webostv/manifest.json b/homeassistant/components/webostv/manifest.json
index 174e8025dd0..8ac470ae922 100644
--- a/homeassistant/components/webostv/manifest.json
+++ b/homeassistant/components/webostv/manifest.json
@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/webostv",
"iot_class": "local_push",
"loggers": ["aiowebostv"],
- "requirements": ["aiowebostv==0.6.1"],
+ "requirements": ["aiowebostv==0.7.3"],
"ssdp": [
{
"st": "urn:lge-com:service:webos-second-screen:1"
diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py
index 5c47a5e775f..780e9f418a5 100644
--- a/homeassistant/components/webostv/media_player.py
+++ b/homeassistant/components/webostv/media_player.py
@@ -11,7 +11,7 @@ from http import HTTPStatus
import logging
from typing import Any, Concatenate, cast
-from aiowebostv import WebOsClient, WebOsTvPairError
+from aiowebostv import WebOsTvPairError, WebOsTvState
import voluptuous as vol
from homeassistant import util
@@ -28,7 +28,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.trigger import PluggableAction
from homeassistant.helpers.typing import VolDictType
@@ -102,7 +102,7 @@ SERVICES = (
async def async_setup_entry(
hass: HomeAssistant,
entry: WebOsTvConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the LG webOS TV platform."""
platform = entity_platform.async_get_current_platform()
@@ -205,51 +205,52 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
"""Call disconnect on removal."""
self._client.unregister_state_update_callback(self.async_handle_state_update)
- async def async_handle_state_update(self, _client: WebOsClient) -> None:
+ async def async_handle_state_update(self, tv_state: WebOsTvState) -> None:
"""Update state from WebOsClient."""
self._update_states()
self.async_write_ha_state()
def _update_states(self) -> None:
"""Update entity state attributes."""
+ tv_state = self._client.tv_state
self._update_sources()
self._attr_state = (
- MediaPlayerState.ON if self._client.is_on else MediaPlayerState.OFF
+ MediaPlayerState.ON if tv_state.is_on else MediaPlayerState.OFF
)
- self._attr_is_volume_muted = cast(bool, self._client.muted)
+ self._attr_is_volume_muted = cast(bool, tv_state.muted)
self._attr_volume_level = None
- if self._client.volume is not None:
- self._attr_volume_level = self._client.volume / 100.0
+ if tv_state.volume is not None:
+ self._attr_volume_level = tv_state.volume / 100.0
self._attr_source = self._current_source
self._attr_source_list = sorted(self._source_list)
self._attr_media_content_type = None
- if self._client.current_app_id == LIVE_TV_APP_ID:
+ if tv_state.current_app_id == LIVE_TV_APP_ID:
self._attr_media_content_type = MediaType.CHANNEL
self._attr_media_title = None
- if (self._client.current_app_id == LIVE_TV_APP_ID) and (
- self._client.current_channel is not None
+ if (tv_state.current_app_id == LIVE_TV_APP_ID) and (
+ tv_state.current_channel is not None
):
self._attr_media_title = cast(
- str, self._client.current_channel.get("channelName")
+ str, tv_state.current_channel.get("channelName")
)
self._attr_media_image_url = None
- if self._client.current_app_id in self._client.apps:
- icon: str = self._client.apps[self._client.current_app_id]["largeIcon"]
+ if tv_state.current_app_id in tv_state.apps:
+ icon: str = tv_state.apps[tv_state.current_app_id]["largeIcon"]
if not icon.startswith("http"):
- icon = self._client.apps[self._client.current_app_id]["icon"]
+ icon = tv_state.apps[tv_state.current_app_id]["icon"]
self._attr_media_image_url = icon
if self.state != MediaPlayerState.OFF or not self._supported_features:
supported = SUPPORT_WEBOSTV
- if self._client.sound_output == "external_speaker":
+ if tv_state.sound_output == "external_speaker":
supported = supported | SUPPORT_WEBOSTV_VOLUME
- elif self._client.sound_output != "lineout":
+ elif tv_state.sound_output != "lineout":
supported = (
supported
| SUPPORT_WEBOSTV_VOLUME
@@ -265,9 +266,9 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
)
self._attr_assumed_state = True
- if self._client.is_on and self._client.media_state:
+ if tv_state.is_on and tv_state.media_state:
self._attr_assumed_state = False
- for entry in self._client.media_state:
+ for entry in tv_state.media_state:
if entry.get("playState") == "playing":
self._attr_state = MediaPlayerState.PLAYING
elif entry.get("playState") == "paused":
@@ -275,35 +276,37 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
elif entry.get("playState") == "unloaded":
self._attr_state = MediaPlayerState.IDLE
+ tv_info = self._client.tv_info
if self.state != MediaPlayerState.OFF:
- maj_v = self._client.software_info.get("major_ver")
- min_v = self._client.software_info.get("minor_ver")
+ maj_v = tv_info.software.get("major_ver")
+ min_v = tv_info.software.get("minor_ver")
if maj_v and min_v:
self._attr_device_info["sw_version"] = f"{maj_v}.{min_v}"
- if model := self._client.system_info.get("modelName"):
+ if model := tv_info.system.get("modelName"):
self._attr_device_info["model"] = model
- if serial_number := self._client.system_info.get("serialNumber"):
+ if serial_number := tv_info.system.get("serialNumber"):
self._attr_device_info["serial_number"] = serial_number
self._attr_extra_state_attributes = {}
- if self._client.sound_output is not None or self.state != MediaPlayerState.OFF:
+ if tv_state.sound_output is not None or self.state != MediaPlayerState.OFF:
self._attr_extra_state_attributes = {
- ATTR_SOUND_OUTPUT: self._client.sound_output
+ ATTR_SOUND_OUTPUT: tv_state.sound_output
}
def _update_sources(self) -> None:
"""Update list of sources from current source, apps, inputs and configured list."""
+ tv_state = self._client.tv_state
source_list = self._source_list
self._source_list = {}
conf_sources = self._sources
found_live_tv = False
- for app in self._client.apps.values():
+ for app in tv_state.apps.values():
if app["id"] == LIVE_TV_APP_ID:
found_live_tv = True
- if app["id"] == self._client.current_app_id:
+ if app["id"] == tv_state.current_app_id:
self._current_source = app["title"]
self._source_list[app["title"]] = app
elif (
@@ -314,10 +317,10 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
):
self._source_list[app["title"]] = app
- for source in self._client.inputs.values():
+ for source in tv_state.inputs.values():
if source["appId"] == LIVE_TV_APP_ID:
found_live_tv = True
- if source["appId"] == self._client.current_app_id:
+ if source["appId"] == tv_state.current_app_id:
self._current_source = source["label"]
self._source_list[source["label"]] = source
elif (
@@ -334,7 +337,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
# not appear in the app or input lists in some cases
elif not found_live_tv:
app = {"id": LIVE_TV_APP_ID, "title": "Live TV"}
- if self._client.current_app_id == LIVE_TV_APP_ID:
+ if tv_state.current_app_id == LIVE_TV_APP_ID:
self._current_source = app["title"]
self._source_list["Live TV"] = app
elif (
@@ -434,12 +437,12 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
"""Play a piece of media."""
_LOGGER.debug("Call play media type <%s>, Id <%s>", media_type, media_id)
- if media_type == MediaType.CHANNEL and self._client.channels:
+ if media_type == MediaType.CHANNEL and self._client.tv_state.channels:
_LOGGER.debug("Searching channel")
partial_match_channel_id = None
perfect_match_channel_id = None
- for channel in self._client.channels:
+ for channel in self._client.tv_state.channels:
if media_id == channel["channelNumber"]:
perfect_match_channel_id = channel["channelId"]
continue
@@ -484,7 +487,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
@cmd
async def async_media_next_track(self) -> None:
"""Send next track command."""
- if self._client.current_app_id == LIVE_TV_APP_ID:
+ if self._client.tv_state.current_app_id == LIVE_TV_APP_ID:
await self._client.channel_up()
else:
await self._client.fast_forward()
@@ -492,7 +495,7 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity):
@cmd
async def async_media_previous_track(self) -> None:
"""Send the previous track command."""
- if self._client.current_app_id == LIVE_TV_APP_ID:
+ if self._client.tv_state.current_app_id == LIVE_TV_APP_ID:
await self._client.channel_down()
else:
await self._client.rewind()
diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py
index 2393cb4cd07..3966cea5e92 100644
--- a/homeassistant/components/webostv/notify.py
+++ b/homeassistant/components/webostv/notify.py
@@ -49,7 +49,7 @@ class LgWebOSNotificationService(BaseNotificationService):
data = kwargs[ATTR_DATA]
icon_path = data.get(ATTR_ICON) if data else None
- if not client.is_on:
+ if not client.tv_state.is_on:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="notify_device_off",
diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py
index 4a360b4a43c..ddcdd4f1cf8 100644
--- a/homeassistant/components/websocket_api/commands.py
+++ b/homeassistant/components/websocket_api/commands.py
@@ -59,7 +59,11 @@ from homeassistant.loader import (
async_get_integration_descriptions,
async_get_integrations,
)
-from homeassistant.setup import async_get_loaded_integrations, async_get_setup_timings
+from homeassistant.setup import (
+ async_get_loaded_integrations,
+ async_get_setup_timings,
+ async_wait_component,
+)
from homeassistant.util.json import format_unserializable_data
from . import const, decorators, messages
@@ -98,6 +102,7 @@ def async_register_commands(
async_reg(hass, handle_subscribe_entities)
async_reg(hass, handle_supported_features)
async_reg(hass, handle_integration_descriptions)
+ async_reg(hass, handle_integration_wait)
def pong_message(iden: int) -> dict[str, Any]:
@@ -923,3 +928,21 @@ async def handle_integration_descriptions(
) -> None:
"""Get metadata for all brands and integrations."""
connection.send_result(msg["id"], await async_get_integration_descriptions(hass))
+
+
+@decorators.websocket_command(
+ {
+ vol.Required("type"): "integration/wait",
+ vol.Required("domain"): str,
+ }
+)
+@decorators.async_response
+async def handle_integration_wait(
+ hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
+) -> None:
+ """Handle wait for integration command."""
+
+ domain: str = msg["domain"]
+ connection.send_result(
+ msg["id"], {"integration_loaded": await async_wait_component(hass, domain)}
+ )
diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py
index a0d031834ae..fce85339430 100644
--- a/homeassistant/components/websocket_api/const.py
+++ b/homeassistant/components/websocket_api/const.py
@@ -21,7 +21,7 @@ type AsyncWebSocketCommandHandler = Callable[
DOMAIN: Final = "websocket_api"
URL: Final = "/api/websocket"
PENDING_MSG_PEAK: Final = 1024
-PENDING_MSG_PEAK_TIME: Final = 5
+PENDING_MSG_PEAK_TIME: Final = 10
# Maximum number of messages that can be pending at any given time.
# This is effectively the upper limit of the number of entities
# that can fire state changes within ~1 second.
diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py
index ebca497193b..4250da149ad 100644
--- a/homeassistant/components/websocket_api/http.py
+++ b/homeassistant/components/websocket_api/http.py
@@ -14,7 +14,7 @@ from aiohttp import WSMsgType, web
from aiohttp.http_websocket import WebSocketWriter
from homeassistant.components.http import KEY_HASS, HomeAssistantView
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_call_later
@@ -73,6 +73,7 @@ class WebSocketHandler:
"_authenticated",
"_closing",
"_connection",
+ "_debug",
"_handle_task",
"_hass",
"_logger",
@@ -107,6 +108,12 @@ class WebSocketHandler:
self._message_queue: deque[bytes] = deque()
self._ready_future: asyncio.Future[int] | None = None
self._release_ready_queue_size: int = 0
+ self._async_logging_changed()
+
+ @callback
+ def _async_logging_changed(self, event: Event | None = None) -> None:
+ """Handle logging change."""
+ self._debug = self._logger.isEnabledFor(logging.DEBUG)
def __repr__(self) -> str:
"""Return the representation."""
@@ -137,7 +144,6 @@ class WebSocketHandler:
logger = self._logger
wsock = self._wsock
loop = self._loop
- is_debug_log_enabled = partial(logger.isEnabledFor, logging.DEBUG)
debug = logger.debug
can_coalesce = connection.can_coalesce
ready_message_count = len(message_queue)
@@ -157,14 +163,14 @@ class WebSocketHandler:
if not can_coalesce or ready_message_count == 1:
message = message_queue.popleft()
- if is_debug_log_enabled():
+ if self._debug:
debug("%s: Sending %s", self.description, message)
await send_bytes_text(message)
continue
coalesced_messages = b"".join((b"[", b",".join(message_queue), b"]"))
message_queue.clear()
- if is_debug_log_enabled():
+ if self._debug:
debug("%s: Sending %s", self.description, coalesced_messages)
await send_bytes_text(coalesced_messages)
except asyncio.CancelledError:
@@ -325,6 +331,9 @@ class WebSocketHandler:
unsub_stop = hass.bus.async_listen(
EVENT_HOMEASSISTANT_STOP, self._async_handle_hass_stop
)
+ cancel_logging_listener = hass.bus.async_listen(
+ EVENT_LOGGING_CHANGED, self._async_logging_changed
+ )
writer = wsock._writer # noqa: SLF001
if TYPE_CHECKING:
@@ -354,6 +363,7 @@ class WebSocketHandler:
"%s: Unexpected error inside websocket API", self.description
)
finally:
+ cancel_logging_listener()
unsub_stop()
self._cancel_peak_checker()
@@ -401,7 +411,7 @@ class WebSocketHandler:
except ValueError as err:
raise Disconnect("Received invalid JSON during auth phase") from err
- if self._logger.isEnabledFor(logging.DEBUG):
+ if self._debug:
self._logger.debug("%s: Received %s", self.description, auth_msg_data)
connection = await auth.async_handle(auth_msg_data)
# As the webserver is now started before the start
@@ -463,7 +473,6 @@ class WebSocketHandler:
wsock = self._wsock
async_handle_str = connection.async_handle
async_handle_binary = connection.async_handle_binary
- _debug_enabled = partial(self._logger.isEnabledFor, logging.DEBUG)
# Command phase
while not wsock.closed:
@@ -496,7 +505,7 @@ class WebSocketHandler:
except ValueError as ex:
raise Disconnect("Received invalid JSON.") from ex
- if _debug_enabled():
+ if self._debug:
self._logger.debug(
"%s: Received %s", self.description, command_msg_data
)
diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py
index 0a8200c5700..6ae7de2c4b7 100644
--- a/homeassistant/components/websocket_api/messages.py
+++ b/homeassistant/components/websocket_api/messages.py
@@ -207,7 +207,7 @@ def _state_diff_event(
additions[COMPRESSED_STATE_STATE] = new_state.state
if old_state.last_changed != new_state.last_changed:
additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed_timestamp
- elif old_state.last_updated != new_state.last_updated:
+ elif old_state.last_updated_timestamp != new_state.last_updated_timestamp:
additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated_timestamp
if old_state_context.parent_id != new_state_context.parent_id:
additions[COMPRESSED_STATE_CONTEXT] = {"parent_id": new_state_context.parent_id}
diff --git a/homeassistant/components/weheat/__init__.py b/homeassistant/components/weheat/__init__.py
index b67c3540dc5..15935f3e418 100644
--- a/homeassistant/components/weheat/__init__.py
+++ b/homeassistant/components/weheat/__init__.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import asyncio
from http import HTTPStatus
import aiohttp
@@ -18,7 +19,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
)
from .const import API_URL, LOGGER
-from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator
+from .coordinator import (
+ HeatPumpInfo,
+ WeheatConfigEntry,
+ WeheatData,
+ WeheatDataUpdateCoordinator,
+ WeheatEnergyUpdateCoordinator,
+)
PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -52,14 +59,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: WeheatConfigEntry) -> bo
except UnauthorizedException as error:
raise ConfigEntryAuthFailed from error
+ nr_of_pumps = len(discovered_heat_pumps)
+
for pump_info in discovered_heat_pumps:
LOGGER.debug("Adding %s", pump_info)
- # for each pump, add a coordinator
- new_coordinator = WeheatDataUpdateCoordinator(hass, entry, session, pump_info)
+ # for each pump, add the coordinators
- await new_coordinator.async_config_entry_first_refresh()
+ new_heat_pump = HeatPumpInfo(pump_info)
+ new_data_coordinator = WeheatDataUpdateCoordinator(
+ hass, entry, session, pump_info, nr_of_pumps
+ )
+ new_energy_coordinator = WeheatEnergyUpdateCoordinator(
+ hass, entry, session, pump_info
+ )
- entry.runtime_data.append(new_coordinator)
+ entry.runtime_data.append(
+ WeheatData(
+ heat_pump_info=new_heat_pump,
+ data_coordinator=new_data_coordinator,
+ energy_coordinator=new_energy_coordinator,
+ )
+ )
+
+ await asyncio.gather(
+ *[
+ data.data_coordinator.async_config_entry_first_refresh()
+ for data in entry.runtime_data
+ ],
+ *[
+ data.energy_coordinator.async_config_entry_first_refresh()
+ for data in entry.runtime_data
+ ],
+ )
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
diff --git a/homeassistant/components/weheat/binary_sensor.py b/homeassistant/components/weheat/binary_sensor.py
index 0ffa876ad0f..5e4c91fde60 100644
--- a/homeassistant/components/weheat/binary_sensor.py
+++ b/homeassistant/components/weheat/binary_sensor.py
@@ -11,10 +11,10 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
-from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator
+from .coordinator import HeatPumpInfo, WeheatConfigEntry, WeheatDataUpdateCoordinator
from .entity import WeheatEntity
# Coordinator is used to centralize the data updates
@@ -64,14 +64,18 @@ BINARY_SENSORS = [
async def async_setup_entry(
hass: HomeAssistant,
entry: WeheatConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors for weheat heat pump."""
entities = [
- WeheatHeatPumpBinarySensor(coordinator, entity_description)
+ WeheatHeatPumpBinarySensor(
+ weheatdata.heat_pump_info,
+ weheatdata.data_coordinator,
+ entity_description,
+ )
+ for weheatdata in entry.runtime_data
for entity_description in BINARY_SENSORS
- for coordinator in entry.runtime_data
- if entity_description.value_fn(coordinator.data) is not None
+ if entity_description.value_fn(weheatdata.data_coordinator.data) is not None
]
async_add_entities(entities)
@@ -80,20 +84,21 @@ async def async_setup_entry(
class WeheatHeatPumpBinarySensor(WeheatEntity, BinarySensorEntity):
"""Defines a Weheat heat pump binary sensor."""
+ heat_pump_info: HeatPumpInfo
coordinator: WeheatDataUpdateCoordinator
entity_description: WeHeatBinarySensorEntityDescription
def __init__(
self,
+ heat_pump_info: HeatPumpInfo,
coordinator: WeheatDataUpdateCoordinator,
entity_description: WeHeatBinarySensorEntityDescription,
) -> None:
"""Pass coordinator to CoordinatorEntity."""
- super().__init__(coordinator)
-
+ super().__init__(heat_pump_info, coordinator)
self.entity_description = entity_description
- self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}"
+ self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}"
@property
def is_on(self) -> bool | None:
diff --git a/homeassistant/components/weheat/const.py b/homeassistant/components/weheat/const.py
index e33fd983572..cd521afd2ea 100644
--- a/homeassistant/components/weheat/const.py
+++ b/homeassistant/components/weheat/const.py
@@ -17,10 +17,12 @@ API_URL = "https://api.weheat.nl"
OAUTH2_SCOPES = ["openid", "offline_access"]
-UPDATE_INTERVAL = 30
+LOG_UPDATE_INTERVAL = 120
+ENERGY_UPDATE_INTERVAL = 1800
LOGGER: Logger = getLogger(__package__)
DISPLAY_PRECISION_WATTS = 0
DISPLAY_PRECISION_COP = 1
DISPLAY_PRECISION_WATER_TEMP = 1
+DISPLAY_PRECISION_FLOW = 1
diff --git a/homeassistant/components/weheat/coordinator.py b/homeassistant/components/weheat/coordinator.py
index d7e53258e9b..30ca61d0387 100644
--- a/homeassistant/components/weheat/coordinator.py
+++ b/homeassistant/components/weheat/coordinator.py
@@ -1,5 +1,6 @@
"""Define a custom coordinator for the Weheat heatpump integration."""
+from dataclasses import dataclass
from datetime import timedelta
from weheat.abstractions.discovery import HeatPumpDiscovery
@@ -10,6 +11,7 @@ from weheat.exceptions import (
ForbiddenException,
NotFoundException,
ServiceException,
+ TooManyRequestsException,
UnauthorizedException,
)
@@ -21,7 +23,9 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from .const import API_URL, DOMAIN, LOGGER, UPDATE_INTERVAL
+from .const import API_URL, DOMAIN, ENERGY_UPDATE_INTERVAL, LOG_UPDATE_INTERVAL, LOGGER
+
+type WeheatConfigEntry = ConfigEntry[list[WeheatData]]
EXCEPTIONS = (
ServiceException,
@@ -29,9 +33,43 @@ EXCEPTIONS = (
ForbiddenException,
BadRequestException,
ApiException,
+ TooManyRequestsException,
)
-type WeheatConfigEntry = ConfigEntry[list[WeheatDataUpdateCoordinator]]
+
+class HeatPumpInfo(HeatPumpDiscovery.HeatPumpInfo):
+ """Heat pump info with additional properties."""
+
+ def __init__(self, pump_info: HeatPumpDiscovery.HeatPumpInfo) -> None:
+ """Initialize the HeatPump object with the provided pump information.
+
+ Args:
+ pump_info (HeatPumpDiscovery.HeatPumpInfo): An object containing the heat pump's discovery information, including:
+ - uuid (str): Unique identifier for the heat pump.
+ - uuid (str): Unique identifier for the heat pump.
+ - device_name (str): Name of the heat pump device.
+ - model (str): Model of the heat pump.
+ - sn (str): Serial number of the heat pump.
+ - has_dhw (bool): Indicates if the heat pump has domestic hot water functionality.
+
+ """
+ super().__init__(
+ pump_info.uuid,
+ pump_info.device_name,
+ pump_info.model,
+ pump_info.sn,
+ pump_info.has_dhw,
+ )
+
+ @property
+ def readable_name(self) -> str | None:
+ """Return the readable name of the heat pump."""
+ return self.device_name if self.device_name else self.model
+
+ @property
+ def heatpump_id(self) -> str:
+ """Return the heat pump id."""
+ return self.uuid
class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]):
@@ -45,45 +83,28 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]):
config_entry: WeheatConfigEntry,
session: OAuth2Session,
heat_pump: HeatPumpDiscovery.HeatPumpInfo,
+ nr_of_heat_pumps: int,
) -> None:
"""Initialize the data coordinator."""
super().__init__(
hass,
- logger=LOGGER,
config_entry=config_entry,
+ logger=LOGGER,
name=DOMAIN,
- update_interval=timedelta(seconds=UPDATE_INTERVAL),
+ update_interval=timedelta(seconds=LOG_UPDATE_INTERVAL * nr_of_heat_pumps),
)
- self.heat_pump_info = heat_pump
self._heat_pump_data = HeatPump(
API_URL, heat_pump.uuid, async_get_clientsession(hass)
)
self.session = session
- @property
- def heatpump_id(self) -> str:
- """Return the heat pump id."""
- return self.heat_pump_info.uuid
-
- @property
- def readable_name(self) -> str | None:
- """Return the readable name of the heat pump."""
- if self.heat_pump_info.name:
- return self.heat_pump_info.name
- return self.heat_pump_info.model
-
- @property
- def model(self) -> str:
- """Return the model of the heat pump."""
- return self.heat_pump_info.model
-
async def _async_update_data(self) -> HeatPump:
"""Fetch data from the API."""
await self.session.async_ensure_token_valid()
try:
- await self._heat_pump_data.async_get_status(
+ await self._heat_pump_data.async_get_logs(
self.session.token[CONF_ACCESS_TOKEN]
)
except UnauthorizedException as error:
@@ -92,3 +113,54 @@ class WeheatDataUpdateCoordinator(DataUpdateCoordinator[HeatPump]):
raise UpdateFailed(error) from error
return self._heat_pump_data
+
+
+class WeheatEnergyUpdateCoordinator(DataUpdateCoordinator[HeatPump]):
+ """A custom Energy coordinator for the Weheat heatpump integration."""
+
+ config_entry: WeheatConfigEntry
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: WeheatConfigEntry,
+ session: OAuth2Session,
+ heat_pump: HeatPumpDiscovery.HeatPumpInfo,
+ ) -> None:
+ """Initialize the data coordinator."""
+ super().__init__(
+ hass,
+ config_entry=config_entry,
+ logger=LOGGER,
+ name=DOMAIN,
+ update_interval=timedelta(seconds=ENERGY_UPDATE_INTERVAL),
+ )
+ self._heat_pump_data = HeatPump(
+ API_URL, heat_pump.uuid, async_get_clientsession(hass)
+ )
+
+ self.session = session
+
+ async def _async_update_data(self) -> HeatPump:
+ """Fetch data from the API."""
+ await self.session.async_ensure_token_valid()
+
+ try:
+ await self._heat_pump_data.async_get_energy(
+ self.session.token[CONF_ACCESS_TOKEN]
+ )
+ except UnauthorizedException as error:
+ raise ConfigEntryAuthFailed from error
+ except EXCEPTIONS as error:
+ raise UpdateFailed(error) from error
+
+ return self._heat_pump_data
+
+
+@dataclass
+class WeheatData:
+ """Data for the Weheat integration."""
+
+ heat_pump_info: HeatPumpInfo
+ data_coordinator: WeheatDataUpdateCoordinator
+ energy_coordinator: WeheatEnergyUpdateCoordinator
diff --git a/homeassistant/components/weheat/entity.py b/homeassistant/components/weheat/entity.py
index 079db596e19..7a12b2edcfa 100644
--- a/homeassistant/components/weheat/entity.py
+++ b/homeassistant/components/weheat/entity.py
@@ -3,25 +3,30 @@
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
+from . import HeatPumpInfo
from .const import DOMAIN, MANUFACTURER
-from .coordinator import WeheatDataUpdateCoordinator
+from .coordinator import WeheatDataUpdateCoordinator, WeheatEnergyUpdateCoordinator
-class WeheatEntity(CoordinatorEntity[WeheatDataUpdateCoordinator]):
+class WeheatEntity[
+ _WeheatEntityT: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator
+](CoordinatorEntity[_WeheatEntityT]):
"""Defines a base Weheat entity."""
_attr_has_entity_name = True
def __init__(
self,
- coordinator: WeheatDataUpdateCoordinator,
+ heat_pump_info: HeatPumpInfo,
+ coordinator: _WeheatEntityT,
) -> None:
"""Initialize the Weheat entity."""
super().__init__(coordinator)
+ self.heat_pump_info = heat_pump_info
self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, coordinator.heatpump_id)},
- name=coordinator.readable_name,
+ identifiers={(DOMAIN, heat_pump_info.heatpump_id)},
+ name=heat_pump_info.readable_name,
manufacturer=MANUFACTURER,
- model=coordinator.model,
+ model=heat_pump_info.model,
)
diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json
index e7f54b478c6..c0955cd051d 100644
--- a/homeassistant/components/weheat/icons.json
+++ b/homeassistant/components/weheat/icons.json
@@ -42,6 +42,12 @@
"heat_pump_state": {
"default": "mdi:state-machine"
},
+ "dhw_flow_volume": {
+ "default": "mdi:pump"
+ },
+ "central_heating_flow_volume": {
+ "default": "mdi:pump"
+ },
"electricity_used": {
"default": "mdi:flash"
},
diff --git a/homeassistant/components/weheat/manifest.json b/homeassistant/components/weheat/manifest.json
index 1d60f66afba..3a4cff6f295 100644
--- a/homeassistant/components/weheat/manifest.json
+++ b/homeassistant/components/weheat/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/weheat",
"iot_class": "cloud_polling",
- "requirements": ["weheat==2025.1.15"]
+ "requirements": ["weheat==2025.3.7"]
}
diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py
index 5d948c6d565..8ff80aeac08 100644
--- a/homeassistant/components/weheat/sensor.py
+++ b/homeassistant/components/weheat/sensor.py
@@ -17,17 +17,24 @@ from homeassistant.const import (
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
+ UnitOfVolumeFlowRate,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import (
DISPLAY_PRECISION_COP,
+ DISPLAY_PRECISION_FLOW,
DISPLAY_PRECISION_WATER_TEMP,
DISPLAY_PRECISION_WATTS,
)
-from .coordinator import WeheatConfigEntry, WeheatDataUpdateCoordinator
+from .coordinator import (
+ HeatPumpInfo,
+ WeheatConfigEntry,
+ WeheatDataUpdateCoordinator,
+ WeheatEnergyUpdateCoordinator,
+)
from .entity import WeheatEntity
# Coordinator is used to centralize the data updates
@@ -142,22 +149,6 @@ SENSORS = [
else None
),
),
- WeHeatSensorEntityDescription(
- translation_key="electricity_used",
- key="electricity_used",
- native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
- device_class=SensorDeviceClass.ENERGY,
- state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda status: status.energy_total,
- ),
- WeHeatSensorEntityDescription(
- translation_key="energy_output",
- key="energy_output",
- native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
- device_class=SensorDeviceClass.ENERGY,
- state_class=SensorStateClass.TOTAL_INCREASING,
- value_fn=lambda status: status.energy_output,
- ),
WeHeatSensorEntityDescription(
translation_key="compressor_rpm",
key="compressor_rpm",
@@ -172,9 +163,17 @@ SENSORS = [
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda status: status.compressor_percentage,
),
+ WeHeatSensorEntityDescription(
+ translation_key="central_heating_flow_volume",
+ key="central_heating_flow_volume",
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=DISPLAY_PRECISION_FLOW,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
+ value_fn=lambda status: status.central_heating_flow_volume,
+ ),
]
-
DHW_SENSORS = [
WeHeatSensorEntityDescription(
translation_key="dhw_top_temperature",
@@ -194,26 +193,76 @@ DHW_SENSORS = [
suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP,
value_fn=lambda status: status.dhw_bottom_temperature,
),
+ WeHeatSensorEntityDescription(
+ translation_key="dhw_flow_volume",
+ key="dhw_flow_volume",
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ state_class=SensorStateClass.MEASUREMENT,
+ suggested_display_precision=DISPLAY_PRECISION_FLOW,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
+ value_fn=lambda status: status.dhw_flow_volume,
+ ),
+]
+
+ENERGY_SENSORS = [
+ WeHeatSensorEntityDescription(
+ translation_key="electricity_used",
+ key="electricity_used",
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda status: status.energy_total,
+ ),
+ WeHeatSensorEntityDescription(
+ translation_key="energy_output",
+ key="energy_output",
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ device_class=SensorDeviceClass.ENERGY,
+ state_class=SensorStateClass.TOTAL_INCREASING,
+ value_fn=lambda status: status.energy_output,
+ ),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: WeheatConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensors for weheat heat pump."""
- entities = [
- WeheatHeatPumpSensor(coordinator, entity_description)
- for entity_description in SENSORS
- for coordinator in entry.runtime_data
- ]
- entities.extend(
- WeheatHeatPumpSensor(coordinator, entity_description)
- for entity_description in DHW_SENSORS
- for coordinator in entry.runtime_data
- if coordinator.heat_pump_info.has_dhw
- )
+
+ entities: list[WeheatHeatPumpSensor] = []
+ for weheatdata in entry.runtime_data:
+ entities.extend(
+ WeheatHeatPumpSensor(
+ weheatdata.heat_pump_info,
+ weheatdata.data_coordinator,
+ entity_description,
+ )
+ for entity_description in SENSORS
+ if entity_description.value_fn(weheatdata.data_coordinator.data) is not None
+ )
+ if weheatdata.heat_pump_info.has_dhw:
+ entities.extend(
+ WeheatHeatPumpSensor(
+ weheatdata.heat_pump_info,
+ weheatdata.data_coordinator,
+ entity_description,
+ )
+ for entity_description in DHW_SENSORS
+ if entity_description.value_fn(weheatdata.data_coordinator.data)
+ is not None
+ )
+ entities.extend(
+ WeheatHeatPumpSensor(
+ weheatdata.heat_pump_info,
+ weheatdata.energy_coordinator,
+ entity_description,
+ )
+ for entity_description in ENERGY_SENSORS
+ if entity_description.value_fn(weheatdata.energy_coordinator.data)
+ is not None
+ )
async_add_entities(entities)
@@ -221,20 +270,21 @@ async def async_setup_entry(
class WeheatHeatPumpSensor(WeheatEntity, SensorEntity):
"""Defines a Weheat heat pump sensor."""
- coordinator: WeheatDataUpdateCoordinator
+ heat_pump_info: HeatPumpInfo
+ coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator
entity_description: WeHeatSensorEntityDescription
def __init__(
self,
- coordinator: WeheatDataUpdateCoordinator,
+ heat_pump_info: HeatPumpInfo,
+ coordinator: WeheatDataUpdateCoordinator | WeheatEnergyUpdateCoordinator,
entity_description: WeHeatSensorEntityDescription,
) -> None:
"""Pass coordinator to CoordinatorEntity."""
- super().__init__(coordinator)
-
+ super().__init__(heat_pump_info, coordinator)
self.entity_description = entity_description
- self._attr_unique_id = f"{coordinator.heatpump_id}_{entity_description.key}"
+ self._attr_unique_id = f"{heat_pump_info.heatpump_id}_{entity_description.key}"
@property
def native_value(self) -> StateType:
diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json
index 2a208c2f8ca..b02389e7f4f 100644
--- a/homeassistant/components/weheat/strings.json
+++ b/homeassistant/components/weheat/strings.json
@@ -37,7 +37,7 @@
"name": "Indoor unit water pump"
},
"indoor_unit_auxiliary_pump_state": {
- "name": "Indoor unit auxilary water pump"
+ "name": "Indoor unit auxiliary water pump"
},
"indoor_unit_dhw_valve_or_pump_state": {
"name": "Indoor unit DHW valve or water pump"
@@ -86,6 +86,12 @@
"dhw_bottom_temperature": {
"name": "DHW bottom temperature"
},
+ "dhw_flow_volume": {
+ "name": "DHW pump flow"
+ },
+ "central_heating_flow_volume": {
+ "name": "Central heating pump flow"
+ },
"heat_pump_state": {
"state": {
"standby": "[%key:common::state::standby%]",
diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py
index f2bcb04d96f..4ed361b18ba 100644
--- a/homeassistant/components/wemo/binary_sensor.py
+++ b/homeassistant/components/wemo/binary_sensor.py
@@ -5,7 +5,7 @@ from pywemo import Insight, Maker, StandbyState
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import async_wemo_dispatcher_connect
from .coordinator import DeviceCoordinator
@@ -15,7 +15,7 @@ from .entity import WemoBinaryStateEntity, WemoEntity
async def async_setup_entry(
hass: HomeAssistant,
_config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WeMo binary sensors."""
diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py
index 42dae679aa5..edfdfc1c78c 100644
--- a/homeassistant/components/wemo/fan.py
+++ b/homeassistant/components/wemo/fan.py
@@ -13,7 +13,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.util.percentage import (
percentage_to_ranged_value,
@@ -48,7 +48,7 @@ SET_HUMIDITY_SCHEMA: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
_config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WeMo binary sensors."""
diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py
index 619e0952457..838073be84a 100644
--- a/homeassistant/components/wemo/light.py
+++ b/homeassistant/components/wemo/light.py
@@ -20,7 +20,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from . import async_wemo_dispatcher_connect
@@ -35,7 +35,7 @@ WEMO_OFF = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WeMo lights."""
@@ -53,7 +53,7 @@ async def async_setup_entry(
def async_setup_bridge(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
coordinator: DeviceCoordinator,
) -> None:
"""Set up a WeMo link."""
diff --git a/homeassistant/components/wemo/sensor.py b/homeassistant/components/wemo/sensor.py
index 90e3546eaf7..76a0265d7da 100644
--- a/homeassistant/components/wemo/sensor.py
+++ b/homeassistant/components/wemo/sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import async_wemo_dispatcher_connect
@@ -59,7 +59,7 @@ ATTRIBUTE_SENSORS = (
async def async_setup_entry(
hass: HomeAssistant,
_config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WeMo sensors."""
diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py
index 3f7bb08b704..7b87b3147d0 100644
--- a/homeassistant/components/wemo/switch.py
+++ b/homeassistant/components/wemo/switch.py
@@ -11,7 +11,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import async_wemo_dispatcher_connect
from .coordinator import DeviceCoordinator
@@ -36,7 +36,7 @@ MAKER_SWITCH_TOGGLE = "toggle"
async def async_setup_entry(
hass: HomeAssistant,
_config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WeMo switches."""
diff --git a/homeassistant/components/whirlpool/__init__.py b/homeassistant/components/whirlpool/__init__.py
index 6231324bb0d..fec26f03691 100644
--- a/homeassistant/components/whirlpool/__init__.py
+++ b/homeassistant/components/whirlpool/__init__.py
@@ -1,6 +1,5 @@
"""The Whirlpool Appliances integration."""
-from dataclasses import dataclass
import logging
from aiohttp import ClientError
@@ -20,13 +19,11 @@ _LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
-type WhirlpoolConfigEntry = ConfigEntry[WhirlpoolData]
+type WhirlpoolConfigEntry = ConfigEntry[AppliancesManager]
async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool:
"""Set up Whirlpool Sixth Sense from a config entry."""
- hass.data.setdefault(DOMAIN, {})
-
session = async_get_clientsession(hass)
region = CONF_REGIONS_MAP[entry.data.get(CONF_REGION, "EU")]
brand = CONF_BRANDS_MAP[entry.data.get(CONF_BRAND, "Whirlpool")]
@@ -52,8 +49,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) ->
if not await appliances_manager.fetch_appliances():
_LOGGER.error("Cannot fetch appliances")
return False
+ await appliances_manager.connect()
- entry.runtime_data = WhirlpoolData(appliances_manager, auth, backend_selector)
+ entry.runtime_data = appliances_manager
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -61,13 +59,5 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) ->
async def async_unload_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) -> bool:
"""Unload a config entry."""
+ await entry.runtime_data.disconnect()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
-
-
-@dataclass
-class WhirlpoolData:
- """Whirlpool integaration shared data."""
-
- appliances_manager: AppliancesManager
- auth: Auth
- backend_selector: BackendSelector
diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py
index 943c5d1c956..0cc9e8bca84 100644
--- a/homeassistant/components/whirlpool/climate.py
+++ b/homeassistant/components/whirlpool/climate.py
@@ -2,16 +2,11 @@
from __future__ import annotations
-import logging
from typing import Any
-from aiohttp import ClientSession
from whirlpool.aircon import Aircon, FanSpeed as AirconFanSpeed, Mode as AirconMode
-from whirlpool.auth import Auth
-from whirlpool.backendselector import BackendSelector
from homeassistant.components.climate import (
- ENTITY_ID_FORMAT,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
@@ -25,16 +20,10 @@ from homeassistant.components.climate import (
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity import generate_entity_id
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WhirlpoolConfigEntry
-from .const import DOMAIN
-
-_LOGGER = logging.getLogger(__name__)
-
+from .entity import WhirlpoolEntity
AIRCON_MODE_MAP = {
AirconMode.Cool: HVACMode.COOL,
@@ -70,35 +59,22 @@ SUPPORTED_TARGET_TEMPERATURE_STEP = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: WhirlpoolConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry."""
- whirlpool_data = config_entry.runtime_data
-
- aircons = [
- AirConEntity(
- hass,
- ac_data["SAID"],
- ac_data["NAME"],
- whirlpool_data.backend_selector,
- whirlpool_data.auth,
- async_get_clientsession(hass),
- )
- for ac_data in whirlpool_data.appliances_manager.aircons
- ]
- async_add_entities(aircons, True)
+ appliances_manager = config_entry.runtime_data
+ aircons = [AirConEntity(hass, aircon) for aircon in appliances_manager.aircons]
+ async_add_entities(aircons)
-class AirConEntity(ClimateEntity):
+class AirConEntity(WhirlpoolEntity, ClimateEntity):
"""Representation of an air conditioner."""
_attr_fan_modes = SUPPORTED_FAN_MODES
- _attr_has_entity_name = True
_attr_name = None
_attr_hvac_modes = SUPPORTED_HVAC_MODES
_attr_max_temp = SUPPORTED_MAX_TEMP
_attr_min_temp = SUPPORTED_MIN_TEMP
- _attr_should_poll = False
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.FAN_MODE
@@ -110,41 +86,10 @@ class AirConEntity(ClimateEntity):
_attr_target_temperature_step = SUPPORTED_TARGET_TEMPERATURE_STEP
_attr_temperature_unit = UnitOfTemperature.CELSIUS
- def __init__(
- self,
- hass: HomeAssistant,
- said: str,
- name: str | None,
- backend_selector: BackendSelector,
- auth: Auth,
- session: ClientSession,
- ) -> None:
+ def __init__(self, hass: HomeAssistant, aircon: Aircon) -> None:
"""Initialize the entity."""
- self._aircon = Aircon(backend_selector, auth, said, session)
- self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, said, hass=hass)
- self._attr_unique_id = said
-
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, said)},
- name=name if name is not None else said,
- manufacturer="Whirlpool",
- model="Sixth Sense",
- )
-
- async def async_added_to_hass(self) -> None:
- """Connect aircon to the cloud."""
- self._aircon.register_attr_callback(self.async_write_ha_state)
- await self._aircon.connect()
-
- async def async_will_remove_from_hass(self) -> None:
- """Close Whrilpool Appliance sockets before removing."""
- self._aircon.unregister_attr_callback(self.async_write_ha_state)
- await self._aircon.disconnect()
-
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self._aircon.get_online()
+ super().__init__(aircon)
+ self._aircon = aircon
@property
def current_temperature(self) -> float:
diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py
index 87d6ea827e2..09338396de4 100644
--- a/homeassistant/components/whirlpool/diagnostics.py
+++ b/homeassistant/components/whirlpool/diagnostics.py
@@ -4,6 +4,8 @@ from __future__ import annotations
from typing import Any
+from whirlpool.appliance import Appliance
+
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
@@ -26,18 +28,25 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
- whirlpool = config_entry.runtime_data
+ def get_appliance_diagnostics(appliance: Appliance) -> dict[str, Any]:
+ return {
+ "data_model": appliance.appliance_info.data_model,
+ "category": appliance.appliance_info.category,
+ "model_number": appliance.appliance_info.model_number,
+ }
+
+ appliances_manager = config_entry.runtime_data
diagnostics_data = {
- "Washer_dryers": {
- wd["NAME"]: dict(wd.items())
- for wd in whirlpool.appliances_manager.washer_dryers
+ "washer_dryers": {
+ wd.name: get_appliance_diagnostics(wd)
+ for wd in appliances_manager.washer_dryers
},
"aircons": {
- ac["NAME"]: dict(ac.items()) for ac in whirlpool.appliances_manager.aircons
+ ac.name: get_appliance_diagnostics(ac) for ac in appliances_manager.aircons
},
"ovens": {
- oven["NAME"]: dict(oven.items())
- for oven in whirlpool.appliances_manager.ovens
+ oven.name: get_appliance_diagnostics(oven)
+ for oven in appliances_manager.ovens
},
}
diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py
new file mode 100644
index 00000000000..a53fe0af263
--- /dev/null
+++ b/homeassistant/components/whirlpool/entity.py
@@ -0,0 +1,40 @@
+"""Base entity for the Whirlpool integration."""
+
+from whirlpool.appliance import Appliance
+
+from homeassistant.helpers.device_registry import DeviceInfo
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN
+
+
+class WhirlpoolEntity(Entity):
+ """Base class for Whirlpool entities."""
+
+ _attr_has_entity_name = True
+ _attr_should_poll = False
+
+ def __init__(self, appliance: Appliance, unique_id_suffix: str = "") -> None:
+ """Initialize the entity."""
+ self._appliance = appliance
+
+ self._attr_device_info = DeviceInfo(
+ identifiers={(DOMAIN, appliance.said)},
+ name=appliance.name.capitalize() if appliance.name else appliance.said,
+ manufacturer="Whirlpool",
+ model_id=appliance.appliance_info.model_number,
+ )
+ self._attr_unique_id = f"{appliance.said}{unique_id_suffix}"
+
+ async def async_added_to_hass(self) -> None:
+ """Register attribute updates callback."""
+ self._appliance.register_attr_callback(self.async_write_ha_state)
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Unregister attribute updates callback."""
+ self._appliance.unregister_attr_callback(self.async_write_ha_state)
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._appliance.get_online()
diff --git a/homeassistant/components/whirlpool/icons.json b/homeassistant/components/whirlpool/icons.json
new file mode 100644
index 00000000000..574b491090e
--- /dev/null
+++ b/homeassistant/components/whirlpool/icons.json
@@ -0,0 +1,12 @@
+{
+ "entity": {
+ "sensor": {
+ "washer_state": {
+ "default": "mdi:washing-machine"
+ },
+ "dryer_state": {
+ "default": "mdi:tumble-dryer"
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json
index 67901eea482..be47ab619e9 100644
--- a/homeassistant/components/whirlpool/manifest.json
+++ b/homeassistant/components/whirlpool/manifest.json
@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["whirlpool"],
- "requirements": ["whirlpool-sixth-sense==0.18.12"]
+ "requirements": ["whirlpool-sixth-sense==0.20.0"]
}
diff --git a/homeassistant/components/whirlpool/quality_scale.yaml b/homeassistant/components/whirlpool/quality_scale.yaml
new file mode 100644
index 00000000000..dafaf25012b
--- /dev/null
+++ b/homeassistant/components/whirlpool/quality_scale.yaml
@@ -0,0 +1,92 @@
+rules:
+ # Bronze
+ action-setup:
+ status: exempt
+ comment: |
+ The integration does not provide any additional 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: |
+ This integration does not provide additional actions.
+ docs-high-level-description: done
+ docs-installation-instructions: done
+ docs-removal-instructions: done
+ entity-event-setup: done
+ entity-unique-id: done
+ has-entity-name: done
+ runtime-data: done
+ test-before-configure: done
+ test-before-setup:
+ status: todo
+ comment: |
+ When fetch_appliances fails, ConfigEntryNotReady should be raised.
+ unique-config-entry: done
+ # Silver
+ action-exceptions:
+ status: todo
+ comment: |
+ - The calls to the api can be changed to return bool, and services can then raise HomeAssistantError
+ - Current services raise ValueError and should raise ServiceValidationError instead.
+ config-entry-unloading: done
+ docs-configuration-parameters:
+ status: exempt
+ comment: Integration has no configuration parameters
+ docs-installation-parameters: todo
+ entity-unavailable: done
+ integration-owner: done
+ log-when-unavailable: todo
+ parallel-updates: todo
+ reauthentication-flow: done
+ test-coverage:
+ status: todo
+ comment: |
+ - Test helper init_integration() does not set a unique_id
+ - Merge test_setup_http_exception and test_setup_auth_account_locked
+ - The climate platform is at 94%
+
+ # Gold
+ devices: done
+ diagnostics: done
+ discovery-update-info:
+ status: exempt
+ comment: |
+ This integration is a cloud service and thus does not support discovery.
+ discovery:
+ status: exempt
+ comment: |
+ This integration is a cloud service and thus does not support discovery.
+ docs-data-update: todo
+ docs-examples: todo
+ docs-known-limitations: todo
+ docs-supported-devices: done
+ docs-supported-functions: done
+ docs-troubleshooting: todo
+ docs-use-cases: todo
+ dynamic-devices: todo
+ entity-category: done
+ entity-device-class:
+ status: todo
+ comment: The "unknown" state should not be part of the enum for the dispense level sensor.
+ entity-disabled-by-default: done
+ entity-translations: done
+ exception-translations: todo
+ icon-translations:
+ status: todo
+ comment: |
+ Time remaining sensor still has hardcoded icon.
+ reconfiguration-flow: todo
+ repair-issues:
+ status: exempt
+ comment: No known use cases for repair issues or flows, yet
+ stale-devices: todo
+
+ # Platinum
+ async-dependency: done
+ inject-websession: done
+ strict-typing: todo
diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py
index 9180164c272..60dd215ebb5 100644
--- a/homeassistant/components/whirlpool/sensor.py
+++ b/homeassistant/components/whirlpool/sensor.py
@@ -1,12 +1,11 @@
"""The Washer/Dryer Sensor for Whirlpool Appliances."""
-from __future__ import annotations
-
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
-import logging
+from typing import override
+from whirlpool.appliance import Appliance
from whirlpool.washerdryer import MachineState, WasherDryer
from homeassistant.components.sensor import (
@@ -15,26 +14,26 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
)
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
from . import WhirlpoolConfigEntry
-from .const import DOMAIN
+from .entity import WhirlpoolEntity
-TANK_FILL = {
- "0": "unknown",
- "1": "empty",
- "2": "25",
- "3": "50",
- "4": "100",
- "5": "active",
+SCAN_INTERVAL = timedelta(minutes=5)
+
+WASHER_TANK_FILL = {
+ 0: "unknown",
+ 1: "empty",
+ 2: "25",
+ 3: "50",
+ 4: "100",
+ 5: "active",
}
-MACHINE_STATE = {
+WASHER_DRYER_MACHINE_STATE = {
MachineState.Standby: "standby",
MachineState.Setting: "setting",
MachineState.DelayCountdownMode: "delay_countdown",
@@ -56,75 +55,92 @@ MACHINE_STATE = {
MachineState.SystemInit: "system_initialize",
}
-CYCLE_FUNC = [
- (WasherDryer.get_cycle_status_filling, "cycle_filling"),
- (WasherDryer.get_cycle_status_rinsing, "cycle_rinsing"),
- (WasherDryer.get_cycle_status_sensing, "cycle_sensing"),
- (WasherDryer.get_cycle_status_soaking, "cycle_soaking"),
- (WasherDryer.get_cycle_status_spinning, "cycle_spinning"),
- (WasherDryer.get_cycle_status_washing, "cycle_washing"),
-]
-
-DOOR_OPEN = "door_open"
-ICON_D = "mdi:tumble-dryer"
-ICON_W = "mdi:washing-machine"
-
-_LOGGER = logging.getLogger(__name__)
-SCAN_INTERVAL = timedelta(minutes=5)
+STATE_CYCLE_FILLING = "cycle_filling"
+STATE_CYCLE_RINSING = "cycle_rinsing"
+STATE_CYCLE_SENSING = "cycle_sensing"
+STATE_CYCLE_SOAKING = "cycle_soaking"
+STATE_CYCLE_SPINNING = "cycle_spinning"
+STATE_CYCLE_WASHING = "cycle_washing"
+STATE_DOOR_OPEN = "door_open"
-def washer_state(washer: WasherDryer) -> str | None:
- """Determine correct states for a washer."""
+def washer_dryer_state(washer_dryer: WasherDryer) -> str | None:
+ """Determine correct states for a washer/dryer."""
- if washer.get_attribute("Cavity_OpStatusDoorOpen") == "1":
- return DOOR_OPEN
+ if washer_dryer.get_door_open():
+ return STATE_DOOR_OPEN
- machine_state = washer.get_machine_state()
+ machine_state = washer_dryer.get_machine_state()
if machine_state == MachineState.RunningMainCycle:
- for func, cycle_name in CYCLE_FUNC:
- if func(washer):
- return cycle_name
+ if washer_dryer.get_cycle_status_filling():
+ return STATE_CYCLE_FILLING
+ if washer_dryer.get_cycle_status_rinsing():
+ return STATE_CYCLE_RINSING
+ if washer_dryer.get_cycle_status_sensing():
+ return STATE_CYCLE_SENSING
+ if washer_dryer.get_cycle_status_soaking():
+ return STATE_CYCLE_SOAKING
+ if washer_dryer.get_cycle_status_spinning():
+ return STATE_CYCLE_SPINNING
+ if washer_dryer.get_cycle_status_washing():
+ return STATE_CYCLE_WASHING
- return MACHINE_STATE.get(machine_state)
+ return WASHER_DRYER_MACHINE_STATE.get(machine_state)
@dataclass(frozen=True, kw_only=True)
class WhirlpoolSensorEntityDescription(SensorEntityDescription):
- """Describes Whirlpool Washer sensor entity."""
+ """Describes a Whirlpool sensor entity."""
- value_fn: Callable
+ value_fn: Callable[[Appliance], str | None]
-SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = (
+WASHER_DRYER_STATE_OPTIONS = [
+ *WASHER_DRYER_MACHINE_STATE.values(),
+ STATE_CYCLE_FILLING,
+ STATE_CYCLE_RINSING,
+ STATE_CYCLE_SENSING,
+ STATE_CYCLE_SOAKING,
+ STATE_CYCLE_SPINNING,
+ STATE_CYCLE_WASHING,
+ STATE_DOOR_OPEN,
+]
+
+WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = (
WhirlpoolSensorEntityDescription(
key="state",
- translation_key="whirlpool_machine",
+ translation_key="washer_state",
device_class=SensorDeviceClass.ENUM,
- options=(
- list(MACHINE_STATE.values())
- + [value for _, value in CYCLE_FUNC]
- + [DOOR_OPEN]
- ),
- value_fn=washer_state,
+ options=WASHER_DRYER_STATE_OPTIONS,
+ value_fn=washer_dryer_state,
),
WhirlpoolSensorEntityDescription(
key="DispenseLevel",
translation_key="whirlpool_tank",
entity_registry_enabled_default=False,
device_class=SensorDeviceClass.ENUM,
- options=list(TANK_FILL.values()),
- value_fn=lambda WasherDryer: TANK_FILL.get(
- WasherDryer.get_attribute("WashCavity_OpStatusBulkDispense1Level")
- ),
+ options=list(WASHER_TANK_FILL.values()),
+ value_fn=lambda washer: WASHER_TANK_FILL.get(washer.get_dispense_1_level()),
),
)
-SENSOR_TIMER: tuple[SensorEntityDescription] = (
+DRYER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = (
+ WhirlpoolSensorEntityDescription(
+ key="state",
+ translation_key="dryer_state",
+ device_class=SensorDeviceClass.ENUM,
+ options=WASHER_DRYER_STATE_OPTIONS,
+ value_fn=washer_dryer_state,
+ ),
+)
+
+WASHER_DRYER_TIME_SENSORS: tuple[SensorEntityDescription] = (
SensorEntityDescription(
key="timeremaining",
translation_key="end_time",
device_class=SensorDeviceClass.TIMESTAMP,
+ icon="mdi:progress-clock",
),
)
@@ -132,146 +148,75 @@ SENSOR_TIMER: tuple[SensorEntityDescription] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: WhirlpoolConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
- """Config flow entry for Whrilpool Laundry."""
+ """Config flow entry for Whirlpool sensors."""
entities: list = []
- whirlpool_data = config_entry.runtime_data
- for appliance in whirlpool_data.appliances_manager.washer_dryers:
- _wd = WasherDryer(
- whirlpool_data.backend_selector,
- whirlpool_data.auth,
- appliance["SAID"],
- async_get_clientsession(hass),
+ appliances_manager = config_entry.runtime_data
+ for washer_dryer in appliances_manager.washer_dryers:
+ sensor_descriptions = (
+ DRYER_SENSORS
+ if "dryer" in washer_dryer.appliance_info.data_model.lower()
+ else WASHER_SENSORS
)
- await _wd.connect()
entities.extend(
- [
- WasherDryerClass(
- appliance["SAID"],
- appliance["NAME"],
- description,
- _wd,
- )
- for description in SENSORS
- ]
+ WhirlpoolSensor(washer_dryer, description)
+ for description in sensor_descriptions
)
entities.extend(
- [
- WasherDryerTimeClass(
- appliance["SAID"],
- appliance["NAME"],
- description,
- _wd,
- )
- for description in SENSOR_TIMER
- ]
+ WasherDryerTimeSensor(washer_dryer, description)
+ for description in WASHER_DRYER_TIME_SENSORS
)
async_add_entities(entities)
-class WasherDryerClass(SensorEntity):
- """A class for the whirlpool/maytag washer account."""
-
- _attr_should_poll = False
- _attr_has_entity_name = True
+class WhirlpoolSensor(WhirlpoolEntity, SensorEntity):
+ """A class for the Whirlpool sensors."""
def __init__(
- self,
- said: str,
- name: str,
- description: WhirlpoolSensorEntityDescription,
- washdry: WasherDryer,
+ self, appliance: Appliance, description: WhirlpoolSensorEntityDescription
) -> None:
"""Initialize the washer sensor."""
- self._wd: WasherDryer = washdry
-
- if name == "dryer":
- self._attr_icon = ICON_D
- else:
- self._attr_icon = ICON_W
-
+ super().__init__(appliance, unique_id_suffix=f"-{description.key}")
self.entity_description: WhirlpoolSensorEntityDescription = description
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, said)},
- name=name.capitalize(),
- manufacturer="Whirlpool",
- )
- self._attr_unique_id = f"{said}-{description.key}"
-
- async def async_added_to_hass(self) -> None:
- """Connect washer/dryer to the cloud."""
- self._wd.register_attr_callback(self.async_write_ha_state)
-
- async def async_will_remove_from_hass(self) -> None:
- """Close Whirlpool Appliance sockets before removing."""
- self._wd.unregister_attr_callback(self.async_write_ha_state)
-
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self._wd.get_online()
@property
def native_value(self) -> StateType | str:
"""Return native value of sensor."""
- return self.entity_description.value_fn(self._wd)
+ return self.entity_description.value_fn(self._appliance)
-class WasherDryerTimeClass(RestoreSensor):
- """A timestamp class for the whirlpool/maytag washer account."""
+class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor):
+ """A timestamp class for the Whirlpool washer/dryer."""
_attr_should_poll = True
- _attr_has_entity_name = True
def __init__(
- self,
- said: str,
- name: str,
- description: SensorEntityDescription,
- washdry: WasherDryer,
+ self, washer_dryer: WasherDryer, description: SensorEntityDescription
) -> None:
"""Initialize the washer sensor."""
- self._wd: WasherDryer = washdry
+ super().__init__(washer_dryer, unique_id_suffix=f"-{description.key}")
+ self.entity_description = description
- if name == "dryer":
- self._attr_icon = ICON_D
- else:
- self._attr_icon = ICON_W
-
- self.entity_description: SensorEntityDescription = description
+ self._wd = washer_dryer
self._running: bool | None = None
- self._attr_device_info = DeviceInfo(
- identifiers={(DOMAIN, said)},
- name=name.capitalize(),
- manufacturer="Whirlpool",
- )
- self._attr_unique_id = f"{said}-{description.key}"
+ self._value: datetime | None = None
async def async_added_to_hass(self) -> None:
- """Connect washer/dryer to the cloud."""
+ """Register attribute updates callback."""
if restored_data := await self.async_get_last_sensor_data():
- self._attr_native_value = restored_data.native_value
+ if isinstance(restored_data.native_value, datetime):
+ self._value = restored_data.native_value
await super().async_added_to_hass()
- self._wd.register_attr_callback(self.update_from_latest_data)
-
- async def async_will_remove_from_hass(self) -> None:
- """Close Whrilpool Appliance sockets before removing."""
- self._wd.unregister_attr_callback(self.update_from_latest_data)
- await self._wd.disconnect()
-
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self._wd.get_online()
async def async_update(self) -> None:
"""Update status of Whirlpool."""
await self._wd.fetch_data()
- @callback
- def update_from_latest_data(self) -> None:
+ @override
+ @property
+ def native_value(self) -> datetime | None:
"""Calculate the time stamp for completion."""
machine_state = self._wd.get_machine_state()
now = utcnow()
@@ -281,19 +226,14 @@ class WasherDryerTimeClass(RestoreSensor):
and self._running
):
self._running = False
- self._attr_native_value = now
- self._async_write_ha_state()
+ self._value = now
if machine_state is MachineState.RunningMainCycle:
self._running = True
-
- new_timestamp = now + timedelta(
- seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining"))
- )
-
- if self._attr_native_value is None or (
- isinstance(self._attr_native_value, datetime)
- and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60)
+ new_timestamp = now + timedelta(seconds=self._wd.get_time_remaining())
+ if self._value is None or (
+ isinstance(self._value, datetime)
+ and abs(new_timestamp - self._value) > timedelta(seconds=60)
):
- self._attr_native_value = new_timestamp
- self._async_write_ha_state()
+ self._value = new_timestamp
+ return self._value
diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json
index 95df3fb9098..8f38330980e 100644
--- a/homeassistant/components/whirlpool/strings.json
+++ b/homeassistant/components/whirlpool/strings.json
@@ -13,19 +13,23 @@
"brand": "Brand"
},
"data_description": {
- "brand": "Please choose the brand of the mobile app you use, or the brand of the appliances in your account"
+ "username": "The username or email address you use to log in to the Whirlpool/Maytag app",
+ "password": "The password you use to log in to the Whirlpool/Maytag app",
+ "region": "The region where your appliances where purchased",
+ "brand": "The brand of the mobile app you use, or the brand of the appliances in your account"
}
},
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
- "description": "For 'brand', please choose the brand of the mobile app you use, or the brand of the appliances in your account",
"data": {
"password": "[%key:common::config_flow::data::password%]",
- "region": "Region",
- "brand": "Brand"
+ "region": "[%key:component::whirlpool::config::step::user::data::region%]",
+ "brand": "[%key:component::whirlpool::config::step::user::data::brand%]"
},
"data_description": {
- "brand": "Please choose the brand of the mobile app you use, or the brand of the appliances in your account"
+ "password": "[%key:component::whirlpool::config::step::user::data_description::password%]",
+ "brand": "[%key:component::whirlpool::config::step::user::data_description::brand%]",
+ "region": "[%key:component::whirlpool::config::step::user::data_description::region%]"
}
}
},
@@ -43,35 +47,66 @@
},
"entity": {
"sensor": {
- "whirlpool_machine": {
+ "washer_state": {
"name": "State",
"state": {
"standby": "[%key:common::state::standby%]",
"setting": "Setting",
- "delay_countdown": "Delay Countdown",
- "delay_paused": "Delay Paused",
- "smart_delay": "Smart Delay",
- "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::whirlpool_machine::state::smart_delay%]",
+ "delay_countdown": "Delay countdown",
+ "delay_paused": "Delay paused",
+ "smart_delay": "Smart delay",
+ "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]",
"pause": "[%key:common::state::paused%]",
- "running_maincycle": "Running Maincycle",
- "running_postcycle": "Running Postcycle",
+ "running_maincycle": "Running maincycle",
+ "running_postcycle": "Running postcycle",
"exception": "Exception",
"complete": "Complete",
- "power_failure": "Power Failure",
- "service_diagnostic_mode": "Service Diagnostic Mode",
- "factory_diagnostic_mode": "Factory Diagnostic Mode",
- "life_test": "Life Test",
- "customer_focus_mode": "Customer Focus Mode",
- "demo_mode": "Demo Mode",
- "hard_stop_or_error": "Hard Stop or Error",
- "system_initialize": "System Initialize",
- "cycle_filling": "Cycle Filling",
- "cycle_rinsing": "Cycle Rinsing",
- "cycle_sensing": "Cycle Sensing",
- "cycle_soaking": "Cycle Soaking",
- "cycle_spinning": "Cycle Spinning",
- "cycle_washing": "Cycle Washing",
- "door_open": "Door Open"
+ "power_failure": "Power failure",
+ "service_diagnostic_mode": "Service diagnostic mode",
+ "factory_diagnostic_mode": "Factory diagnostic mode",
+ "life_test": "Life test",
+ "customer_focus_mode": "Customer focus mode",
+ "demo_mode": "Demo mode",
+ "hard_stop_or_error": "Hard stop or error",
+ "system_initialize": "System initialize",
+ "cycle_filling": "Cycle filling",
+ "cycle_rinsing": "Cycle rinsing",
+ "cycle_sensing": "Cycle sensing",
+ "cycle_soaking": "Cycle soaking",
+ "cycle_spinning": "Cycle spinning",
+ "cycle_washing": "Cycle washing",
+ "door_open": "Door open"
+ }
+ },
+ "dryer_state": {
+ "name": "[%key:component::whirlpool::entity::sensor::washer_state::name%]",
+ "state": {
+ "standby": "[%key:common::state::standby%]",
+ "setting": "[%key:component::whirlpool::entity::sensor::washer_state::state::setting%]",
+ "delay_countdown": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_countdown%]",
+ "delay_paused": "[%key:component::whirlpool::entity::sensor::washer_state::state::delay_paused%]",
+ "smart_delay": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]",
+ "smart_grid_pause": "[%key:component::whirlpool::entity::sensor::washer_state::state::smart_delay%]",
+ "pause": "[%key:common::state::paused%]",
+ "running_maincycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_maincycle%]",
+ "running_postcycle": "[%key:component::whirlpool::entity::sensor::washer_state::state::running_postcycle%]",
+ "exception": "[%key:component::whirlpool::entity::sensor::washer_state::state::exception%]",
+ "complete": "[%key:component::whirlpool::entity::sensor::washer_state::state::complete%]",
+ "power_failure": "[%key:component::whirlpool::entity::sensor::washer_state::state::power_failure%]",
+ "service_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::service_diagnostic_mode%]",
+ "factory_diagnostic_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::factory_diagnostic_mode%]",
+ "life_test": "[%key:component::whirlpool::entity::sensor::washer_state::state::life_test%]",
+ "customer_focus_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::customer_focus_mode%]",
+ "demo_mode": "[%key:component::whirlpool::entity::sensor::washer_state::state::demo_mode%]",
+ "hard_stop_or_error": "[%key:component::whirlpool::entity::sensor::washer_state::state::hard_stop_or_error%]",
+ "system_initialize": "[%key:component::whirlpool::entity::sensor::washer_state::state::system_initialize%]",
+ "cycle_filling": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_filling%]",
+ "cycle_rinsing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_rinsing%]",
+ "cycle_sensing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_sensing%]",
+ "cycle_soaking": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_soaking%]",
+ "cycle_spinning": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_spinning%]",
+ "cycle_washing": "[%key:component::whirlpool::entity::sensor::washer_state::state::cycle_washing%]",
+ "door_open": "[%key:component::whirlpool::entity::sensor::washer_state::state::door_open%]"
}
},
"whirlpool_tank": {
diff --git a/homeassistant/components/whois/config_flow.py b/homeassistant/components/whois/config_flow.py
index cb4326d996d..a8306be7632 100644
--- a/homeassistant/components/whois/config_flow.py
+++ b/homeassistant/components/whois/config_flow.py
@@ -11,6 +11,8 @@ from whois.exceptions import (
UnknownDateFormat,
UnknownTld,
WhoisCommandFailed,
+ WhoisPrivateRegistry,
+ WhoisQuotaExceeded,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -48,6 +50,10 @@ class WhoisFlowHandler(ConfigFlow, domain=DOMAIN):
errors["base"] = "unexpected_response"
except UnknownDateFormat:
errors["base"] = "unknown_date_format"
+ except WhoisPrivateRegistry:
+ errors["base"] = "private_registry"
+ except WhoisQuotaExceeded:
+ errors["base"] = "quota_exceeded"
else:
return self.async_create_entry(
title=self.imported_name or user_input[CONF_DOMAIN],
diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py
index fe193b16eea..8098e052575 100644
--- a/homeassistant/components/whois/sensor.py
+++ b/homeassistant/components/whois/sensor.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DOMAIN, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -127,7 +127,7 @@ SENSORS: tuple[WhoisSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the platform from config_entry."""
coordinator: DataUpdateCoordinator[Domain | None] = hass.data[DOMAIN][
diff --git a/homeassistant/components/whois/strings.json b/homeassistant/components/whois/strings.json
index c28c079784d..3b0f9dfd4d1 100644
--- a/homeassistant/components/whois/strings.json
+++ b/homeassistant/components/whois/strings.json
@@ -11,7 +11,9 @@
"unexpected_response": "Unexpected response from whois server",
"unknown_date_format": "Unknown date format in whois server response",
"unknown_tld": "The given TLD is unknown or not available to this integration",
- "whois_command_failed": "Whois command failed: could not retrieve whois information"
+ "whois_command_failed": "Whois command failed: could not retrieve whois information",
+ "private_registry": "The given domain is registered in a private registry and cannot be monitored",
+ "quota_exceeded": "Your whois quota has been exceeded for this TLD"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py
index b7431b2555c..93fdb7cce1c 100644
--- a/homeassistant/components/wiffi/binary_sensor.py
+++ b/homeassistant/components/wiffi/binary_sensor.py
@@ -4,7 +4,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CREATE_ENTITY_SIGNAL
from .entity import WiffiEntity
@@ -13,7 +13,7 @@ from .entity import WiffiEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up platform for a new integration.
diff --git a/homeassistant/components/wiffi/entity.py b/homeassistant/components/wiffi/entity.py
index fd774c930c8..84bbc9b3df1 100644
--- a/homeassistant/components/wiffi/entity.py
+++ b/homeassistant/components/wiffi/entity.py
@@ -41,7 +41,7 @@ class WiffiEntity(Entity):
self._value = None
self._timeout = options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Entity has been added to hass."""
self.async_on_remove(
async_dispatcher_connect(
diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py
index 699a760685a..9afcc719c9b 100644
--- a/homeassistant/components/wiffi/sensor.py
+++ b/homeassistant/components/wiffi/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import DEGREE, LIGHT_LUX, UnitOfPressure, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CREATE_ENTITY_SIGNAL
from .entity import WiffiEntity
@@ -41,7 +41,7 @@ UOM_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up platform for a new integration.
diff --git a/homeassistant/components/wilight/cover.py b/homeassistant/components/wilight/cover.py
index 8a5cb45d909..2e9b92e7a21 100644
--- a/homeassistant/components/wilight/cover.py
+++ b/homeassistant/components/wilight/cover.py
@@ -18,7 +18,7 @@ from pywilight.const import (
from homeassistant.components.cover import ATTR_POSITION, CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import WiLightDevice
@@ -26,7 +26,9 @@ from .parent_device import WiLightParent
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WiLight covers from a config entry."""
parent: WiLightParent = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py
index a14198e3b5d..6a22da5879e 100644
--- a/homeassistant/components/wilight/fan.py
+++ b/homeassistant/components/wilight/fan.py
@@ -19,7 +19,7 @@ from pywilight.wilight_device import PyWiLightDevice
from homeassistant.components.fan import DIRECTION_FORWARD, FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
@@ -33,7 +33,9 @@ ORDERED_NAMED_FAN_SPEEDS = [WL_SPEED_LOW, WL_SPEED_MEDIUM, WL_SPEED_HIGH]
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WiLight lights from a config entry."""
parent: WiLightParent = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py
index fbe2499798d..7df0eb1a4c6 100644
--- a/homeassistant/components/wilight/light.py
+++ b/homeassistant/components/wilight/light.py
@@ -15,7 +15,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import WiLightDevice
@@ -41,7 +41,9 @@ def entities_from_discovered_wilight(api_device: PyWiLightDevice) -> list[LightE
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WiLight lights from a config entry."""
parent: WiLightParent = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/wilight/switch.py b/homeassistant/components/wilight/switch.py
index f2a1ce8b0c5..148ea65dd94 100644
--- a/homeassistant/components/wilight/switch.py
+++ b/homeassistant/components/wilight/switch.py
@@ -12,7 +12,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import WiLightDevice
@@ -75,7 +75,9 @@ def entities_from_discovered_wilight(api_device: PyWiLightDevice) -> tuple[Any]:
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WiLight switches from a config entry."""
parent: WiLightParent = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/wirelesstag/entity.py b/homeassistant/components/wirelesstag/entity.py
index 31f8ee99d0d..73b13cdc397 100644
--- a/homeassistant/components/wirelesstag/entity.py
+++ b/homeassistant/components/wirelesstag/entity.py
@@ -60,11 +60,11 @@ class WirelessTagBaseSensor(Entity):
return f"{value:.1f}"
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if entity is available."""
return self._tag.is_alive
- def update(self):
+ def update(self) -> None:
"""Update state."""
if not self.should_poll:
return
diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py
index 856aeeffc5c..457bbe59bcc 100644
--- a/homeassistant/components/withings/binary_sensor.py
+++ b/homeassistant/components/withings/binary_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WithingsConfigEntry
from .const import DOMAIN
@@ -22,7 +22,7 @@ from .entity import WithingsEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: WithingsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
coordinator = entry.runtime_data.bed_presence_coordinator
diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py
index ac867fbfdca..8dcad9d73ba 100644
--- a/homeassistant/components/withings/calendar.py
+++ b/homeassistant/components/withings/calendar.py
@@ -11,7 +11,7 @@ from homeassistant.components.calendar import CalendarEntity, CalendarEvent
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN, WithingsConfigEntry
from .coordinator import WithingsWorkoutDataUpdateCoordinator
@@ -21,7 +21,7 @@ from .entity import WithingsEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: WithingsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the calendar platform for entity."""
ent_reg = er.async_get(hass)
diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json
index 4c78e077d21..232997da054 100644
--- a/homeassistant/components/withings/manifest.json
+++ b/homeassistant/components/withings/manifest.json
@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/withings",
"iot_class": "cloud_push",
"loggers": ["aiowithings"],
- "requirements": ["aiowithings==3.1.5"]
+ "requirements": ["aiowithings==3.1.6"]
}
diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py
index 1005b5995a5..f20145f8bf9 100644
--- a/homeassistant/components/withings/sensor.py
+++ b/homeassistant/components/withings/sensor.py
@@ -36,7 +36,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util import dt as dt_util
@@ -122,7 +122,7 @@ MEASUREMENT_SENSORS: dict[
measurement_type=MeasurementType.HEIGHT,
translation_key="height",
native_unit_of_measurement=UnitOfLength.METERS,
- suggested_display_precision=1,
+ suggested_display_precision=2,
device_class=SensorDeviceClass.DISTANCE,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
@@ -326,6 +326,7 @@ SLEEP_SENSORS = [
value_fn=lambda sleep_summary: sleep_summary.deep_sleep_duration,
translation_key="deep_sleep",
native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
),
@@ -334,6 +335,7 @@ SLEEP_SENSORS = [
value_fn=lambda sleep_summary: sleep_summary.sleep_latency,
translation_key="time_to_sleep",
native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
@@ -343,6 +345,7 @@ SLEEP_SENSORS = [
value_fn=lambda sleep_summary: sleep_summary.wake_up_latency,
translation_key="time_to_wakeup",
native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
@@ -376,6 +379,7 @@ SLEEP_SENSORS = [
value_fn=lambda sleep_summary: sleep_summary.light_sleep_duration,
translation_key="light_sleep",
native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
@@ -385,6 +389,7 @@ SLEEP_SENSORS = [
value_fn=lambda sleep_summary: sleep_summary.rem_sleep_duration,
translation_key="rem_sleep",
native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
@@ -425,6 +430,9 @@ SLEEP_SENSORS = [
key="sleep_snoring",
value_fn=lambda sleep_summary: sleep_summary.snoring,
translation_key="snoring",
+ native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.MINUTES,
+ device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
@@ -448,6 +456,7 @@ SLEEP_SENSORS = [
value_fn=lambda sleep_summary: sleep_summary.total_time_awake,
translation_key="wakeup_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
+ suggested_unit_of_measurement=UnitOfTime.HOURS,
device_class=SensorDeviceClass.DURATION,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
@@ -683,7 +692,7 @@ def get_current_goals(goals: Goals) -> set[str]:
async def async_setup_entry(
hass: HomeAssistant,
entry: WithingsConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor config entry."""
ent_reg = er.async_get(hass)
diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json
index 775ef5cdaab..746fa244c8e 100644
--- a/homeassistant/components/withings/strings.json
+++ b/homeassistant/components/withings/strings.json
@@ -313,9 +313,9 @@
"battery": {
"name": "[%key:component::sensor::entity_component::battery::name%]",
"state": {
- "low": "Low",
- "medium": "Medium",
- "high": "High"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
}
}
diff --git a/homeassistant/components/wiz/binary_sensor.py b/homeassistant/components/wiz/binary_sensor.py
index 3411ee200b9..385e6827d77 100644
--- a/homeassistant/components/wiz/binary_sensor.py
+++ b/homeassistant/components/wiz/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WizConfigEntry
from .const import DOMAIN, SIGNAL_WIZ_PIR
@@ -27,7 +27,7 @@ OCCUPANCY_UNIQUE_ID = "{}_occupancy"
async def async_setup_entry(
hass: HomeAssistant,
entry: WizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the WiZ binary sensor platform."""
mac = entry.runtime_data.bulb.mac
diff --git a/homeassistant/components/wiz/light.py b/homeassistant/components/wiz/light.py
index 9ef4cd57b3d..e38d518f6bc 100644
--- a/homeassistant/components/wiz/light.py
+++ b/homeassistant/components/wiz/light.py
@@ -20,7 +20,7 @@ from homeassistant.components.light import (
filter_supported_color_modes,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WizConfigEntry
from .entity import WizToggleEntity
@@ -57,7 +57,7 @@ def _async_pilot_builder(**kwargs: Any) -> PilotBuilder:
async def async_setup_entry(
hass: HomeAssistant,
entry: WizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the WiZ Platform from config_flow."""
if entry.runtime_data.bulb.bulbtype.bulb_type != BulbClass.SOCKET:
diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json
index 7b1ecdcdb6b..947e7f0b638 100644
--- a/homeassistant/components/wiz/manifest.json
+++ b/homeassistant/components/wiz/manifest.json
@@ -26,5 +26,5 @@
],
"documentation": "https://www.home-assistant.io/integrations/wiz",
"iot_class": "local_push",
- "requirements": ["pywizlight==0.5.14"]
+ "requirements": ["pywizlight==0.6.2"]
}
diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py
index 0591e854d7d..0c8ee3f2bf4 100644
--- a/homeassistant/components/wiz/number.py
+++ b/homeassistant/components/wiz/number.py
@@ -15,7 +15,7 @@ from homeassistant.components.number import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WizConfigEntry
from .entity import WizEntity
@@ -68,7 +68,7 @@ NUMBERS: tuple[WizNumberEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: WizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the wiz speed number."""
async_add_entities(
diff --git a/homeassistant/components/wiz/sensor.py b/homeassistant/components/wiz/sensor.py
index eb77686a5cf..217dae9e8fb 100644
--- a/homeassistant/components/wiz/sensor.py
+++ b/homeassistant/components/wiz/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.const import (
UnitOfPower,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WizConfigEntry
from .entity import WizEntity
@@ -45,7 +45,7 @@ POWER_SENSORS: tuple[SensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: WizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the wiz sensor."""
entities = [
diff --git a/homeassistant/components/wiz/switch.py b/homeassistant/components/wiz/switch.py
index 4c089d2d6d2..a57834bc18d 100644
--- a/homeassistant/components/wiz/switch.py
+++ b/homeassistant/components/wiz/switch.py
@@ -9,7 +9,7 @@ from pywizlight.bulblibrary import BulbClass
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WizConfigEntry
from .entity import WizToggleEntity
@@ -19,7 +19,7 @@ from .models import WizData
async def async_setup_entry(
hass: HomeAssistant,
entry: WizConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the WiZ switch platform."""
if entry.runtime_data.bulb.bulbtype.bulb_type == BulbClass.SOCKET:
diff --git a/homeassistant/components/wled/button.py b/homeassistant/components/wled/button.py
index 74799b4dcc4..119b2dc9b9f 100644
--- a/homeassistant/components/wled/button.py
+++ b/homeassistant/components/wled/button.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WLEDConfigEntry
from .coordinator import WLEDDataUpdateCoordinator
@@ -16,7 +16,7 @@ from .helpers import wled_exception_handler
async def async_setup_entry(
hass: HomeAssistant,
entry: WLEDConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WLED button based on a config entry."""
async_add_entities([WLEDRestartButton(entry.runtime_data)])
diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py
index b4edf10dc58..5e2ff117580 100644
--- a/homeassistant/components/wled/light.py
+++ b/homeassistant/components/wled/light.py
@@ -17,7 +17,7 @@ from homeassistant.components.light import (
LightEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WLEDConfigEntry
from .const import (
@@ -39,7 +39,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: WLEDConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WLED light based on a config entry."""
coordinator = entry.runtime_data
@@ -284,7 +284,7 @@ class WLEDSegmentLight(WLEDEntity, LightEntity):
def async_update_segments(
coordinator: WLEDDataUpdateCoordinator,
current_ids: set[int],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Update segments."""
segment_ids = {
diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py
index 225d783bfdb..e4ff184fd4b 100644
--- a/homeassistant/components/wled/number.py
+++ b/homeassistant/components/wled/number.py
@@ -11,7 +11,7 @@ from wled import Segment
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WLEDConfigEntry
from .const import ATTR_INTENSITY, ATTR_SPEED
@@ -25,7 +25,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: WLEDConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WLED number based on a config entry."""
coordinator = entry.runtime_data
@@ -130,7 +130,7 @@ class WLEDNumber(WLEDEntity, NumberEntity):
def async_update_segments(
coordinator: WLEDDataUpdateCoordinator,
current_ids: set[int],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Update segments."""
segment_ids = {
diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py
index a645b04573c..76837652ae5 100644
--- a/homeassistant/components/wled/select.py
+++ b/homeassistant/components/wled/select.py
@@ -9,7 +9,7 @@ from wled import LiveDataOverride
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WLEDConfigEntry
from .coordinator import WLEDDataUpdateCoordinator
@@ -22,7 +22,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: WLEDConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WLED select based on a config entry."""
coordinator = entry.runtime_data
@@ -79,9 +79,10 @@ class WLEDPresetSelect(WLEDEntity, SelectEntity):
super().__init__(coordinator=coordinator)
self._attr_unique_id = f"{coordinator.data.info.mac_address}_preset"
- self._attr_options = [
- preset.name for preset in self.coordinator.data.presets.values()
- ]
+ sorted_values = sorted(
+ coordinator.data.presets.values(), key=lambda preset: preset.name
+ )
+ self._attr_options = [preset.name for preset in sorted_values]
@property
def available(self) -> bool:
@@ -115,9 +116,10 @@ class WLEDPlaylistSelect(WLEDEntity, SelectEntity):
super().__init__(coordinator=coordinator)
self._attr_unique_id = f"{coordinator.data.info.mac_address}_playlist"
- self._attr_options = [
- playlist.name for playlist in self.coordinator.data.playlists.values()
- ]
+ sorted_values = sorted(
+ coordinator.data.playlists.values(), key=lambda playlist: playlist.name
+ )
+ self._attr_options = [playlist.name for playlist in sorted_values]
@property
def available(self) -> bool:
@@ -159,9 +161,10 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity):
self._attr_translation_placeholders = {"segment": str(segment)}
self._attr_unique_id = f"{coordinator.data.info.mac_address}_palette_{segment}"
- self._attr_options = [
- palette.name for palette in self.coordinator.data.palettes.values()
- ]
+ sorted_values = sorted(
+ coordinator.data.palettes.values(), key=lambda palette: palette.name
+ )
+ self._attr_options = [palette.name for palette in sorted_values]
self._segment = segment
@property
@@ -191,7 +194,7 @@ class WLEDPaletteSelect(WLEDEntity, SelectEntity):
def async_update_segments(
coordinator: WLEDDataUpdateCoordinator,
current_ids: set[int],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Update segments."""
segment_ids = {
diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py
index 4f97c367612..06f96782019 100644
--- a/homeassistant/components/wled/sensor.py
+++ b/homeassistant/components/wled/sensor.py
@@ -22,7 +22,7 @@ from homeassistant.const import (
UnitOfInformation,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.util.dt import utcnow
@@ -128,7 +128,7 @@ SENSORS: tuple[WLEDSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: WLEDConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WLED sensor based on a config entry."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py
index 643834dcdec..8ed6ed56114 100644
--- a/homeassistant/components/wled/switch.py
+++ b/homeassistant/components/wled/switch.py
@@ -8,7 +8,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WLEDConfigEntry
from .const import ATTR_DURATION, ATTR_TARGET_BRIGHTNESS, ATTR_UDP_PORT
@@ -22,7 +22,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
entry: WLEDConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WLED switch based on a config entry."""
coordinator = entry.runtime_data
@@ -195,7 +195,7 @@ class WLEDReverseSwitch(WLEDEntity, SwitchEntity):
def async_update_segments(
coordinator: WLEDDataUpdateCoordinator,
current_ids: set[int],
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Update segments."""
segment_ids = {
diff --git a/homeassistant/components/wled/update.py b/homeassistant/components/wled/update.py
index 384b394ac50..ccf72425b77 100644
--- a/homeassistant/components/wled/update.py
+++ b/homeassistant/components/wled/update.py
@@ -10,7 +10,7 @@ from homeassistant.components.update import (
UpdateEntityFeature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WLED_KEY, WLEDConfigEntry
from .coordinator import WLEDDataUpdateCoordinator, WLEDReleasesDataUpdateCoordinator
@@ -21,7 +21,7 @@ from .helpers import wled_exception_handler
async def async_setup_entry(
hass: HomeAssistant,
entry: WLEDConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up WLED update based on a config entry."""
async_add_entities([WLEDUpdateEntity(entry.runtime_data, hass.data[WLED_KEY])])
diff --git a/homeassistant/components/wmspro/cover.py b/homeassistant/components/wmspro/cover.py
index a36b34642b7..715add3023f 100644
--- a/homeassistant/components/wmspro/cover.py
+++ b/homeassistant/components/wmspro/cover.py
@@ -12,7 +12,7 @@ from wmspro.const import (
from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WebControlProConfigEntry
from .entity import WebControlProGenericEntity
@@ -24,7 +24,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: WebControlProConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the WMS based covers from a config entry."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/wmspro/light.py b/homeassistant/components/wmspro/light.py
index 9242982bcf9..d181beb1eaa 100644
--- a/homeassistant/components/wmspro/light.py
+++ b/homeassistant/components/wmspro/light.py
@@ -9,7 +9,7 @@ from wmspro.const import WMS_WebControl_pro_API_actionDescription
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import brightness_to_value, value_to_brightness
from . import WebControlProConfigEntry
@@ -23,7 +23,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: WebControlProConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the WMS based lights from a config entry."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/wmspro/scene.py b/homeassistant/components/wmspro/scene.py
index de18106b7f0..7edd7a2b186 100644
--- a/homeassistant/components/wmspro/scene.py
+++ b/homeassistant/components/wmspro/scene.py
@@ -9,7 +9,7 @@ from wmspro.scene import Scene as WMS_Scene
from homeassistant.components.scene import Scene
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import WebControlProConfigEntry
from .const import ATTRIBUTION, DOMAIN, MANUFACTURER
@@ -18,7 +18,7 @@ from .const import ATTRIBUTION, DOMAIN, MANUFACTURER
async def async_setup_entry(
hass: HomeAssistant,
config_entry: WebControlProConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the WMS based scenes from a config entry."""
hub = config_entry.runtime_data
diff --git a/homeassistant/components/wolflink/manifest.json b/homeassistant/components/wolflink/manifest.json
index 4bfc0e6dd83..5f3a6366fe1 100644
--- a/homeassistant/components/wolflink/manifest.json
+++ b/homeassistant/components/wolflink/manifest.json
@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/wolflink",
"iot_class": "cloud_polling",
"loggers": ["wolf_comm"],
- "requirements": ["wolf-comm==0.0.15"]
+ "requirements": ["wolf-comm==0.0.23"]
}
diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py
index 1f6e6c42464..9380c28de89 100644
--- a/homeassistant/components/wolflink/sensor.py
+++ b/homeassistant/components/wolflink/sensor.py
@@ -2,52 +2,150 @@
from __future__ import annotations
+from collections.abc import Callable
+from dataclasses import dataclass
+
from wolf_comm.models import (
+ EnergyParameter,
+ FlowParameter,
+ FrequencyParameter,
HoursParameter,
ListItemParameter,
Parameter,
PercentageParameter,
+ PowerParameter,
Pressure,
+ RPMParameter,
SimpleParameter,
Temperature,
)
-from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
+from homeassistant.components.sensor import (
+ SensorDeviceClass,
+ SensorEntity,
+ SensorEntityDescription,
+ SensorStateClass,
+)
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import UnitOfPressure, UnitOfTemperature, UnitOfTime
+from homeassistant.const import (
+ PERCENTAGE,
+ REVOLUTIONS_PER_MINUTE,
+ UnitOfEnergy,
+ UnitOfFrequency,
+ UnitOfPower,
+ UnitOfPressure,
+ UnitOfTemperature,
+ UnitOfTime,
+ UnitOfVolumeFlowRate,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import COORDINATOR, DEVICE_ID, DOMAIN, MANUFACTURER, PARAMETERS, STATES
+def get_listitem_resolve_state(wolf_object, state):
+ """Resolve list item state."""
+ resolved_state = [item for item in wolf_object.items if item.value == int(state)]
+ if resolved_state:
+ resolved_name = resolved_state[0].name
+ state = STATES.get(resolved_name, resolved_name)
+ return state
+
+
+@dataclass(kw_only=True, frozen=True)
+class WolflinkSensorEntityDescription(SensorEntityDescription):
+ """Describes Wolflink sensor entity."""
+
+ value_fn: Callable[[Parameter, str], str | None] = lambda param, value: value
+ supported_fn: Callable[[Parameter], bool]
+
+
+SENSOR_DESCRIPTIONS = [
+ WolflinkSensorEntityDescription(
+ key="temperature",
+ device_class=SensorDeviceClass.TEMPERATURE,
+ native_unit_of_measurement=UnitOfTemperature.CELSIUS,
+ supported_fn=lambda param: isinstance(param, Temperature),
+ ),
+ WolflinkSensorEntityDescription(
+ key="pressure",
+ device_class=SensorDeviceClass.PRESSURE,
+ native_unit_of_measurement=UnitOfPressure.BAR,
+ supported_fn=lambda param: isinstance(param, Pressure),
+ ),
+ WolflinkSensorEntityDescription(
+ key="energy",
+ device_class=SensorDeviceClass.ENERGY,
+ native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
+ supported_fn=lambda param: isinstance(param, EnergyParameter),
+ ),
+ WolflinkSensorEntityDescription(
+ key="power",
+ device_class=SensorDeviceClass.POWER,
+ native_unit_of_measurement=UnitOfPower.KILO_WATT,
+ supported_fn=lambda param: isinstance(param, PowerParameter),
+ ),
+ WolflinkSensorEntityDescription(
+ key="percentage",
+ native_unit_of_measurement=PERCENTAGE,
+ supported_fn=lambda param: isinstance(param, PercentageParameter),
+ ),
+ WolflinkSensorEntityDescription(
+ key="list_item",
+ translation_key="state",
+ supported_fn=lambda param: isinstance(param, ListItemParameter),
+ value_fn=get_listitem_resolve_state,
+ ),
+ WolflinkSensorEntityDescription(
+ key="hours",
+ icon="mdi:clock",
+ native_unit_of_measurement=UnitOfTime.HOURS,
+ supported_fn=lambda param: isinstance(param, HoursParameter),
+ ),
+ WolflinkSensorEntityDescription(
+ key="flow",
+ device_class=SensorDeviceClass.VOLUME_FLOW_RATE,
+ native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE,
+ supported_fn=lambda param: isinstance(param, FlowParameter),
+ ),
+ WolflinkSensorEntityDescription(
+ key="frequency",
+ device_class=SensorDeviceClass.FREQUENCY,
+ native_unit_of_measurement=UnitOfFrequency.HERTZ,
+ supported_fn=lambda param: isinstance(param, FrequencyParameter),
+ ),
+ WolflinkSensorEntityDescription(
+ key="rpm",
+ state_class=SensorStateClass.MEASUREMENT,
+ native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
+ supported_fn=lambda param: isinstance(param, RPMParameter),
+ ),
+ WolflinkSensorEntityDescription(
+ key="default",
+ supported_fn=lambda param: isinstance(param, SimpleParameter),
+ ),
+]
+
+
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up all entries for Wolf Platform."""
-
coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATOR]
parameters = hass.data[DOMAIN][config_entry.entry_id][PARAMETERS]
device_id = hass.data[DOMAIN][config_entry.entry_id][DEVICE_ID]
- entities: list[WolfLinkSensor] = []
- for parameter in parameters:
- if isinstance(parameter, Temperature):
- entities.append(WolfLinkTemperature(coordinator, parameter, device_id))
- if isinstance(parameter, Pressure):
- entities.append(WolfLinkPressure(coordinator, parameter, device_id))
- if isinstance(parameter, PercentageParameter):
- entities.append(WolfLinkPercentage(coordinator, parameter, device_id))
- if isinstance(parameter, ListItemParameter):
- entities.append(WolfLinkState(coordinator, parameter, device_id))
- if isinstance(parameter, HoursParameter):
- entities.append(WolfLinkHours(coordinator, parameter, device_id))
- if isinstance(parameter, SimpleParameter):
- entities.append(WolfLinkSensor(coordinator, parameter, device_id))
+ entities: list[WolfLinkSensor] = [
+ WolfLinkSensor(coordinator, parameter, device_id, description)
+ for parameter in parameters
+ for description in SENSOR_DESCRIPTIONS
+ if description.supported_fn(parameter)
+ ]
async_add_entities(entities, True)
@@ -55,9 +153,18 @@ async def async_setup_entry(
class WolfLinkSensor(CoordinatorEntity, SensorEntity):
"""Base class for all Wolf entities."""
- def __init__(self, coordinator, wolf_object: Parameter, device_id) -> None:
+ entity_description: WolflinkSensorEntityDescription
+
+ def __init__(
+ self,
+ coordinator,
+ wolf_object: Parameter,
+ device_id: str,
+ description: WolflinkSensorEntityDescription,
+ ) -> None:
"""Initialize."""
super().__init__(coordinator)
+ self.entity_description = description
self.wolf_object = wolf_object
self._attr_name = wolf_object.name
self._attr_unique_id = f"{device_id}:{wolf_object.parameter_id}"
@@ -69,68 +176,26 @@ class WolfLinkSensor(CoordinatorEntity, SensorEntity):
)
@property
- def native_value(self):
+ def native_value(self) -> str | None:
"""Return the state. Wolf Client is returning only changed values so we need to store old value here."""
if self.wolf_object.parameter_id in self.coordinator.data:
new_state = self.coordinator.data[self.wolf_object.parameter_id]
self.wolf_object.value_id = new_state[0]
self._state = new_state[1]
+ if (
+ isinstance(self.wolf_object, ListItemParameter)
+ and self._state is not None
+ ):
+ self._state = self.entity_description.value_fn(
+ self.wolf_object, self._state
+ )
return self._state
@property
- def extra_state_attributes(self):
+ def extra_state_attributes(self) -> dict[str, str | None]:
"""Return the state attributes."""
return {
"parameter_id": self.wolf_object.parameter_id,
"value_id": self.wolf_object.value_id,
"parent": self.wolf_object.parent,
}
-
-
-class WolfLinkHours(WolfLinkSensor):
- """Class for hour based entities."""
-
- _attr_icon = "mdi:clock"
- _attr_native_unit_of_measurement = UnitOfTime.HOURS
-
-
-class WolfLinkTemperature(WolfLinkSensor):
- """Class for temperature based entities."""
-
- _attr_device_class = SensorDeviceClass.TEMPERATURE
- _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS
-
-
-class WolfLinkPressure(WolfLinkSensor):
- """Class for pressure based entities."""
-
- _attr_device_class = SensorDeviceClass.PRESSURE
- _attr_native_unit_of_measurement = UnitOfPressure.BAR
-
-
-class WolfLinkPercentage(WolfLinkSensor):
- """Class for percentage based entities."""
-
- @property
- def native_unit_of_measurement(self):
- """Return the unit the value is expressed in."""
- return self.wolf_object.unit
-
-
-class WolfLinkState(WolfLinkSensor):
- """Class for entities which has defined list of state."""
-
- _attr_translation_key = "state"
-
- @property
- def native_value(self):
- """Return the state converting with supported values."""
- state = super().native_value
- if state is not None:
- resolved_state = [
- item for item in self.wolf_object.items if item.value == int(state)
- ]
- if resolved_state:
- resolved_name = resolved_state[0].name
- return STATES.get(resolved_name, resolved_name)
- return state
diff --git a/homeassistant/components/wolflink/strings.json b/homeassistant/components/wolflink/strings.json
index b1c332984a1..bd5d358529b 100644
--- a/homeassistant/components/wolflink/strings.json
+++ b/homeassistant/components/wolflink/strings.json
@@ -28,11 +28,11 @@
"sensor": {
"state": {
"state": {
- "ein": "[%key:common::state::enabled%]",
- "deaktiviert": "Inactive",
- "aus": "[%key:common::state::disabled%]",
+ "ein": "[%key:common::state::on%]",
+ "deaktiviert": "[%key:common::state::disabled%]",
+ "aus": "[%key:common::state::off%]",
"standby": "[%key:common::state::standby%]",
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
"permanent": "Permanent",
"initialisierung": "Initialization",
"antilegionellenfunktion": "Anti-legionella Function",
diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py
index 3aad6d805d0..6b878db8159 100644
--- a/homeassistant/components/workday/binary_sensor.py
+++ b/homeassistant/components/workday/binary_sensor.py
@@ -26,7 +26,7 @@ from homeassistant.core import (
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
from homeassistant.helpers.event import async_track_point_in_utc_time
@@ -113,7 +113,9 @@ def _get_obj_holidays(
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Workday sensor."""
add_holidays: list[str] = entry.options[CONF_ADD_HOLIDAYS]
diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py
index 895c7cd50e2..b0b1e9fcc02 100644
--- a/homeassistant/components/workday/config_flow.py
+++ b/homeassistant/components/workday/config_flow.py
@@ -26,6 +26,7 @@ from homeassistant.helpers.selector import (
NumberSelector,
NumberSelectorConfig,
NumberSelectorMode,
+ SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
@@ -79,10 +80,19 @@ def add_province_and_language_to_schema(
}
if provinces := all_countries.get(country):
+ if _country.subdivisions_aliases and (
+ subdiv_aliases := _country.get_subdivision_aliases()
+ ):
+ province_options: list[Any] = [
+ SelectOptionDict(value=k, label=", ".join(v))
+ for k, v in subdiv_aliases.items()
+ ]
+ else:
+ province_options = provinces
province_schema = {
vol.Optional(CONF_PROVINCE): SelectSelector(
SelectSelectorConfig(
- options=provinces,
+ options=province_options,
mode=SelectSelectorMode.DROPDOWN,
translation_key=CONF_PROVINCE,
)
diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json
index cbb11a06aec..60196fb15b7 100644
--- a/homeassistant/components/workday/manifest.json
+++ b/homeassistant/components/workday/manifest.json
@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
- "requirements": ["holidays==0.66"]
+ "requirements": ["holidays==0.70"]
}
diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json
index 87fa294dbba..feedc52331b 100644
--- a/homeassistant/components/workday/strings.json
+++ b/homeassistant/components/workday/strings.json
@@ -2,13 +2,13 @@
"title": "Workday",
"config": {
"abort": {
- "already_configured": "Workday has already been setup with chosen configuration"
+ "already_configured": "Workday has already been set up with chosen configuration"
},
"step": {
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
- "country": "Country"
+ "country": "[%key:common::config_flow::data::country%]"
}
},
"options": {
@@ -18,7 +18,7 @@
"days_offset": "Offset",
"workdays": "Days to include",
"add_holidays": "Add holidays",
- "remove_holidays": "Remove Holidays",
+ "remove_holidays": "Remove holidays",
"province": "Subdivision of country",
"language": "Language for named holidays",
"category": "Additional category as holiday"
@@ -116,14 +116,14 @@
},
"issues": {
"bad_country": {
- "title": "Configured Country for {title} does not exist",
+ "title": "Configured country for {title} does not exist",
"fix_flow": {
"step": {
"country": {
"title": "Select country for {title}",
"description": "Select a country to use for your Workday sensor.",
"data": {
- "country": "[%key:component::workday::config::step::user::data::country%]"
+ "country": "[%key:common::config_flow::data::country%]"
}
},
"province": {
@@ -133,7 +133,7 @@
"province": "[%key:component::workday::config::step::options::data::province%]"
},
"data_description": {
- "province": "State, Territory, Province, Region of Country"
+ "province": "[%key:component::workday::config::step::options::data_description::province%]"
}
}
}
@@ -150,7 +150,7 @@
"province": "[%key:component::workday::config::step::options::data::province%]"
},
"data_description": {
- "province": "[%key:component::workday::issues::bad_country::fix_flow::step::province::data_description::province%]"
+ "province": "[%key:component::workday::config::step::options::data_description::province%]"
}
}
}
@@ -217,7 +217,7 @@
"services": {
"check_date": {
"name": "Check date",
- "description": "Check if date is workday.",
+ "description": "Checks if a given date is a workday.",
"fields": {
"check_date": {
"name": "Date",
diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py
index 88e5a317cdd..9b52993919c 100644
--- a/homeassistant/components/worldclock/sensor.py
+++ b/homeassistant/components/worldclock/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_TIME_ZONE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .const import CONF_TIME_FORMAT, DOMAIN
@@ -18,7 +18,7 @@ from .const import CONF_TIME_FORMAT, DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the World clock sensor entry."""
time_zone = await dt_util.async_get_time_zone(entry.options[CONF_TIME_ZONE])
diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py
index a2cd7ba471b..fb8ba5ae996 100644
--- a/homeassistant/components/ws66i/media_player.py
+++ b/homeassistant/components/ws66i/media_player.py
@@ -10,7 +10,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MAX_VOL
@@ -23,7 +23,7 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the WS66i 6-zone amplifier platform from a config entry."""
ws66i_data: Ws66iData = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/wyoming/__init__.py b/homeassistant/components/wyoming/__init__.py
index d639933ece6..4e76287d8e7 100644
--- a/homeassistant/components/wyoming/__init__.py
+++ b/homeassistant/components/wyoming/__init__.py
@@ -8,15 +8,19 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
-from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import config_validation as cv, device_registry as dr
+from homeassistant.helpers.typing import ConfigType
from .const import ATTR_SPEAKER, DOMAIN
from .data import WyomingService
from .devices import SatelliteDevice
from .models import DomainDataItem
+from .websocket_api import async_register_websocket_api
_LOGGER = logging.getLogger(__name__)
+CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
+
SATELLITE_PLATFORMS = [
Platform.ASSIST_SATELLITE,
Platform.BINARY_SENSOR,
@@ -28,11 +32,19 @@ SATELLITE_PLATFORMS = [
__all__ = [
"ATTR_SPEAKER",
"DOMAIN",
+ "async_setup",
"async_setup_entry",
"async_unload_entry",
]
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the Wyoming integration."""
+ async_register_websocket_api(hass)
+
+ return True
+
+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load Wyoming."""
service = await WyomingService.create(entry.data["host"], entry.data["port"])
diff --git a/homeassistant/components/wyoming/assist_satellite.py b/homeassistant/components/wyoming/assist_satellite.py
index 615084bcbf3..88939f0ba77 100644
--- a/homeassistant/components/wyoming/assist_satellite.py
+++ b/homeassistant/components/wyoming/assist_satellite.py
@@ -24,18 +24,20 @@ from wyoming.tts import Synthesize, SynthesizeVoice
from wyoming.vad import VoiceStarted, VoiceStopped
from wyoming.wake import Detect, Detection
-from homeassistant.components import assist_pipeline, intent, tts
+from homeassistant.components import assist_pipeline, ffmpeg, intent, tts
from homeassistant.components.assist_pipeline import PipelineEvent
from homeassistant.components.assist_satellite import (
+ AssistSatelliteAnnouncement,
AssistSatelliteConfiguration,
AssistSatelliteEntity,
AssistSatelliteEntityDescription,
+ AssistSatelliteEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
-from .const import DOMAIN
+from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_WIDTH
from .data import WyomingService
from .devices import SatelliteDevice
from .entity import WyomingSatelliteEntity
@@ -49,6 +51,8 @@ _RESTART_SECONDS: Final = 3
_PING_TIMEOUT: Final = 5
_PING_SEND_DELAY: Final = 2
_PIPELINE_FINISH_TIMEOUT: Final = 1
+_TTS_SAMPLE_RATE: Final = 22050
+_ANNOUNCE_CHUNK_BYTES: Final = 2048 # 1024 samples
# Wyoming stage -> Assist stage
_STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = {
@@ -62,7 +66,7 @@ _STAGES: dict[PipelineStage, assist_pipeline.PipelineStage] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Wyoming Assist satellite entity."""
domain_data: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id]
@@ -83,6 +87,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
entity_description = AssistSatelliteEntityDescription(key="assist_satellite")
_attr_translation_key = "assist_satellite"
_attr_name = None
+ _attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE
def __init__(
self,
@@ -116,6 +121,10 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
self.device.set_pipeline_listener(self._pipeline_changed)
self.device.set_audio_settings_listener(self._audio_settings_changed)
+ # For announcements
+ self._ffmpeg_manager: ffmpeg.FFmpegManager | None = None
+ self._played_event_received: asyncio.Event | None = None
+
@property
def pipeline_entity_id(self) -> str | None:
"""Return the entity ID of the pipeline to use for the next conversation."""
@@ -131,9 +140,9 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
"""Options passed for text-to-speech."""
return {
tts.ATTR_PREFERRED_FORMAT: "wav",
- tts.ATTR_PREFERRED_SAMPLE_RATE: 16000,
- tts.ATTR_PREFERRED_SAMPLE_CHANNELS: 1,
- tts.ATTR_PREFERRED_SAMPLE_BYTES: 2,
+ tts.ATTR_PREFERRED_SAMPLE_RATE: _TTS_SAMPLE_RATE,
+ tts.ATTR_PREFERRED_SAMPLE_CHANNELS: SAMPLE_CHANNELS,
+ tts.ATTR_PREFERRED_SAMPLE_BYTES: SAMPLE_WIDTH,
}
async def async_added_to_hass(self) -> None:
@@ -169,7 +178,11 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
self._pipeline_ended_event.set()
self.device.set_is_active(False)
elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_START:
- self.hass.add_job(self._client.write_event(Detect().event()))
+ self.config_entry.async_create_background_task(
+ self.hass,
+ self._client.write_event(Detect().event()),
+ f"{self.entity_id} {event.type}",
+ )
elif event.type == assist_pipeline.PipelineEventType.WAKE_WORD_END:
# Wake word detection
# Inform client of wake word detection
@@ -178,46 +191,59 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
name=wake_word_output["wake_word_id"],
timestamp=wake_word_output.get("timestamp"),
)
- self.hass.add_job(self._client.write_event(detection.event()))
+ self.config_entry.async_create_background_task(
+ self.hass,
+ self._client.write_event(detection.event()),
+ f"{self.entity_id} {event.type}",
+ )
elif event.type == assist_pipeline.PipelineEventType.STT_START:
# Speech-to-text
self.device.set_is_active(True)
if event.data:
- self.hass.add_job(
+ self.config_entry.async_create_background_task(
+ self.hass,
self._client.write_event(
Transcribe(language=event.data["metadata"]["language"]).event()
- )
+ ),
+ f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.STT_VAD_START:
# User started speaking
if event.data:
- self.hass.add_job(
+ self.config_entry.async_create_background_task(
+ self.hass,
self._client.write_event(
VoiceStarted(timestamp=event.data["timestamp"]).event()
- )
+ ),
+ f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.STT_VAD_END:
# User stopped speaking
if event.data:
- self.hass.add_job(
+ self.config_entry.async_create_background_task(
+ self.hass,
self._client.write_event(
VoiceStopped(timestamp=event.data["timestamp"]).event()
- )
+ ),
+ f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.STT_END:
# Speech-to-text transcript
if event.data:
# Inform client of transript
stt_text = event.data["stt_output"]["text"]
- self.hass.add_job(
- self._client.write_event(Transcript(text=stt_text).event())
+ self.config_entry.async_create_background_task(
+ self.hass,
+ self._client.write_event(Transcript(text=stt_text).event()),
+ f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.TTS_START:
# Text-to-speech text
if event.data:
# Inform client of text
- self.hass.add_job(
+ self.config_entry.async_create_background_task(
+ self.hass,
self._client.write_event(
Synthesize(
text=event.data["tts_input"],
@@ -226,24 +252,104 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
language=event.data.get("language"),
),
).event()
- )
+ ),
+ f"{self.entity_id} {event.type}",
)
elif event.type == assist_pipeline.PipelineEventType.TTS_END:
# TTS stream
- if event.data and (tts_output := event.data["tts_output"]):
- media_id = tts_output["media_id"]
- self.hass.add_job(self._stream_tts(media_id))
+ if (
+ event.data
+ and (tts_output := event.data["tts_output"])
+ and (stream := tts.async_get_stream(self.hass, tts_output["token"]))
+ ):
+ self.config_entry.async_create_background_task(
+ self.hass,
+ self._stream_tts(stream),
+ f"{self.entity_id} {event.type}",
+ )
elif event.type == assist_pipeline.PipelineEventType.ERROR:
# Pipeline error
if event.data:
- self.hass.add_job(
+ self.config_entry.async_create_background_task(
+ self.hass,
self._client.write_event(
Error(
text=event.data["message"], code=event.data["code"]
).event()
- )
+ ),
+ f"{self.entity_id} {event.type}",
)
+ async def async_announce(self, announcement: AssistSatelliteAnnouncement) -> None:
+ """Announce media on the satellite.
+
+ Should block until the announcement is done playing.
+ """
+ assert self._client is not None
+
+ if self._ffmpeg_manager is None:
+ self._ffmpeg_manager = ffmpeg.get_ffmpeg_manager(self.hass)
+
+ if self._played_event_received is None:
+ self._played_event_received = asyncio.Event()
+
+ self._played_event_received.clear()
+ await self._client.write_event(
+ AudioStart(
+ rate=_TTS_SAMPLE_RATE,
+ width=SAMPLE_WIDTH,
+ channels=SAMPLE_CHANNELS,
+ timestamp=0,
+ ).event()
+ )
+
+ timestamp = 0
+ try:
+ # Use ffmpeg to convert to raw PCM audio with the appropriate format
+ proc = await asyncio.create_subprocess_exec(
+ self._ffmpeg_manager.binary,
+ "-i",
+ announcement.media_id,
+ "-f",
+ "s16le",
+ "-ac",
+ str(SAMPLE_CHANNELS),
+ "-ar",
+ str(_TTS_SAMPLE_RATE),
+ "-nostats",
+ "pipe:",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.PIPE,
+ close_fds=False, # use posix_spawn in CPython < 3.13
+ )
+ assert proc.stdout is not None
+ while True:
+ chunk_bytes = await proc.stdout.read(_ANNOUNCE_CHUNK_BYTES)
+ if not chunk_bytes:
+ break
+
+ chunk = AudioChunk(
+ rate=_TTS_SAMPLE_RATE,
+ width=SAMPLE_WIDTH,
+ channels=SAMPLE_CHANNELS,
+ audio=chunk_bytes,
+ timestamp=timestamp,
+ )
+ await self._client.write_event(chunk.event())
+
+ timestamp += chunk.milliseconds
+ finally:
+ await self._client.write_event(AudioStop().event())
+ if timestamp > 0:
+ # Wait the length of the audio or until we receive a played event
+ audio_seconds = timestamp / 1000
+ try:
+ async with asyncio.timeout(audio_seconds + 0.5):
+ await self._played_event_received.wait()
+ except TimeoutError:
+ # Older satellite clients will wait longer than necessary
+ _LOGGER.debug("Did not receive played event for announcement")
+
# -------------------------------------------------------------------------
def start_satellite(self) -> None:
@@ -511,6 +617,9 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
elif Played.is_type(client_event.type):
# TTS response has finished playing on satellite
self.tts_response_finished()
+
+ if self._played_event_received is not None:
+ self._played_event_received.set()
else:
_LOGGER.debug("Unexpected event from satellite: %s", client_event)
@@ -580,13 +689,16 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
await self._client.disconnect()
self._client = None
- async def _stream_tts(self, media_id: str) -> None:
+ async def _stream_tts(self, tts_result: tts.ResultStream) -> None:
"""Stream TTS WAV audio to satellite in chunks."""
assert self._client is not None
- extension, data = await tts.async_get_media_source_audio(self.hass, media_id)
- if extension != "wav":
- raise ValueError(f"Cannot stream audio format to satellite: {extension}")
+ if tts_result.extension != "wav":
+ raise ValueError(
+ f"Cannot stream audio format to satellite: {tts_result.extension}"
+ )
+
+ data = b"".join([chunk async for chunk in tts_result.async_stream_result()])
with io.BytesIO(data) as wav_io, wave.open(wav_io, "rb") as wav_file:
sample_rate = wav_file.getframerate()
diff --git a/homeassistant/components/wyoming/binary_sensor.py b/homeassistant/components/wyoming/binary_sensor.py
index 24ee073ec4d..a3652e7f70f 100644
--- a/homeassistant/components/wyoming/binary_sensor.py
+++ b/homeassistant/components/wyoming/binary_sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import WyomingSatelliteEntity
@@ -22,7 +22,7 @@ if TYPE_CHECKING:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensor entities."""
item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/wyoming/conversation.py b/homeassistant/components/wyoming/conversation.py
index 988d47925ac..5760d04bfc2 100644
--- a/homeassistant/components/wyoming/conversation.py
+++ b/homeassistant/components/wyoming/conversation.py
@@ -12,7 +12,7 @@ from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import intent
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import ulid as ulid_util
from .const import DOMAIN
@@ -26,7 +26,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Wyoming conversation."""
item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/wyoming/manifest.json b/homeassistant/components/wyoming/manifest.json
index b837d2a9e76..d75b70dffa8 100644
--- a/homeassistant/components/wyoming/manifest.json
+++ b/homeassistant/components/wyoming/manifest.json
@@ -7,7 +7,8 @@
"assist_satellite",
"assist_pipeline",
"intent",
- "conversation"
+ "conversation",
+ "ffmpeg"
],
"documentation": "https://www.home-assistant.io/integrations/wyoming",
"integration_type": "service",
diff --git a/homeassistant/components/wyoming/number.py b/homeassistant/components/wyoming/number.py
index d9a58cc3333..96ec5877545 100644
--- a/homeassistant/components/wyoming/number.py
+++ b/homeassistant/components/wyoming/number.py
@@ -8,7 +8,7 @@ from homeassistant.components.number import NumberEntityDescription, RestoreNumb
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import WyomingSatelliteEntity
@@ -24,7 +24,7 @@ _MAX_VOLUME_MULTIPLIER: Final = 10.0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Wyoming number entities."""
item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/wyoming/select.py b/homeassistant/components/wyoming/select.py
index bbcaab81710..2af0438e35f 100644
--- a/homeassistant/components/wyoming/select.py
+++ b/homeassistant/components/wyoming/select.py
@@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import restore_state
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .devices import SatelliteDevice
@@ -36,7 +36,7 @@ _DEFAULT_NOISE_SUPPRESSION_LEVEL: Final = "off"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Wyoming select entities."""
item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/wyoming/strings.json b/homeassistant/components/wyoming/strings.json
index 4a1a4c3a246..2578b0e5278 100644
--- a/homeassistant/components/wyoming/strings.json
+++ b/homeassistant/components/wyoming/strings.json
@@ -40,10 +40,10 @@
"noise_suppression_level": {
"name": "Noise suppression level",
"state": {
- "off": "Off",
- "low": "Low",
- "medium": "Medium",
- "high": "High",
+ "off": "[%key:common::state::off%]",
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]",
"max": "Max"
}
},
diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py
index a28e5fdb527..2851004a854 100644
--- a/homeassistant/components/wyoming/stt.py
+++ b/homeassistant/components/wyoming/stt.py
@@ -10,7 +10,7 @@ from wyoming.client import AsyncTcpClient
from homeassistant.components import stt
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, SAMPLE_CHANNELS, SAMPLE_RATE, SAMPLE_WIDTH
from .data import WyomingService
@@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Wyoming speech-to-text."""
item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/wyoming/switch.py b/homeassistant/components/wyoming/switch.py
index 308429331c3..9eb91d5ef39 100644
--- a/homeassistant/components/wyoming/switch.py
+++ b/homeassistant/components/wyoming/switch.py
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import restore_state
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .entity import WyomingSatelliteEntity
@@ -21,7 +21,7 @@ if TYPE_CHECKING:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up VoIP switch entities."""
item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py
index 65ce4d942f1..79e431fee98 100644
--- a/homeassistant/components/wyoming/tts.py
+++ b/homeassistant/components/wyoming/tts.py
@@ -12,7 +12,7 @@ from wyoming.tts import Synthesize, SynthesizeVoice
from homeassistant.components import tts
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_SPEAKER, DOMAIN
from .data import WyomingService
@@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Wyoming speech-to-text."""
item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py
index 64dfd60c068..2a21b7303e5 100644
--- a/homeassistant/components/wyoming/wake_word.py
+++ b/homeassistant/components/wyoming/wake_word.py
@@ -11,7 +11,7 @@ from wyoming.wake import Detect, Detection
from homeassistant.components import wake_word
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .data import WyomingService, load_wyoming_info
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Wyoming wake-word-detection."""
item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/wyoming/websocket_api.py b/homeassistant/components/wyoming/websocket_api.py
new file mode 100644
index 00000000000..613238c302a
--- /dev/null
+++ b/homeassistant/components/wyoming/websocket_api.py
@@ -0,0 +1,42 @@
+"""Wyoming Websocket API."""
+
+import logging
+from typing import Any
+
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.core import HomeAssistant, callback
+
+from .const import DOMAIN
+from .models import DomainDataItem
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def async_register_websocket_api(hass: HomeAssistant) -> None:
+ """Register the websocket API."""
+ websocket_api.async_register_command(hass, websocket_info)
+
+
+@callback
+@websocket_api.require_admin
+@websocket_api.websocket_command({vol.Required("type"): "wyoming/info"})
+def websocket_info(
+ hass: HomeAssistant,
+ connection: websocket_api.connection.ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """List service information for Wyoming all config entries."""
+ entry_items: dict[str, DomainDataItem] = hass.data.get(DOMAIN, {})
+
+ connection.send_result(
+ msg["id"],
+ {
+ "info": {
+ entry_id: item.service.info.to_dict()
+ for entry_id, item in entry_items.items()
+ }
+ },
+ )
diff --git a/homeassistant/components/xbox/binary_sensor.py b/homeassistant/components/xbox/binary_sensor.py
index af95834425a..5339c4d7a8e 100644
--- a/homeassistant/components/xbox/binary_sensor.py
+++ b/homeassistant/components/xbox/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import XboxUpdateCoordinator
@@ -18,7 +18,9 @@ PRESENCE_ATTRIBUTES = ["online", "in_party", "in_game", "in_multiplayer"]
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Xbox Live friends."""
coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
diff --git a/homeassistant/components/xbox/media_player.py b/homeassistant/components/xbox/media_player.py
index 7298c7e2da3..6464b2417cc 100644
--- a/homeassistant/components/xbox/media_player.py
+++ b/homeassistant/components/xbox/media_player.py
@@ -24,7 +24,7 @@ from homeassistant.components.media_player import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .browse_media import build_item_response
@@ -56,7 +56,9 @@ XBOX_STATE_MAP: dict[PlaybackState | PowerState, MediaPlayerState | None] = {
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Xbox media_player from a config entry."""
client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"]
diff --git a/homeassistant/components/xbox/remote.py b/homeassistant/components/xbox/remote.py
index 1b4ffdf35cc..4e5893ddb13 100644
--- a/homeassistant/components/xbox/remote.py
+++ b/homeassistant/components/xbox/remote.py
@@ -24,7 +24,7 @@ from homeassistant.components.remote import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
@@ -32,7 +32,9 @@ from .coordinator import ConsoleData, XboxUpdateCoordinator
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Xbox media_player from a config entry."""
client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"]
diff --git a/homeassistant/components/xbox/sensor.py b/homeassistant/components/xbox/sensor.py
index f269e0a5bb9..da53557a2d3 100644
--- a/homeassistant/components/xbox/sensor.py
+++ b/homeassistant/components/xbox/sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import XboxUpdateCoordinator
@@ -20,7 +20,7 @@ SENSOR_ATTRIBUTES = ["status", "gamer_score", "account_tier", "gold_tenure"]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Xbox Live friends."""
coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id][
diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py
index 579994aaf6b..6e4d143d84e 100644
--- a/homeassistant/components/xiaomi_aqara/__init__.py
+++ b/homeassistant/components/xiaomi_aqara/__init__.py
@@ -7,7 +7,7 @@ import voluptuous as vol
from xiaomi_gateway import AsyncXiaomiGatewayMulticast, XiaomiGateway
from homeassistant.components import persistent_notification
-from homeassistant.config_entries import ConfigEntry, ConfigEntryState
+from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
CONF_HOST,
@@ -216,12 +216,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
if unload_ok:
hass.data[DOMAIN][GATEWAYS_KEY].pop(config_entry.entry_id)
- loaded_entries = [
- entry
- for entry in hass.config_entries.async_entries(DOMAIN)
- if entry.state == ConfigEntryState.LOADED
- ]
- if len(loaded_entries) == 1:
+ if not hass.config_entries.async_loaded_entries(DOMAIN):
# No gateways left, stop Xiaomi socket
unsub_stop = hass.data[DOMAIN].pop(KEY_UNSUB_STOP)
unsub_stop()
diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py
index ad91dda2173..47cc823ad7f 100644
--- a/homeassistant/components/xiaomi_aqara/binary_sensor.py
+++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.restore_state import RestoreEntity
@@ -32,7 +32,7 @@ ATTR_DENSITY = "Density"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Xiaomi devices."""
entities: list[XiaomiBinarySensor] = []
diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py
index e073ef6b683..82d5129ac5e 100644
--- a/homeassistant/components/xiaomi_aqara/cover.py
+++ b/homeassistant/components/xiaomi_aqara/cover.py
@@ -5,7 +5,7 @@ from typing import Any
from homeassistant.components.cover import ATTR_POSITION, CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, GATEWAYS_KEY
from .entity import XiaomiDevice
@@ -19,7 +19,7 @@ DATA_KEY_PROTO_V2 = "curtain_status"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Xiaomi devices."""
entities = []
diff --git a/homeassistant/components/xiaomi_aqara/entity.py b/homeassistant/components/xiaomi_aqara/entity.py
index db47015c0cf..59107984ddf 100644
--- a/homeassistant/components/xiaomi_aqara/entity.py
+++ b/homeassistant/components/xiaomi_aqara/entity.py
@@ -57,7 +57,7 @@ class XiaomiDevice(Entity):
self._is_gateway = False
self._device_id = self._sid
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Start unavailability tracking."""
self._xiaomi_hub.callbacks[self._sid].append(self.push_data)
self._async_track_unavailable()
@@ -100,7 +100,7 @@ class XiaomiDevice(Entity):
return device_info
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if entity is available."""
return self._is_available
diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py
index 11ce7a0107b..ef1f06695f9 100644
--- a/homeassistant/components/xiaomi_aqara/light.py
+++ b/homeassistant/components/xiaomi_aqara/light.py
@@ -13,7 +13,7 @@ from homeassistant.components.light import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .const import DOMAIN, GATEWAYS_KEY
@@ -25,7 +25,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Xiaomi devices."""
entities = []
diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py
index 5e538f25699..b3f4e9f4caf 100644
--- a/homeassistant/components/xiaomi_aqara/lock.py
+++ b/homeassistant/components/xiaomi_aqara/lock.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from homeassistant.components.lock import LockEntity, LockState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from .const import DOMAIN, GATEWAYS_KEY
@@ -24,7 +24,7 @@ UNLOCK_MAINTAIN_TIME = 5
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Xiaomi devices."""
gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id]
diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py
index 49358276a48..59ccee5a1a8 100644
--- a/homeassistant/components/xiaomi_aqara/sensor.py
+++ b/homeassistant/components/xiaomi_aqara/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY, POWER_MODELS
from .entity import XiaomiDevice
@@ -85,7 +85,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Xiaomi devices."""
entities: list[XiaomiSensor | XiaomiBatterySensor] = []
diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py
index f66cf8c7603..7d3abf47bd1 100644
--- a/homeassistant/components/xiaomi_aqara/switch.py
+++ b/homeassistant/components/xiaomi_aqara/switch.py
@@ -6,7 +6,7 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, GATEWAYS_KEY
from .entity import XiaomiDevice
@@ -29,7 +29,7 @@ IN_USE = "inuse"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Perform the setup for Xiaomi devices."""
entities = []
diff --git a/homeassistant/components/xiaomi_ble/binary_sensor.py b/homeassistant/components/xiaomi_ble/binary_sensor.py
index b853f83b967..8956e207253 100644
--- a/homeassistant/components/xiaomi_ble/binary_sensor.py
+++ b/homeassistant/components/xiaomi_ble/binary_sensor.py
@@ -18,7 +18,7 @@ from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorEntity,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .coordinator import XiaomiPassiveBluetoothDataProcessor
@@ -135,7 +135,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: XiaomiBLEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Xiaomi BLE sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/xiaomi_ble/const.py b/homeassistant/components/xiaomi_ble/const.py
index 8ea99cf1f84..aab443c67fa 100644
--- a/homeassistant/components/xiaomi_ble/const.py
+++ b/homeassistant/components/xiaomi_ble/const.py
@@ -37,6 +37,7 @@ LOCK_FINGERPRINT = "lock_fingerprint"
MOTION_DEVICE: Final = "motion_device"
DOUBLE_BUTTON: Final = "double_button"
TRIPPLE_BUTTON: Final = "tripple_button"
+QUADRUPLE_BUTTON: Final = "quadruple_button"
REMOTE: Final = "remote"
REMOTE_FAN: Final = "remote_fan"
REMOTE_VENFAN: Final = "remote_ventilator_fan"
@@ -48,6 +49,7 @@ BUTTON_PRESS_LONG: Final = "button_press_long"
BUTTON_PRESS_DOUBLE_LONG: Final = "button_press_double_long"
DOUBLE_BUTTON_PRESS_DOUBLE_LONG: Final = "double_button_press_double_long"
TRIPPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "tripple_button_press_double_long"
+QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG: Final = "quadruple_button_press_double_long"
class XiaomiBleEvent(TypedDict):
diff --git a/homeassistant/components/xiaomi_ble/device_trigger.py b/homeassistant/components/xiaomi_ble/device_trigger.py
index 119424788db..3c5488a1e74 100644
--- a/homeassistant/components/xiaomi_ble/device_trigger.py
+++ b/homeassistant/components/xiaomi_ble/device_trigger.py
@@ -47,6 +47,8 @@ from .const import (
LOCK_FINGERPRINT,
MOTION,
MOTION_DEVICE,
+ QUADRUPLE_BUTTON,
+ QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG,
REMOTE,
REMOTE_BATHROOM,
REMOTE_FAN,
@@ -123,6 +125,12 @@ EVENT_TYPES = {
DIMMER: ["dimmer"],
DOUBLE_BUTTON: ["button_left", "button_right"],
TRIPPLE_BUTTON: ["button_left", "button_middle", "button_right"],
+ QUADRUPLE_BUTTON: [
+ "button_left",
+ "button_mid_left",
+ "button_mid_right",
+ "button_right",
+ ],
ERROR: ["error"],
FINGERPRINT: ["fingerprint"],
LOCK: ["lock"],
@@ -205,6 +213,11 @@ TRIGGER_MODEL_DATA = {
event_types=EVENT_TYPES[TRIPPLE_BUTTON],
triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG],
),
+ QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG: TriggerModelData(
+ event_class=EVENT_CLASS_BUTTON,
+ event_types=EVENT_TYPES[QUADRUPLE_BUTTON],
+ triggers=TRIGGERS_BY_TYPE[BUTTON_PRESS_DOUBLE_LONG],
+ ),
ERROR: TriggerModelData(
event_class=EVENT_CLASS_ERROR,
event_types=EVENT_TYPES[ERROR],
@@ -261,6 +274,8 @@ MODEL_DATA = {
"XMWXKG01YL": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG],
"K9B-2BTN": TRIGGER_MODEL_DATA[DOUBLE_BUTTON_PRESS_DOUBLE_LONG],
"K9B-3BTN": TRIGGER_MODEL_DATA[TRIPPLE_BUTTON_PRESS_DOUBLE_LONG],
+ "KS1": TRIGGER_MODEL_DATA[QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG],
+ "KS1BP": TRIGGER_MODEL_DATA[QUADRUPLE_BUTTON_PRESS_DOUBLE_LONG],
"YLYK01YL": TRIGGER_MODEL_DATA[REMOTE],
"YLYK01YL-FANRC": TRIGGER_MODEL_DATA[REMOTE_FAN],
"YLYK01YL-VENFAN": TRIGGER_MODEL_DATA[REMOTE_VENFAN],
diff --git a/homeassistant/components/xiaomi_ble/event.py b/homeassistant/components/xiaomi_ble/event.py
index 7265bcd112c..c5f6e01e575 100644
--- a/homeassistant/components/xiaomi_ble/event.py
+++ b/homeassistant/components/xiaomi_ble/event.py
@@ -12,7 +12,7 @@ from homeassistant.components.event import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import format_discovered_event_class, format_event_dispatcher_name
from .const import (
@@ -183,7 +183,7 @@ class XiaomiEventEntity(EventEntity):
async def async_setup_entry(
hass: HomeAssistant,
entry: XiaomiBLEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Xiaomi event."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json
index 26dd82c73bc..a908d4747ad 100644
--- a/homeassistant/components/xiaomi_ble/manifest.json
+++ b/homeassistant/components/xiaomi_ble/manifest.json
@@ -24,5 +24,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
"iot_class": "local_push",
- "requirements": ["xiaomi-ble==0.33.0"]
+ "requirements": ["xiaomi-ble==0.37.0"]
}
diff --git a/homeassistant/components/xiaomi_ble/sensor.py b/homeassistant/components/xiaomi_ble/sensor.py
index ba8f64383ee..0fcae1925bb 100644
--- a/homeassistant/components/xiaomi_ble/sensor.py
+++ b/homeassistant/components/xiaomi_ble/sensor.py
@@ -9,9 +9,11 @@ from xiaomi_ble.parser import ExtendedSensorDeviceClass
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataUpdate,
+ PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
+ EntityDescription,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
@@ -31,7 +33,7 @@ from homeassistant.const import (
UnitOfTime,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
from .coordinator import XiaomiPassiveBluetoothDataProcessor
@@ -78,6 +80,7 @@ SENSOR_DESCRIPTIONS = {
icon="mdi:omega",
native_unit_of_measurement=Units.OHM,
state_class=SensorStateClass.MEASUREMENT,
+ translation_key="impedance",
),
# Mass sensor (kg)
(DeviceClass.MASS, Units.MASS_KILOGRAMS): SensorEntityDescription(
@@ -93,6 +96,7 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfMass.KILOGRAMS,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
+ translation_key="weight_non_stabilized",
),
(DeviceClass.MOISTURE, Units.PERCENTAGE): SensorEntityDescription(
key=f"{DeviceClass.MOISTURE}_{Units.PERCENTAGE}",
@@ -173,6 +177,25 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfTime.MINUTES,
state_class=SensorStateClass.MEASUREMENT,
),
+ # Low frequency impedance sensor (ohm)
+ (ExtendedSensorDeviceClass.IMPEDANCE_LOW, Units.OHM): SensorEntityDescription(
+ key=str(ExtendedSensorDeviceClass.IMPEDANCE_LOW),
+ native_unit_of_measurement=Units.OHM,
+ state_class=SensorStateClass.MEASUREMENT,
+ icon="mdi:omega",
+ ),
+ # Heart rate sensor (bpm)
+ (ExtendedSensorDeviceClass.HEART_RATE, "bpm"): SensorEntityDescription(
+ key=str(ExtendedSensorDeviceClass.HEART_RATE),
+ native_unit_of_measurement="bpm",
+ state_class=SensorStateClass.MEASUREMENT,
+ icon="mdi:heart-pulse",
+ ),
+ # User profile ID sensor
+ (ExtendedSensorDeviceClass.PROFILE_ID, None): SensorEntityDescription(
+ key=str(ExtendedSensorDeviceClass.PROFILE_ID),
+ icon="mdi:identifier",
+ ),
}
@@ -180,18 +203,20 @@ def sensor_update_to_bluetooth_data_update(
sensor_update: SensorUpdate,
) -> PassiveBluetoothDataUpdate[float | None]:
"""Convert a sensor update to a bluetooth data update."""
+ entity_descriptions: dict[PassiveBluetoothEntityKey, EntityDescription] = {
+ device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[
+ (description.device_class, description.native_unit_of_measurement)
+ ]
+ for device_key, description in sensor_update.entity_descriptions.items()
+ if description.device_class
+ }
+
return PassiveBluetoothDataUpdate(
devices={
device_id: sensor_device_info_to_hass_device_info(device_info)
for device_id, device_info in sensor_update.devices.items()
},
- entity_descriptions={
- device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[
- (description.device_class, description.native_unit_of_measurement)
- ]
- for device_key, description in sensor_update.entity_descriptions.items()
- if description.device_class
- },
+ entity_descriptions=entity_descriptions,
entity_data={
device_key_to_bluetooth_entity_key(device_key): cast(
float | None, sensor_values.native_value
@@ -201,6 +226,17 @@ def sensor_update_to_bluetooth_data_update(
entity_names={
device_key_to_bluetooth_entity_key(device_key): sensor_values.name
for device_key, sensor_values in sensor_update.entity_values.items()
+ # Add names where the entity description has neither a translation_key nor
+ # a device_class
+ if (
+ description := entity_descriptions.get(
+ device_key_to_bluetooth_entity_key(device_key)
+ )
+ )
+ is None
+ or (
+ description.translation_key is None and description.device_class is None
+ )
},
)
@@ -208,7 +244,7 @@ def sensor_update_to_bluetooth_data_update(
async def async_setup_entry(
hass: HomeAssistant,
entry: XiaomiBLEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Xiaomi BLE sensors."""
coordinator = entry.runtime_data
diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json
index 4ea4a47c61e..06b49b8e86f 100644
--- a/homeassistant/components/xiaomi_ble/strings.json
+++ b/homeassistant/components/xiaomi_ble/strings.json
@@ -86,6 +86,8 @@
"trigger_type": {
"button": "Button \"{subtype}\"",
"button_left": "Button Left \"{subtype}\"",
+ "button_mid_left": "Button Mid Left \"{subtype}\"",
+ "button_mid_right": "Button Mid Right \"{subtype}\"",
"button_middle": "Button Middle \"{subtype}\"",
"button_right": "Button Right \"{subtype}\"",
"button_on": "Button On \"{subtype}\"",
@@ -227,6 +229,14 @@
}
}
}
+ },
+ "sensor": {
+ "impedance": {
+ "name": "Impedance"
+ },
+ "weight_non_stabilized": {
+ "name": "Weight non stabilized"
+ }
}
}
}
diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py
index 199d9161353..1ce37c661a2 100644
--- a/homeassistant/components/xiaomi_miio/air_quality.py
+++ b/homeassistant/components/xiaomi_miio/air_quality.py
@@ -9,7 +9,7 @@ from homeassistant.components.air_quality import AirQualityEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MODEL, CONF_TOKEN
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_FLOW_TYPE,
@@ -242,7 +242,7 @@ DEVICE_MAP: dict[str, dict[str, Callable]] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Xiaomi Air Quality from a config entry."""
entities = []
diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py
index 9c06198bc7e..ecab5228f6e 100644
--- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py
+++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py
@@ -15,7 +15,7 @@ from homeassistant.components.alarm_control_panel import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_GATEWAY, DOMAIN
@@ -29,7 +29,7 @@ XIAOMI_STATE_ARMING_VALUE = "oning"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Xiaomi Gateway Alarm from a config entry."""
entities = []
diff --git a/homeassistant/components/xiaomi_miio/binary_sensor.py b/homeassistant/components/xiaomi_miio/binary_sensor.py
index a5ab7e56e6b..213886691f0 100644
--- a/homeassistant/components/xiaomi_miio/binary_sensor.py
+++ b/homeassistant/components/xiaomi_miio/binary_sensor.py
@@ -14,7 +14,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VacuumCoordinatorDataAttributes
from .const import (
@@ -171,7 +171,7 @@ def _setup_vacuum_sensors(hass, config_entry, async_add_entities):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Xiaomi sensor from a config entry."""
entities = []
diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py
index 9a64941f398..a7bcb3a12fe 100644
--- a/homeassistant/components/xiaomi_miio/button.py
+++ b/homeassistant/components/xiaomi_miio/button.py
@@ -14,7 +14,7 @@ from homeassistant.components.button import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL, EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
DOMAIN,
@@ -117,14 +117,14 @@ MODEL_TO_BUTTON_MAP: dict[str, tuple[str, ...]] = {
ATTR_RESET_DUST_FILTER,
ATTR_RESET_UPPER_FILTER,
),
- **{model: BUTTONS_FOR_VACUUM for model in MODELS_VACUUM},
+ **dict.fromkeys(MODELS_VACUUM, BUTTONS_FOR_VACUUM),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the button from a config entry."""
model = config_entry.data[CONF_MODEL]
diff --git a/homeassistant/components/xiaomi_miio/entity.py b/homeassistant/components/xiaomi_miio/entity.py
index 0343a7526d7..ba1148985ba 100644
--- a/homeassistant/components/xiaomi_miio/entity.py
+++ b/homeassistant/components/xiaomi_miio/entity.py
@@ -185,7 +185,7 @@ class XiaomiGatewayDevice(CoordinatorEntity, Entity):
)
@property
- def available(self):
+ def available(self) -> bool:
"""Return if entity is available."""
if self.coordinator.data is None:
return False
diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py
index 12ed9f7195b..31d5dd9de2c 100644
--- a/homeassistant/components/xiaomi_miio/fan.py
+++ b/homeassistant/components/xiaomi_miio/fan.py
@@ -34,7 +34,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICE, CONF_MODEL
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -205,7 +205,7 @@ FAN_DIRECTIONS_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fan from a config entry."""
entities: list[FanEntity] = []
diff --git a/homeassistant/components/xiaomi_miio/humidifier.py b/homeassistant/components/xiaomi_miio/humidifier.py
index 4701345756a..f19fbec5e78 100644
--- a/homeassistant/components/xiaomi_miio/humidifier.py
+++ b/homeassistant/components/xiaomi_miio/humidifier.py
@@ -23,7 +23,7 @@ from homeassistant.components.humidifier import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_MODE, CONF_DEVICE, CONF_MODEL
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import percentage_to_ranged_value
from .const import (
@@ -71,7 +71,7 @@ AVAILABLE_MODES_OTHER = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Humidifier from a config entry."""
if config_entry.data[CONF_FLOW_TYPE] != CONF_DEVICE:
diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py
index c1f778928d9..81f68306cbc 100644
--- a/homeassistant/components/xiaomi_miio/light.py
+++ b/homeassistant/components/xiaomi_miio/light.py
@@ -44,7 +44,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util, dt as dt_util
from .const import (
@@ -132,7 +132,7 @@ SERVICE_TO_METHOD = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Xiaomi light from a config entry."""
entities: list[LightEntity] = []
diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py
index a3c501aad3f..f30d4728275 100644
--- a/homeassistant/components/xiaomi_miio/number.py
+++ b/homeassistant/components/xiaomi_miio/number.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
@@ -289,7 +289,7 @@ FAVORITE_LEVEL_VALUES = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Selectors from a config entry."""
entities = []
diff --git a/homeassistant/components/xiaomi_miio/select.py b/homeassistant/components/xiaomi_miio/select.py
index 6729ce2e0f4..94a93fc1fae 100644
--- a/homeassistant/components/xiaomi_miio/select.py
+++ b/homeassistant/components/xiaomi_miio/select.py
@@ -32,7 +32,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, CONF_MODEL, EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_FLOW_TYPE,
@@ -205,7 +205,7 @@ SELECTOR_TYPES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Selectors from a config entry."""
if config_entry.data[CONF_FLOW_TYPE] != CONF_DEVICE:
diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py
index aafcba97487..6f623c46af8 100644
--- a/homeassistant/components/xiaomi_miio/sensor.py
+++ b/homeassistant/components/xiaomi_miio/sensor.py
@@ -45,7 +45,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from . import VacuumCoordinatorDataAttributes
@@ -755,7 +755,7 @@ def _setup_vacuum_sensors(hass, config_entry, async_add_entities):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Xiaomi sensor from a config entry."""
entities: list[SensorEntity] = []
diff --git a/homeassistant/components/xiaomi_miio/strings.json b/homeassistant/components/xiaomi_miio/strings.json
index dd49ba502f0..a5af3d8bd1f 100644
--- a/homeassistant/components/xiaomi_miio/strings.json
+++ b/homeassistant/components/xiaomi_miio/strings.json
@@ -14,7 +14,7 @@
"unknown_device": "The device model is not known, not able to set up the device using config flow.",
"cloud_no_devices": "No devices found in this Xiaomi Miio cloud account.",
"cloud_credentials_incomplete": "Cloud credentials incomplete, please fill in username, password and country",
- "cloud_login_error": "Could not login to Xiaomi Miio Cloud, check the credentials."
+ "cloud_login_error": "Could not log in to Xiaomi Miio Cloud, check the credentials."
},
"flow_title": "{name}",
"step": {
@@ -82,15 +82,15 @@
"airpurifier_mode": {
"state": {
"silent": "Silent",
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
"favorite": "Favorite"
}
},
"ptc_level": {
"state": {
- "low": "Low",
- "medium": "Medium",
- "high": "High"
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
}
}
},
@@ -100,7 +100,7 @@
"preset_mode": {
"state": {
"nature": "Nature",
- "normal": "Normal"
+ "normal": "[%key:common::state::normal%]"
}
}
}
@@ -331,7 +331,7 @@
"fields": {
"entity_id": {
"name": "Entity ID",
- "description": "Name of the xiaomi miio entity."
+ "description": "Name of the Xiaomi Miio entity."
}
}
},
@@ -365,7 +365,7 @@
},
"light_set_delayed_turn_off": {
"name": "Light set delayed turn off",
- "description": "Delayed turn off.",
+ "description": "Sets the delayed turning off of a light.",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -373,7 +373,7 @@
},
"time_period": {
"name": "Time period",
- "description": "Time period for the delayed turn off."
+ "description": "Time period for the delayed turning off."
}
}
},
@@ -398,8 +398,8 @@
}
},
"light_night_light_mode_on": {
- "name": "Night light mode on",
- "description": "Turns the eyecare mode on (EYECARE SMART LAMP 2 ONLY).",
+ "name": "Light night light mode on",
+ "description": "Turns on the night light mode of a light (EYECARE SMART LAMP 2 ONLY).",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -408,8 +408,8 @@
}
},
"light_night_light_mode_off": {
- "name": "Night light mode off",
- "description": "Turns the eyecare mode fan_set_dry_off (EYECARE SMART LAMP 2 ONLY).",
+ "name": "Light night light mode off",
+ "description": "Turns off the night light mode of a light (EYECARE SMART LAMP 2 ONLY).",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -419,7 +419,7 @@
},
"light_eyecare_mode_on": {
"name": "Light eyecare mode on",
- "description": "[%key:component::xiaomi_miio::services::light_reminder_on::description%]",
+ "description": "Turns on the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -429,7 +429,7 @@
},
"light_eyecare_mode_off": {
"name": "Light eyecare mode off",
- "description": "[%key:component::xiaomi_miio::services::light_reminder_off::description%]",
+ "description": "Turns off the eyecare mode of a light (EYECARE SMART LAMP 2 ONLY).",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -439,7 +439,7 @@
},
"remote_learn_command": {
"name": "Remote learn command",
- "description": "Learns an IR command, select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.",
+ "description": "Learns an IR command. Select **Perform action**, point the remote at the IR device, and the learned command will be shown as a notification in Overview.",
"fields": {
"slot": {
"name": "Slot",
@@ -447,21 +447,21 @@
},
"timeout": {
"name": "Timeout",
- "description": "Define the timeout, before which the command must be learned."
+ "description": "Define the timeout before which the command must be learned."
}
}
},
"remote_set_led_on": {
"name": "Remote set LED on",
- "description": "Turns on blue LED."
+ "description": "Turns on the remote’s blue LED."
},
"remote_set_led_off": {
"name": "Remote set LED off",
- "description": "Turns off blue LED."
+ "description": "Turns off the remote’s blue LED."
},
"switch_set_wifi_led_on": {
"name": "Switch set Wi-Fi LED on",
- "description": "Turns the Wi-Fi LED on.",
+ "description": "Turns on the Wi-Fi LED of a switch.",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -471,7 +471,7 @@
},
"switch_set_wifi_led_off": {
"name": "Switch set Wi-Fi LED off",
- "description": "Turns the Wi-Fi LED off.",
+ "description": "Turns off the Wi-Fi LED of a switch.",
"fields": {
"entity_id": {
"name": "Entity ID",
@@ -509,7 +509,7 @@
},
"vacuum_remote_control_start": {
"name": "Vacuum remote control start",
- "description": "Starts remote control of the vacuum cleaner. You can then move it with `remote_control_move`, when done call `remote_control_stop`."
+ "description": "Starts remote control of the vacuum cleaner. You can then move it with the 'Vacuum remote control move' action, when done use 'Vacuum remote control stop'."
},
"vacuum_remote_control_stop": {
"name": "Vacuum remote control stop",
@@ -517,7 +517,7 @@
},
"vacuum_remote_control_move": {
"name": "Vacuum remote control move",
- "description": "Remote controls the vacuum cleaner, make sure you first set it in remote control mode with `remote_control_start`.",
+ "description": "Remote controls the vacuum cleaner, make sure you first set it in remote control mode with the 'Vacuum remote control start' action.",
"fields": {
"velocity": {
"name": "Velocity",
diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py
index b4c4300dbe8..e4b94aebc20 100644
--- a/homeassistant/components/xiaomi_miio/switch.py
+++ b/homeassistant/components/xiaomi_miio/switch.py
@@ -30,7 +30,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_FLOW_TYPE,
@@ -341,7 +341,7 @@ SWITCH_TYPES = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switch from a config entry."""
model = config_entry.data[CONF_MODEL]
diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py
index 532eb9581cd..1cbc79b89f3 100644
--- a/homeassistant/components/xiaomi_miio/vacuum.py
+++ b/homeassistant/components/xiaomi_miio/vacuum.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from homeassistant.util.dt import as_utc
@@ -79,7 +79,7 @@ STATE_CODE_TO_STATE = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Xiaomi vacuum cleaner robot from a config entry."""
entities = []
diff --git a/homeassistant/components/xs1/entity.py b/homeassistant/components/xs1/entity.py
index 7239a6fd446..c1ec43ec33c 100644
--- a/homeassistant/components/xs1/entity.py
+++ b/homeassistant/components/xs1/entity.py
@@ -17,7 +17,7 @@ class XS1DeviceEntity(Entity):
"""Initialize the XS1 device."""
self.device = device
- async def async_update(self):
+ async def async_update(self) -> None:
"""Retrieve latest device state."""
async with UPDATE_LOCK:
await self.hass.async_add_executor_job(self.device.update)
diff --git a/homeassistant/components/yale/binary_sensor.py b/homeassistant/components/yale/binary_sensor.py
index dbb00ad7d42..bb9acb16644 100644
--- a/homeassistant/components/yale/binary_sensor.py
+++ b/homeassistant/components/yale/binary_sensor.py
@@ -21,7 +21,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from . import YaleConfigEntry, YaleData
@@ -92,7 +92,7 @@ SENSOR_TYPES_DOORBELL: tuple[YaleDoorbellBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: YaleConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yale binary sensors."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/yale/button.py b/homeassistant/components/yale/button.py
index b04ad638f0c..005d477e4ca 100644
--- a/homeassistant/components/yale/button.py
+++ b/homeassistant/components/yale/button.py
@@ -2,7 +2,7 @@
from homeassistant.components.button import ButtonEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YaleConfigEntry
from .entity import YaleEntity
@@ -11,7 +11,7 @@ from .entity import YaleEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: YaleConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Yale lock wake buttons."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/yale/camera.py b/homeassistant/components/yale/camera.py
index 217e8f5f6fd..acabba23b59 100644
--- a/homeassistant/components/yale/camera.py
+++ b/homeassistant/components/yale/camera.py
@@ -12,7 +12,7 @@ from yalexs.util import update_doorbell_image_from_activity
from homeassistant.components.camera import Camera
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import aiohttp_client
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YaleConfigEntry, YaleData
from .const import DEFAULT_NAME, DEFAULT_TIMEOUT
@@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: YaleConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Yale cameras."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/yale/event.py b/homeassistant/components/yale/event.py
index 935ba7376f8..0ea7694be6d 100644
--- a/homeassistant/components/yale/event.py
+++ b/homeassistant/components/yale/event.py
@@ -16,7 +16,7 @@ from homeassistant.components.event import (
EventEntityDescription,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YaleConfigEntry, YaleData
from .entity import YaleDescriptionEntity
@@ -59,7 +59,7 @@ TYPES_DOORBELL: tuple[YaleEventEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: YaleConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the yale event platform."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/yale/lock.py b/homeassistant/components/yale/lock.py
index 7fdad118cde..079c1dcd3dd 100644
--- a/homeassistant/components/yale/lock.py
+++ b/homeassistant/components/yale/lock.py
@@ -14,7 +14,7 @@ from yalexs.util import get_latest_activity, update_lock_detail_from_activity
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity, LockEntityFeature
from homeassistant.const import ATTR_BATTERY_LEVEL
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.util import dt as dt_util
@@ -29,7 +29,7 @@ LOCK_JAMMED_ERR = 531
async def async_setup_entry(
hass: HomeAssistant,
config_entry: YaleConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Yale locks."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/yale/sensor.py b/homeassistant/components/yale/sensor.py
index bb3d4317277..91ecbea704d 100644
--- a/homeassistant/components/yale/sensor.py
+++ b/homeassistant/components/yale/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
EntityCategory,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YaleConfigEntry
from .const import (
@@ -82,7 +82,7 @@ SENSOR_TYPE_KEYPAD_BATTERY = YaleSensorEntityDescription[KeypadDetail](
async def async_setup_entry(
hass: HomeAssistant,
config_entry: YaleConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yale sensors."""
data = config_entry.runtime_data
diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py
index 8244d96064a..b443ba016d6 100644
--- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py
+++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py
@@ -17,7 +17,7 @@ from homeassistant.components.alarm_control_panel import (
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YaleConfigEntry
from .const import DOMAIN, STATE_MAP, YALE_ALL_ERRORS
@@ -26,7 +26,9 @@ from .entity import YaleAlarmEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: YaleConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the alarm entry."""
diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py
index fa9584505e2..20fe3648eed 100644
--- a/homeassistant/components/yale_smart_alarm/binary_sensor.py
+++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YaleConfigEntry
from .coordinator import YaleDataUpdateCoordinator
@@ -44,7 +44,9 @@ SENSOR_TYPES = (
async def async_setup_entry(
- hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: YaleConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yale binary sensor entry."""
diff --git a/homeassistant/components/yale_smart_alarm/button.py b/homeassistant/components/yale_smart_alarm/button.py
index 0e53c814fd4..0875ab4514d 100644
--- a/homeassistant/components/yale_smart_alarm/button.py
+++ b/homeassistant/components/yale_smart_alarm/button.py
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YaleConfigEntry
from .const import DOMAIN, YALE_ALL_ERRORS
@@ -25,7 +25,7 @@ BUTTON_TYPES = (
async def async_setup_entry(
hass: HomeAssistant,
entry: YaleConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the button from a config entry."""
diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py
index 7a93baf0827..f4fae531b67 100644
--- a/homeassistant/components/yale_smart_alarm/lock.py
+++ b/homeassistant/components/yale_smart_alarm/lock.py
@@ -10,7 +10,7 @@ from homeassistant.components.lock import LockEntity, LockState
from homeassistant.const import ATTR_CODE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YaleConfigEntry
from .const import (
@@ -30,7 +30,9 @@ LOCK_STATE_MAP = {
async def async_setup_entry(
- hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: YaleConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yale lock entry."""
diff --git a/homeassistant/components/yale_smart_alarm/select.py b/homeassistant/components/yale_smart_alarm/select.py
index 55b56dd8e54..0b443e762e6 100644
--- a/homeassistant/components/yale_smart_alarm/select.py
+++ b/homeassistant/components/yale_smart_alarm/select.py
@@ -6,7 +6,7 @@ from yalesmartalarmclient import YaleLock, YaleLockVolume
from homeassistant.components.select import SelectEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YaleConfigEntry
from .coordinator import YaleDataUpdateCoordinator
@@ -16,7 +16,9 @@ VOLUME_OPTIONS = {value.name.lower(): str(value.value) for value in YaleLockVolu
async def async_setup_entry(
- hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: YaleConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yale select entry."""
diff --git a/homeassistant/components/yale_smart_alarm/sensor.py b/homeassistant/components/yale_smart_alarm/sensor.py
index 50343f2e41f..14301d0c6b5 100644
--- a/homeassistant/components/yale_smart_alarm/sensor.py
+++ b/homeassistant/components/yale_smart_alarm/sensor.py
@@ -7,7 +7,7 @@ from typing import cast
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import YaleConfigEntry
@@ -15,7 +15,9 @@ from .entity import YaleEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: YaleConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yale sensor entry."""
diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json
index ebcf0b3af63..fd8d403da8d 100644
--- a/homeassistant/components/yale_smart_alarm/strings.json
+++ b/homeassistant/components/yale_smart_alarm/strings.json
@@ -71,8 +71,8 @@
"volume": {
"name": "Volume",
"state": {
- "high": "High",
- "low": "Low",
+ "high": "[%key:common::state::high%]",
+ "low": "[%key:common::state::low%]",
"off": "[%key:common::state::off%]"
}
}
diff --git a/homeassistant/components/yale_smart_alarm/switch.py b/homeassistant/components/yale_smart_alarm/switch.py
index e8c0817c2de..e4523a66802 100644
--- a/homeassistant/components/yale_smart_alarm/switch.py
+++ b/homeassistant/components/yale_smart_alarm/switch.py
@@ -8,7 +8,7 @@ from yalesmartalarmclient import YaleLock
from homeassistant.components.switch import SwitchEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YaleConfigEntry
from .coordinator import YaleDataUpdateCoordinator
@@ -16,7 +16,9 @@ from .entity import YaleLockEntity
async def async_setup_entry(
- hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: YaleConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Yale switch entry."""
diff --git a/homeassistant/components/yalexs_ble/binary_sensor.py b/homeassistant/components/yalexs_ble/binary_sensor.py
index 7cd142bb9ba..dc924486df2 100644
--- a/homeassistant/components/yalexs_ble/binary_sensor.py
+++ b/homeassistant/components/yalexs_ble/binary_sensor.py
@@ -9,7 +9,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YALEXSBLEConfigEntry
from .entity import YALEXSBLEEntity
@@ -18,7 +18,7 @@ from .entity import YALEXSBLEEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: YALEXSBLEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up YALE XS binary sensors."""
data = entry.runtime_data
diff --git a/homeassistant/components/yalexs_ble/lock.py b/homeassistant/components/yalexs_ble/lock.py
index 6eb32e3f78a..78b92ab9eb1 100644
--- a/homeassistant/components/yalexs_ble/lock.py
+++ b/homeassistant/components/yalexs_ble/lock.py
@@ -8,7 +8,7 @@ from yalexs_ble import ConnectionInfo, LockInfo, LockState, LockStatus
from homeassistant.components.lock import LockEntity
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YALEXSBLEConfigEntry
from .entity import YALEXSBLEEntity
@@ -17,7 +17,7 @@ from .entity import YALEXSBLEEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: YALEXSBLEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up locks."""
async_add_entities([YaleXSBLELock(entry.runtime_data)])
diff --git a/homeassistant/components/yalexs_ble/sensor.py b/homeassistant/components/yalexs_ble/sensor.py
index 90f61219e0b..bc9312effe3 100644
--- a/homeassistant/components/yalexs_ble/sensor.py
+++ b/homeassistant/components/yalexs_ble/sensor.py
@@ -20,7 +20,7 @@ from homeassistant.const import (
UnitOfElectricPotential,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import YALEXSBLEConfigEntry
from .entity import YALEXSBLEEntity
@@ -75,7 +75,7 @@ SENSORS: tuple[YaleXSBLESensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: YALEXSBLEConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up YALE XS Bluetooth sensors."""
data = entry.runtime_data
diff --git a/homeassistant/components/yamaha_musiccast/entity.py b/homeassistant/components/yamaha_musiccast/entity.py
index 4f1add825e4..8023b13c10a 100644
--- a/homeassistant/components/yamaha_musiccast/entity.py
+++ b/homeassistant/components/yamaha_musiccast/entity.py
@@ -78,13 +78,13 @@ class MusicCastDeviceEntity(MusicCastEntity):
return device_info
- async def async_added_to_hass(self):
+ async def async_added_to_hass(self) -> None:
"""Run when this Entity has been added to HA."""
await super().async_added_to_hass()
# All entities should register callbacks to update HA when their state changes
self.coordinator.musiccast.register_callback(self.async_write_ha_state)
- async def async_will_remove_from_hass(self):
+ async def async_will_remove_from_hass(self) -> None:
"""Entity being removed from hass."""
await super().async_will_remove_from_hass()
self.coordinator.musiccast.remove_callback(self.async_write_ha_state)
diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py
index cff14f2b67d..7bf139e9c3b 100644
--- a/homeassistant/components/yamaha_musiccast/media_player.py
+++ b/homeassistant/components/yamaha_musiccast/media_player.py
@@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import uuid as uuid_util
from .const import (
@@ -55,7 +55,7 @@ MUSIC_PLAYER_BASE_SUPPORT = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MusicCast sensor based on a config entry."""
coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/yamaha_musiccast/number.py b/homeassistant/components/yamaha_musiccast/number.py
index 02dd6720d91..0de14ef142d 100644
--- a/homeassistant/components/yamaha_musiccast/number.py
+++ b/homeassistant/components/yamaha_musiccast/number.py
@@ -7,7 +7,7 @@ from aiomusiccast.capabilities import NumberSetter
from homeassistant.components.number import NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MusicCastDataUpdateCoordinator
@@ -17,7 +17,7 @@ from .entity import MusicCastCapabilityEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MusicCast number entities based on a config entry."""
coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/yamaha_musiccast/select.py b/homeassistant/components/yamaha_musiccast/select.py
index 3a4649b9ae5..133cb4c4d7b 100644
--- a/homeassistant/components/yamaha_musiccast/select.py
+++ b/homeassistant/components/yamaha_musiccast/select.py
@@ -7,7 +7,7 @@ from aiomusiccast.capabilities import OptionSetter
from homeassistant.components.select import SelectEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, TRANSLATION_KEY_MAPPING
from .coordinator import MusicCastDataUpdateCoordinator
@@ -17,7 +17,7 @@ from .entity import MusicCastCapabilityEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MusicCast select entities based on a config entry."""
coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/yamaha_musiccast/strings.json b/homeassistant/components/yamaha_musiccast/strings.json
index eaa5ac50c80..e38eb5955d9 100644
--- a/homeassistant/components/yamaha_musiccast/strings.json
+++ b/homeassistant/components/yamaha_musiccast/strings.json
@@ -29,29 +29,29 @@
"select": {
"dimmer": {
"state": {
- "auto": "Auto"
+ "auto": "[%key:common::state::auto%]"
}
},
"zone_sleep": {
"state": {
"off": "[%key:common::state::off%]",
- "30_min": "30 Minutes",
- "60_min": "60 Minutes",
- "90_min": "90 Minutes",
- "120_min": "120 Minutes"
+ "30_min": "30 minutes",
+ "60_min": "60 minutes",
+ "90_min": "90 minutes",
+ "120_min": "120 minutes"
}
},
"zone_tone_control_mode": {
"state": {
- "manual": "Manual",
- "auto": "Auto",
+ "manual": "[%key:common::state::manual%]",
+ "auto": "[%key:common::state::auto%]",
"bypass": "Bypass"
}
},
"zone_surr_decoder_type": {
"state": {
"toggle": "[%key:common::action::toggle%]",
- "auto": "Auto",
+ "auto": "[%key:common::state::auto%]",
"dolby_pl": "Dolby ProLogic",
"dolby_pl2x_movie": "Dolby ProLogic 2x Movie",
"dolby_pl2x_music": "Dolby ProLogic 2x Music",
@@ -64,8 +64,8 @@
},
"zone_equalizer_mode": {
"state": {
- "manual": "Manual",
- "auto": "Auto",
+ "manual": "[%key:common::state::manual%]",
+ "auto": "[%key:common::state::auto%]",
"bypass": "[%key:component::yamaha_musiccast::entity::select::zone_tone_control_mode::state::bypass%]"
}
},
@@ -84,11 +84,11 @@
},
"zone_link_audio_delay": {
"state": {
- "audio_sync_on": "Audio Synchronization On",
- "audio_sync_off": "Audio Synchronization Off",
+ "audio_sync_on": "Audio synchronization on",
+ "audio_sync_off": "Audio synchronization off",
"balanced": "Balanced",
- "lip_sync": "Lip Synchronization",
- "audio_sync": "Audio Synchronization"
+ "lip_sync": "Lip synchronization",
+ "audio_sync": "Audio synchronization"
}
}
}
diff --git a/homeassistant/components/yamaha_musiccast/switch.py b/homeassistant/components/yamaha_musiccast/switch.py
index 49d031a02b5..148f09930f3 100644
--- a/homeassistant/components/yamaha_musiccast/switch.py
+++ b/homeassistant/components/yamaha_musiccast/switch.py
@@ -7,7 +7,7 @@ from aiomusiccast.capabilities import BinarySetter
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import MusicCastDataUpdateCoordinator
@@ -17,7 +17,7 @@ from .entity import MusicCastCapabilityEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MusicCast sensor based on a config entry."""
coordinator: MusicCastDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/yardian/switch.py b/homeassistant/components/yardian/switch.py
index 910bacc1c2e..6531a48dc82 100644
--- a/homeassistant/components/yardian/switch.py
+++ b/homeassistant/components/yardian/switch.py
@@ -10,7 +10,7 @@ from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, entity_platform
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import VolDictType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -26,7 +26,7 @@ SERVICE_SCHEMA_START_IRRIGATION: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entry for a Yardian irrigation switches."""
coordinator = hass.data[DOMAIN][config_entry.entry_id]
diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py
index 9993272d510..69427c65fd5 100644
--- a/homeassistant/components/yeelight/binary_sensor.py
+++ b/homeassistant/components/yeelight/binary_sensor.py
@@ -6,7 +6,7 @@ from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN
from .entity import YeelightEntity
@@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Yeelight from a config entry."""
device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE]
diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py
index 92ee3976f7f..a2f705298c9 100644
--- a/homeassistant/components/yeelight/light.py
+++ b/homeassistant/components/yeelight/light.py
@@ -34,7 +34,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.typing import VolDictType
from homeassistant.util import color as color_util
@@ -278,7 +278,7 @@ def _async_cmd[_YeelightBaseLightT: YeelightBaseLight, **_P, _R](
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Yeelight from a config entry."""
custom_effects = _parse_custom_effects(hass.data[DOMAIN][DATA_CUSTOM_EFFECTS])
diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json
index cf7bc9c9035..07970cb25ca 100644
--- a/homeassistant/components/yeelight/manifest.json
+++ b/homeassistant/components/yeelight/manifest.json
@@ -16,7 +16,7 @@
},
"iot_class": "local_push",
"loggers": ["async_upnp_client", "yeelight"],
- "requirements": ["yeelight==0.7.16", "async-upnp-client==0.43.0"],
+ "requirements": ["yeelight==0.7.16", "async-upnp-client==0.44.0"],
"zeroconf": [
{
"type": "_miio._udp.local.",
diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json
index 72e400b7cf3..e01a853a360 100644
--- a/homeassistant/components/yeelight/strings.json
+++ b/homeassistant/components/yeelight/strings.json
@@ -73,7 +73,7 @@
"fields": {
"rgb_color": {
"name": "RGB color",
- "description": "Color for the light in RGB-format."
+ "description": "Color for the light in RGB format."
},
"brightness": {
"name": "Brightness",
@@ -161,11 +161,11 @@
},
"set_music_mode": {
"name": "Set music mode",
- "description": "Enables or disables music_mode.",
+ "description": "Enables or disables music mode.",
"fields": {
"music_mode": {
"name": "Music mode",
- "description": "Use true or false to enable / disable music_mode."
+ "description": "Whether to enable or disable music mode."
}
}
}
@@ -173,11 +173,11 @@
"selector": {
"mode": {
"options": {
- "color_flow": "Color Flow",
+ "normal": "[%key:common::state::normal%]",
+ "color_flow": "Color flow",
"hsv": "HSV",
"last": "Last",
"moonlight": "Moonlight",
- "normal": "Normal",
"rgb": "RGB"
}
},
diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py
index 0c92aa696ca..7ba7433f53f 100644
--- a/homeassistant/components/yolink/__init__.py
+++ b/homeassistant/components/yolink/__init__.py
@@ -7,7 +7,7 @@ from dataclasses import dataclass
from datetime import timedelta
from typing import Any
-from yolink.const import ATTR_DEVICE_SMART_REMOTER
+from yolink.const import ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH
from yolink.device import YoLinkDevice
from yolink.exception import YoLinkAuthFailError, YoLinkClientError
from yolink.home_manager import YoLinkHome
@@ -75,7 +75,8 @@ class YoLinkHomeMessageListener(MessageListener):
device_coordinator.async_set_updated_data(msg_data)
# handling events
if (
- device_coordinator.device.device_type == ATTR_DEVICE_SMART_REMOTER
+ device_coordinator.device.device_type
+ in [ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH]
and msg_data.get("event") is not None
):
device_registry = dr.async_get(self._hass)
diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py
index fa4c2202b03..30c04d3a424 100644
--- a/homeassistant/components/yolink/binary_sensor.py
+++ b/homeassistant/components/yolink/binary_sensor.py
@@ -23,7 +23,7 @@ from homeassistant.components.binary_sensor import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YoLinkCoordinator
@@ -101,7 +101,7 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up YoLink Sensor from a config entry."""
device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators
diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py
index ff3bbf0d93b..65253094fa9 100644
--- a/homeassistant/components/yolink/climate.py
+++ b/homeassistant/components/yolink/climate.py
@@ -22,7 +22,7 @@ from homeassistant.components.climate import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YoLinkCoordinator
@@ -47,7 +47,7 @@ YOLINK_ACTION_2_HA = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up YoLink Thermostat from a config entry."""
device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators
diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py
index eb6169eccad..8879ef15125 100644
--- a/homeassistant/components/yolink/const.py
+++ b/homeassistant/components/yolink/const.py
@@ -33,3 +33,7 @@ DEV_MODEL_PLUG_YS6602_UC = "YS6602-UC"
DEV_MODEL_PLUG_YS6602_EC = "YS6602-EC"
DEV_MODEL_PLUG_YS6803_UC = "YS6803-UC"
DEV_MODEL_PLUG_YS6803_EC = "YS6803-EC"
+DEV_MODEL_SWITCH_YS5708_UC = "YS5708-UC"
+DEV_MODEL_SWITCH_YS5708_EC = "YS5708-EC"
+DEV_MODEL_SWITCH_YS5709_UC = "YS5709-UC"
+DEV_MODEL_SWITCH_YS5709_EC = "YS5709-EC"
diff --git a/homeassistant/components/yolink/cover.py b/homeassistant/components/yolink/cover.py
index b2454bd0d4a..b1cfc3681cc 100644
--- a/homeassistant/components/yolink/cover.py
+++ b/homeassistant/components/yolink/cover.py
@@ -14,7 +14,7 @@ from homeassistant.components.cover import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YoLinkCoordinator
@@ -24,7 +24,7 @@ from .entity import YoLinkEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up YoLink garage door from a config entry."""
device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators
diff --git a/homeassistant/components/yolink/device_trigger.py b/homeassistant/components/yolink/device_trigger.py
index 6e247bf858e..6f5ed8b24fa 100644
--- a/homeassistant/components/yolink/device_trigger.py
+++ b/homeassistant/components/yolink/device_trigger.py
@@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any
import voluptuous as vol
-from yolink.const import ATTR_DEVICE_SMART_REMOTER
+from yolink.const import ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
from homeassistant.components.homeassistant.triggers import event as event_trigger
@@ -21,6 +21,10 @@ from .const import (
DEV_MODEL_FLEX_FOB_YS3604_UC,
DEV_MODEL_FLEX_FOB_YS3614_EC,
DEV_MODEL_FLEX_FOB_YS3614_UC,
+ DEV_MODEL_SWITCH_YS5708_EC,
+ DEV_MODEL_SWITCH_YS5708_UC,
+ DEV_MODEL_SWITCH_YS5709_EC,
+ DEV_MODEL_SWITCH_YS5709_UC,
)
CONF_BUTTON_1 = "button_1"
@@ -30,7 +34,7 @@ CONF_BUTTON_4 = "button_4"
CONF_SHORT_PRESS = "short_press"
CONF_LONG_PRESS = "long_press"
-FLEX_FOB_4_BUTTONS = {
+FLEX_BUTTONS_4 = {
f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}",
f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}",
f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}",
@@ -41,7 +45,7 @@ FLEX_FOB_4_BUTTONS = {
f"{CONF_BUTTON_4}_{CONF_LONG_PRESS}",
}
-FLEX_FOB_2_BUTTONS = {
+FLEX_BUTTONS_2 = {
f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}",
f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}",
f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}",
@@ -49,16 +53,19 @@ FLEX_FOB_2_BUTTONS = {
}
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
- {vol.Required(CONF_TYPE): vol.In(FLEX_FOB_4_BUTTONS)}
+ {vol.Required(CONF_TYPE): vol.In(FLEX_BUTTONS_4)}
)
-
-# YoLink Remotes YS3604/YS3614
-FLEX_FOB_TRIGGER_TYPES: dict[str, set[str]] = {
- DEV_MODEL_FLEX_FOB_YS3604_EC: FLEX_FOB_4_BUTTONS,
- DEV_MODEL_FLEX_FOB_YS3604_UC: FLEX_FOB_4_BUTTONS,
- DEV_MODEL_FLEX_FOB_YS3614_UC: FLEX_FOB_2_BUTTONS,
- DEV_MODEL_FLEX_FOB_YS3614_EC: FLEX_FOB_2_BUTTONS,
+# YoLink Remotes YS3604/YS3614, Switch YS5708/YS5709
+TRIGGER_MAPPINGS: dict[str, set[str]] = {
+ DEV_MODEL_FLEX_FOB_YS3604_EC: FLEX_BUTTONS_4,
+ DEV_MODEL_FLEX_FOB_YS3604_UC: FLEX_BUTTONS_4,
+ DEV_MODEL_FLEX_FOB_YS3614_UC: FLEX_BUTTONS_2,
+ DEV_MODEL_FLEX_FOB_YS3614_EC: FLEX_BUTTONS_2,
+ DEV_MODEL_SWITCH_YS5708_EC: FLEX_BUTTONS_2,
+ DEV_MODEL_SWITCH_YS5708_UC: FLEX_BUTTONS_2,
+ DEV_MODEL_SWITCH_YS5709_EC: FLEX_BUTTONS_2,
+ DEV_MODEL_SWITCH_YS5709_UC: FLEX_BUTTONS_2,
}
@@ -68,9 +75,12 @@ async def async_get_triggers(
"""List device triggers for YoLink devices."""
device_registry = dr.async_get(hass)
registry_device = device_registry.async_get(device_id)
- if not registry_device or registry_device.model != ATTR_DEVICE_SMART_REMOTER:
+ if not registry_device or registry_device.model not in [
+ ATTR_DEVICE_SMART_REMOTER,
+ ATTR_DEVICE_SWITCH,
+ ]:
return []
- if registry_device.model_id not in list(FLEX_FOB_TRIGGER_TYPES.keys()):
+ if registry_device.model_id not in list(TRIGGER_MAPPINGS.keys()):
return []
return [
{
@@ -79,7 +89,7 @@ async def async_get_triggers(
CONF_PLATFORM: "device",
CONF_TYPE: trigger,
}
- for trigger in FLEX_FOB_TRIGGER_TYPES[registry_device.model_id]
+ for trigger in TRIGGER_MAPPINGS[registry_device.model_id]
]
diff --git a/homeassistant/components/yolink/light.py b/homeassistant/components/yolink/light.py
index e07d17f7d74..54470673fa5 100644
--- a/homeassistant/components/yolink/light.py
+++ b/homeassistant/components/yolink/light.py
@@ -10,7 +10,7 @@ from yolink.const import ATTR_DEVICE_DIMMER
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YoLinkCoordinator
@@ -20,7 +20,7 @@ from .entity import YoLinkEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up YoLink Dimmer from a config entry."""
device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators
diff --git a/homeassistant/components/yolink/lock.py b/homeassistant/components/yolink/lock.py
index d675fd8cf06..5e244dd08f2 100644
--- a/homeassistant/components/yolink/lock.py
+++ b/homeassistant/components/yolink/lock.py
@@ -10,7 +10,7 @@ from yolink.const import ATTR_DEVICE_LOCK, ATTR_DEVICE_LOCK_V2
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YoLinkCoordinator
@@ -20,7 +20,7 @@ from .entity import YoLinkEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up YoLink lock from a config entry."""
device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators
@@ -51,15 +51,16 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity):
def update_entity_state(self, state: dict[str, Any]) -> None:
"""Update HA Entity State."""
state_value = state.get("state")
- if self.coordinator.device.device_type == ATTR_DEVICE_LOCK_V2:
- self._attr_is_locked = (
- state_value["lock"] == "locked" if state_value is not None else None
- )
- else:
- self._attr_is_locked = (
- state_value == "locked" if state_value is not None else None
- )
- self.async_write_ha_state()
+ if state_value is not None:
+ if self.coordinator.device.device_type == ATTR_DEVICE_LOCK_V2:
+ self._attr_is_locked = (
+ state_value["lock"] == "locked" if state_value is not None else None
+ )
+ else:
+ self._attr_is_locked = (
+ state_value == "locked" if state_value is not None else None
+ )
+ self.async_write_ha_state()
async def call_lock_state_change(self, state: str) -> None:
"""Call setState api to change lock state."""
@@ -69,7 +70,7 @@ class YoLinkLockEntity(YoLinkEntity, LockEntity):
)
else:
await self.call_device(ClientRequest("setState", {"state": state}))
- self._attr_is_locked = state == "lock"
+ self._attr_is_locked = state in ["locked", "lock"]
self.async_write_ha_state()
async def async_lock(self, **kwargs: Any) -> None:
diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json
index 78b553d7978..8c297c68670 100644
--- a/homeassistant/components/yolink/manifest.json
+++ b/homeassistant/components/yolink/manifest.json
@@ -6,5 +6,5 @@
"dependencies": ["auth", "application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/yolink",
"iot_class": "cloud_push",
- "requirements": ["yolink-api==0.4.7"]
+ "requirements": ["yolink-api==0.4.9"]
}
diff --git a/homeassistant/components/yolink/number.py b/homeassistant/components/yolink/number.py
index 7b7b582382b..c643a20d0ea 100644
--- a/homeassistant/components/yolink/number.py
+++ b/homeassistant/components/yolink/number.py
@@ -17,7 +17,7 @@ from homeassistant.components.number import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YoLinkCoordinator
@@ -66,7 +66,7 @@ DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device number type config option entity from a config entry."""
device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators
diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py
index 8f263cdae07..511b7718e26 100644
--- a/homeassistant/components/yolink/sensor.py
+++ b/homeassistant/components/yolink/sensor.py
@@ -47,7 +47,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import percentage
from .const import (
@@ -280,7 +280,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up YoLink Sensor from a config entry."""
device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators
diff --git a/homeassistant/components/yolink/services.py b/homeassistant/components/yolink/services.py
index 8d622de70e7..f17408a7005 100644
--- a/homeassistant/components/yolink/services.py
+++ b/homeassistant/components/yolink/services.py
@@ -39,7 +39,7 @@ def async_register_services(hass: HomeAssistant) -> None:
continue
if entry.domain == DOMAIN:
break
- if entry is None or entry.state == ConfigEntryState.NOT_LOADED:
+ if entry is None or entry.state != ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_config_entry",
diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py
index 9e02f50bb70..d13e2dc6573 100644
--- a/homeassistant/components/yolink/siren.py
+++ b/homeassistant/components/yolink/siren.py
@@ -17,7 +17,7 @@ from homeassistant.components.siren import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import YoLinkCoordinator
@@ -46,7 +46,7 @@ DEVICE_TYPE = [ATTR_DEVICE_SIREN]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up YoLink siren from a config entry."""
device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators
diff --git a/homeassistant/components/yolink/strings.json b/homeassistant/components/yolink/strings.json
index 8ec7612fd73..8867457342f 100644
--- a/homeassistant/components/yolink/strings.json
+++ b/homeassistant/components/yolink/strings.json
@@ -61,8 +61,8 @@
"power_failure_alarm": {
"name": "Power failure alarm",
"state": {
- "normal": "Normal",
"alert": "Alert",
+ "normal": "[%key:common::state::normal%]",
"off": "[%key:common::state::off%]"
}
},
@@ -72,7 +72,11 @@
},
"power_failure_alarm_volume": {
"name": "Power failure alarm volume",
- "state": { "low": "Low", "medium": "Medium", "high": "High" }
+ "state": {
+ "low": "[%key:common::state::low%]",
+ "medium": "[%key:common::state::medium%]",
+ "high": "[%key:common::state::high%]"
+ }
},
"power_failure_alarm_beep": {
"name": "Power failure alarm beep",
diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py
index c999f04d90d..2af7a3c9ddc 100644
--- a/homeassistant/components/yolink/switch.py
+++ b/homeassistant/components/yolink/switch.py
@@ -23,7 +23,7 @@ from homeassistant.components.switch import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DEV_MODEL_MULTI_OUTLET_YS6801, DOMAIN
from .coordinator import YoLinkCoordinator
@@ -116,7 +116,7 @@ DEVICE_TYPE = [
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up YoLink switch from a config entry."""
device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators
@@ -162,11 +162,12 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity):
@callback
def update_entity_state(self, state: dict[str, str | list[str]]) -> None:
"""Update HA Entity State."""
- self._attr_is_on = self._get_state(
- state.get("state"),
- self.entity_description.plug_index_fn(self.coordinator.device),
- )
- self.async_write_ha_state()
+ if (state_value := state.get("state")) is not None:
+ self._attr_is_on = self._get_state(
+ state_value,
+ self.entity_description.plug_index_fn(self.coordinator.device),
+ )
+ self.async_write_ha_state()
async def call_state_change(self, state: str) -> None:
"""Call setState api to change switch state."""
diff --git a/homeassistant/components/yolink/valve.py b/homeassistant/components/yolink/valve.py
index d8c199697c3..26ce72a53d1 100644
--- a/homeassistant/components/yolink/valve.py
+++ b/homeassistant/components/yolink/valve.py
@@ -17,7 +17,7 @@ from homeassistant.components.valve import (
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DEV_MODEL_WATER_METER_YS5007, DOMAIN
from .coordinator import YoLinkCoordinator
@@ -50,7 +50,7 @@ DEVICE_TYPE = [ATTR_DEVICE_WATER_METER_CONTROLLER]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up YoLink valve from a config entry."""
device_coordinators = hass.data[DOMAIN][config_entry.entry_id].device_coordinators
diff --git a/homeassistant/components/youless/sensor.py b/homeassistant/components/youless/sensor.py
index db8244c0b06..be17bed4352 100644
--- a/homeassistant/components/youless/sensor.py
+++ b/homeassistant/components/youless/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import DOMAIN
@@ -302,7 +302,9 @@ SENSOR_TYPES: tuple[YouLessSensorEntityDescription, ...] = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize the integration."""
coordinator: YouLessCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py
index 48336422585..76d74965b34 100644
--- a/homeassistant/components/youtube/config_flow.py
+++ b/homeassistant/components/youtube/config_flow.py
@@ -7,7 +7,6 @@ import logging
from typing import Any
import voluptuous as vol
-from youtubeaio.helper import first
from youtubeaio.types import AuthScope, ForbiddenError
from youtubeaio.youtube import YouTube
@@ -96,8 +95,12 @@ class OAuth2FlowHandler(
"""Create an entry for the flow, or update existing entry."""
try:
youtube = await self.get_resource(data[CONF_TOKEN][CONF_ACCESS_TOKEN])
- own_channel = await first(youtube.get_user_channels())
- if own_channel is None or own_channel.snippet is None:
+ own_channels = [
+ channel
+ async for channel in youtube.get_user_channels()
+ if channel.snippet is not None
+ ]
+ if not own_channels:
return self.async_abort(
reason="no_channel",
description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL},
@@ -111,10 +114,10 @@ class OAuth2FlowHandler(
except Exception as ex: # noqa: BLE001
LOGGER.error("Unknown error occurred: %s", ex.args)
return self.async_abort(reason="unknown")
- self._title = own_channel.snippet.title
+ self._title = own_channels[0].snippet.title
self._data = data
- await self.async_set_unique_id(own_channel.channel_id)
+ await self.async_set_unique_id(own_channels[0].channel_id)
if self.source != SOURCE_REAUTH:
self._abort_if_unique_id_configured()
@@ -138,13 +141,39 @@ class OAuth2FlowHandler(
options=user_input,
)
youtube = await self.get_resource(self._data[CONF_TOKEN][CONF_ACCESS_TOKEN])
+
+ # Get user's own channels
+ own_channels = [
+ channel
+ async for channel in youtube.get_user_channels()
+ if channel.snippet is not None
+ ]
+ if not own_channels:
+ return self.async_abort(
+ reason="no_channel",
+ description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL},
+ )
+
+ # Start with user's own channels
selectable_channels = [
SelectOptionDict(
- value=subscription.snippet.channel_id,
- label=subscription.snippet.title,
+ value=channel.channel_id,
+ label=f"{channel.snippet.title} (Your Channel)",
)
- async for subscription in youtube.get_user_subscriptions()
+ for channel in own_channels
]
+
+ # Add subscribed channels
+ selectable_channels.extend(
+ [
+ SelectOptionDict(
+ value=subscription.snippet.channel_id,
+ label=subscription.snippet.title,
+ )
+ async for subscription in youtube.get_user_subscriptions()
+ ]
+ )
+
if not selectable_channels:
return self.async_abort(reason="no_subscriptions")
return self.async_show_form(
@@ -175,13 +204,39 @@ class YouTubeOptionsFlowHandler(OptionsFlow):
await youtube.set_user_authentication(
self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN], [AuthScope.READ_ONLY]
)
+
+ # Get user's own channels
+ own_channels = [
+ channel
+ async for channel in youtube.get_user_channels()
+ if channel.snippet is not None
+ ]
+ if not own_channels:
+ return self.async_abort(
+ reason="no_channel",
+ description_placeholders={"support_url": CHANNEL_CREATION_HELP_URL},
+ )
+
+ # Start with user's own channels
selectable_channels = [
SelectOptionDict(
- value=subscription.snippet.channel_id,
- label=subscription.snippet.title,
+ value=channel.channel_id,
+ label=f"{channel.snippet.title} (Your Channel)",
)
- async for subscription in youtube.get_user_subscriptions()
+ for channel in own_channels
]
+
+ # Add subscribed channels
+ selectable_channels.extend(
+ [
+ SelectOptionDict(
+ value=subscription.snippet.channel_id,
+ label=subscription.snippet.title,
+ )
+ async for subscription in youtube.get_user_subscriptions()
+ ]
+ )
+
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py
index 8832382508c..128c23f7082 100644
--- a/homeassistant/components/youtube/sensor.py
+++ b/homeassistant/components/youtube/sensor.py
@@ -10,7 +10,7 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescriptio
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ICON
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from . import YouTubeDataUpdateCoordinator
@@ -72,7 +72,9 @@ SENSOR_TYPES = [
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the YouTube sensor."""
coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py
index 7c7f5fd6c16..fdb9d51185c 100644
--- a/homeassistant/components/zamg/sensor.py
+++ b/homeassistant/components/zamg/sensor.py
@@ -23,7 +23,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -82,7 +82,8 @@ SENSOR_TYPES: tuple[ZamgSensorEntityDescription, ...] = (
key="wind_bearing",
name="Wind Bearing",
native_unit_of_measurement=DEGREE,
- state_class=SensorStateClass.MEASUREMENT,
+ state_class=SensorStateClass.MEASUREMENT_ANGLE,
+ device_class=SensorDeviceClass.WIND_DIRECTION,
para_name="DD",
),
ZamgSensorEntityDescription(
@@ -163,7 +164,9 @@ API_FIELDS: list[str] = [desc.para_name for desc in SENSOR_TYPES]
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ZAMG sensor platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py
index 286a6460f19..ac376577ade 100644
--- a/homeassistant/components/zamg/weather.py
+++ b/homeassistant/components/zamg/weather.py
@@ -12,7 +12,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION, CONF_STATION_ID, DOMAIN, MANUFACTURER_URL
@@ -20,7 +20,9 @@ from .coordinator import ZamgDataUpdateCoordinator
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the ZAMG weather platform."""
coordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py
index 2ab46820b56..ccb6733c650 100644
--- a/homeassistant/components/zengge/light.py
+++ b/homeassistant/components/zengge/light.py
@@ -2,138 +2,38 @@
from __future__ import annotations
-import logging
-from typing import Any
-
import voluptuous as vol
-from zengge import zengge
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS,
- ATTR_HS_COLOR,
- ATTR_WHITE,
- PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA,
- ColorMode,
- LightEntity,
-)
+from homeassistant.components.light import PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA
from homeassistant.const import CONF_DEVICES, CONF_NAME
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
-from homeassistant.util import color as color_util
-
-_LOGGER = logging.getLogger(__name__)
DEVICE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string})
+DOMAIN = "zengge"
PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA.extend(
{vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}}
)
-def setup_platform(
+def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Zengge platform."""
- lights = []
- for address, device_config in config[CONF_DEVICES].items():
- light = ZenggeLight(device_config[CONF_NAME], address)
- if light.is_valid:
- lights.append(light)
-
- add_entities(lights, True)
-
-
-class ZenggeLight(LightEntity):
- """Representation of a Zengge light."""
-
- _attr_supported_color_modes = {ColorMode.HS, ColorMode.WHITE}
-
- def __init__(self, name: str, address: str) -> None:
- """Initialize the light."""
-
- self._attr_name = name
- self._attr_unique_id = address
- self.is_valid = True
- self._bulb = zengge(address)
- self._white = 0
- self._attr_brightness = 0
- self._attr_hs_color = (0, 0)
- self._attr_is_on = False
- if self._bulb.connect() is False:
- self.is_valid = False
- _LOGGER.error("Failed to connect to bulb %s, %s", address, name)
- return
-
- @property
- def white_value(self) -> int:
- """Return the white property."""
- return self._white
-
- @property
- def color_mode(self) -> ColorMode:
- """Return the current color mode."""
- if self._white != 0:
- return ColorMode.WHITE
- return ColorMode.HS
-
- def _set_rgb(self, red: int, green: int, blue: int) -> None:
- """Set the rgb state."""
- self._bulb.set_rgb(red, green, blue)
-
- def _set_white(self, white):
- """Set the white state."""
- return self._bulb.set_white(white)
-
- def turn_on(self, **kwargs: Any) -> None:
- """Turn the specified light on."""
- self._attr_is_on = True
- self._bulb.on()
-
- hs_color = kwargs.get(ATTR_HS_COLOR)
- white = kwargs.get(ATTR_WHITE)
- brightness = kwargs.get(ATTR_BRIGHTNESS)
-
- if white is not None:
- # Change the bulb to white
- self._attr_brightness = white
- self._white = white
- self._attr_hs_color = (0, 0)
-
- if hs_color is not None:
- # Change the bulb to hs
- self._white = 0
- self._attr_hs_color = hs_color
-
- if brightness is not None:
- self._attr_brightness = brightness
-
- if self._white != 0:
- self._set_white(self.brightness)
- else:
- assert self.hs_color is not None
- assert self.brightness is not None
- rgb = color_util.color_hsv_to_RGB(
- self.hs_color[0], self.hs_color[1], self.brightness / 255 * 100
- )
- self._set_rgb(*rgb)
-
- def turn_off(self, **kwargs: Any) -> None:
- """Turn the specified light off."""
- self._attr_is_on = False
- self._bulb.off()
-
- def update(self) -> None:
- """Synchronise internal state with the actual light state."""
- rgb = self._bulb.get_colour()
- hsv = color_util.color_RGB_to_hsv(*rgb)
- self._attr_hs_color = hsv[:2]
- self._attr_brightness = int((hsv[2] / 100) * 255)
- self._white = self._bulb.get_white()
- if self._white:
- self._attr_brightness = self._white
- self._attr_is_on = self._bulb.get_on()
+ ir.async_create_issue(
+ hass,
+ DOMAIN,
+ DOMAIN,
+ is_fixable=False,
+ severity=ir.IssueSeverity.ERROR,
+ translation_key="integration_removed",
+ translation_placeholders={
+ "led_ble_url": "https://www.home-assistant.io/integrations/led_ble/",
+ },
+ )
diff --git a/homeassistant/components/zengge/manifest.json b/homeassistant/components/zengge/manifest.json
index 03d989c5f3b..daa63b4de3d 100644
--- a/homeassistant/components/zengge/manifest.json
+++ b/homeassistant/components/zengge/manifest.json
@@ -5,6 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/zengge",
"iot_class": "local_polling",
"loggers": ["zengge"],
- "quality_scale": "legacy",
- "requirements": ["bluepy==1.3.0", "zengge==0.2"]
+ "quality_scale": "legacy"
}
diff --git a/homeassistant/components/zengge/strings.json b/homeassistant/components/zengge/strings.json
new file mode 100644
index 00000000000..abc3b2450aa
--- /dev/null
+++ b/homeassistant/components/zengge/strings.json
@@ -0,0 +1,8 @@
+{
+ "issues": {
+ "integration_removed": {
+ "title": "The Zengge integration has been removed",
+ "description": "The Zengge integration has been removed from Home Assistant. Support for Zengge lights is provided by the `led_ble` integration.\n\nTo resolve this issue, please remove the (now defunct) `zengge` light configuration from your Home Assistant configuration and [configure the `led_ble` integration]({led_ble_url})."
+ }
+ }
+}
diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py
index b748006336c..86f8dbca792 100644
--- a/homeassistant/components/zeroconf/__init__.py
+++ b/homeassistant/components/zeroconf/__init__.py
@@ -141,13 +141,11 @@ def async_get_async_zeroconf(hass: HomeAssistant) -> HaAsyncZeroconf:
return _async_get_instance(hass)
-def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZeroconf:
+def _async_get_instance(hass: HomeAssistant) -> HaAsyncZeroconf:
if DOMAIN in hass.data:
return cast(HaAsyncZeroconf, hass.data[DOMAIN])
- logging.getLogger("zeroconf").setLevel(logging.NOTSET)
-
- zeroconf = HaZeroconf(**zcargs)
+ zeroconf = HaZeroconf(**_async_get_zc_args(hass))
aio_zc = HaAsyncZeroconf(zc=zeroconf)
install_multiple_zeroconf_catcher(zeroconf)
@@ -175,12 +173,10 @@ def _async_zc_has_functional_dual_stack() -> bool:
)
-async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up Zeroconf and make Home Assistant discoverable."""
- zc_args: dict = {"ip_version": IPVersion.V4Only}
-
- adapters = await network.async_get_adapters(hass)
-
+def _async_get_zc_args(hass: HomeAssistant) -> dict[str, Any]:
+ """Get zeroconf arguments from config."""
+ zc_args: dict[str, Any] = {"ip_version": IPVersion.V4Only}
+ adapters = network.async_get_loaded_adapters(hass)
ipv6 = False
if _async_zc_has_functional_dual_stack():
if any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters):
@@ -195,7 +191,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
else:
zc_args["interfaces"] = [
str(source_ip)
- for source_ip in await network.async_get_enabled_source_ips(hass)
+ for source_ip in network.async_get_enabled_source_ips_from_adapters(
+ adapters
+ )
if not source_ip.is_loopback
and not (isinstance(source_ip, IPv6Address) and source_ip.is_global)
and not (
@@ -207,8 +205,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
and zc_args["ip_version"] == IPVersion.V6Only
)
]
+ return zc_args
- aio_zc = _async_get_instance(hass, **zc_args)
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up Zeroconf and make Home Assistant discoverable."""
+ aio_zc = _async_get_instance(hass)
zeroconf = cast(HaZeroconf, aio_zc.zeroconf)
zeroconf_types = await async_get_zeroconf(hass)
homekit_models = await async_get_homekit(hass)
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
index f4a78cd99e9..e2637d792e2 100644
--- a/homeassistant/components/zeroconf/manifest.json
+++ b/homeassistant/components/zeroconf/manifest.json
@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["zeroconf"],
"quality_scale": "internal",
- "requirements": ["zeroconf==0.143.0"]
+ "requirements": ["zeroconf==0.146.5"]
}
diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py
index 36a964a46ab..19175ae3084 100644
--- a/homeassistant/components/zerproc/light.py
+++ b/homeassistant/components/zerproc/light.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util import color as color_util
@@ -51,7 +51,7 @@ async def discover_entities(hass: HomeAssistant) -> list[ZerprocLight]:
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Zerproc light devices."""
warned = False
diff --git a/homeassistant/components/zeversolar/sensor.py b/homeassistant/components/zeversolar/sensor.py
index 5023e274267..330e5bb72d8 100644
--- a/homeassistant/components/zeversolar/sensor.py
+++ b/homeassistant/components/zeversolar/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.components.sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, UnitOfEnergy, UnitOfPower
from homeassistant.core import HomeAssistant
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import ZeversolarCoordinator
@@ -52,7 +52,9 @@ SENSOR_TYPES = (
async def async_setup_entry(
- hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
+ hass: HomeAssistant,
+ entry: ConfigEntry,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zeversolar sensor."""
coordinator: ZeversolarCoordinator = hass.data[DOMAIN][entry.entry_id]
diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py
index 734683e5497..ff61ce07d23 100644
--- a/homeassistant/components/zha/alarm_control_panel.py
+++ b/homeassistant/components/zha/alarm_control_panel.py
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ZHAEntity
from .helpers import (
@@ -46,7 +46,7 @@ ZHA_STATE_TO_ALARM_STATE_MAP = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation alarm control panel from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py
index f45ebf0c5a5..f8146026384 100644
--- a/homeassistant/components/zha/binary_sensor.py
+++ b/homeassistant/components/zha/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ZHAEntity
from .helpers import (
@@ -26,7 +26,7 @@ from .helpers import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation binary sensor from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py
index ecd5cd51f61..dd90bcd29b1 100644
--- a/homeassistant/components/zha/button.py
+++ b/homeassistant/components/zha/button.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ZHAEntity
from .helpers import (
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation button from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py
index af9f56cd7dc..a3f60420a38 100644
--- a/homeassistant/components/zha/climate.py
+++ b/homeassistant/components/zha/climate.py
@@ -30,7 +30,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PRECISION_TENTHS, Platform, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ZHAEntity
from .helpers import (
@@ -66,7 +66,7 @@ ZHA_TO_HA_HVAC_ACTION = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation sensor from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py
index 0d6be2dbb35..d058f37ff6b 100644
--- a/homeassistant/components/zha/cover.py
+++ b/homeassistant/components/zha/cover.py
@@ -23,7 +23,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ZHAEntity
from .helpers import (
@@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation cover from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py
index 7bdfc54c986..c86bb3352b5 100644
--- a/homeassistant/components/zha/device_tracker.py
+++ b/homeassistant/components/zha/device_tracker.py
@@ -10,7 +10,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ZHAEntity
from .helpers import (
@@ -23,7 +23,7 @@ from .helpers import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation device tracker from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py
index 499721722fa..e3339661d15 100644
--- a/homeassistant/components/zha/entity.py
+++ b/homeassistant/components/zha/entity.py
@@ -59,6 +59,10 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
def name(self) -> str | UndefinedType | None:
"""Return the name of the entity."""
meta = self.entity_data.entity.info_object
+ if meta.primary:
+ self._attr_name = None
+ return super().name
+
original_name = super().name
if original_name not in (UNDEFINED, None) or meta.fallback_name is None:
diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py
index 73b23e97387..81206f8819e 100644
--- a/homeassistant/components/zha/fan.py
+++ b/homeassistant/components/zha/fan.py
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ZHAEntity
from .helpers import (
@@ -27,7 +27,7 @@ from .helpers import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation fan from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py
index c31627d3dc3..c819f94ceba 100644
--- a/homeassistant/components/zha/helpers.py
+++ b/homeassistant/components/zha/helpers.py
@@ -11,6 +11,7 @@ import enum
import functools
import itertools
import logging
+import queue
import re
import time
from types import MappingProxyType
@@ -111,9 +112,10 @@ from homeassistant.helpers import (
device_registry as dr,
entity_registry as er,
)
-from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
+from homeassistant.util.logging import HomeAssistantQueueHandler
from .const import (
ATTR_ACTIVE_COORDINATOR,
@@ -505,7 +507,15 @@ class ZHAGatewayProxy(EventBase):
DEBUG_LEVEL_CURRENT: async_capture_log_levels(),
}
self.debug_enabled: bool = False
- self._log_relay_handler: LogRelayHandler = LogRelayHandler(hass, self)
+
+ log_relay_handler: LogRelayHandler = LogRelayHandler(hass, self)
+ log_simple_queue: queue.SimpleQueue[logging.Handler] = queue.SimpleQueue()
+ self._log_queue_handler = HomeAssistantQueueHandler(log_simple_queue)
+ self._log_queue_handler.listener = logging.handlers.QueueListener(
+ log_simple_queue, log_relay_handler
+ )
+ self._log_queue_handler_count: int = 0
+
self._unsubs: list[Callable[[], None]] = []
self._unsubs.append(self.gateway.on_all_events(self._handle_event_protocol))
self._reload_task: asyncio.Task | None = None
@@ -736,10 +746,16 @@ class ZHAGatewayProxy(EventBase):
self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels()
if filterer:
- self._log_relay_handler.addFilter(filterer)
+ self._log_queue_handler.addFilter(filterer)
+
+ # Only start a new log queue handler if the old one is no longer running
+ self._log_queue_handler_count += 1
+
+ if self._log_queue_handler.listener and self._log_queue_handler_count == 1:
+ self._log_queue_handler.listener.start()
for logger_name in DEBUG_RELAY_LOGGERS:
- logging.getLogger(logger_name).addHandler(self._log_relay_handler)
+ logging.getLogger(logger_name).addHandler(self._log_queue_handler)
self.debug_enabled = True
@@ -749,9 +765,17 @@ class ZHAGatewayProxy(EventBase):
async_set_logger_levels(self._log_levels[DEBUG_LEVEL_ORIGINAL])
self._log_levels[DEBUG_LEVEL_CURRENT] = async_capture_log_levels()
for logger_name in DEBUG_RELAY_LOGGERS:
- logging.getLogger(logger_name).removeHandler(self._log_relay_handler)
+ logging.getLogger(logger_name).removeHandler(self._log_queue_handler)
+
+ # Only stop the log queue handler if nothing else is using it
+ self._log_queue_handler_count -= 1
+
+ if self._log_queue_handler.listener and self._log_queue_handler_count == 0:
+ self._log_queue_handler.listener.stop()
+
if filterer:
- self._log_relay_handler.removeFilter(filterer)
+ self._log_queue_handler.removeFilter(filterer)
+
self.debug_enabled = False
async def shutdown(self) -> None:
@@ -978,7 +1002,7 @@ class LogRelayHandler(logging.Handler):
entry = LogEntry(
record, self.paths_re, figure_out_source=record.levelno >= logging.WARNING
)
- async_dispatcher_send(
+ dispatcher_send(
self.hass,
ZHA_GW_MSG,
{ATTR_TYPE: ZHA_GW_MSG_LOG_OUTPUT, ZHA_GW_MSG_LOG_ENTRY: entry.to_dict()},
diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py
index 2f5d9e9e4c9..a2fb61dc019 100644
--- a/homeassistant/components/zha/light.py
+++ b/homeassistant/components/zha/light.py
@@ -28,7 +28,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON, Platform
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .entity import ZHAEntity
@@ -59,7 +59,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation light from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py
index ebac03eb7b8..dc27ec7a6fa 100644
--- a/homeassistant/components/zha/lock.py
+++ b/homeassistant/components/zha/lock.py
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import (
- AddEntitiesCallback,
+ AddConfigEntryEntitiesCallback,
async_get_current_platform,
)
@@ -33,7 +33,7 @@ SERVICE_CLEAR_LOCK_USER_CODE = "clear_lock_user_code"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation Door Lock from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index 821159afb22..04f3658d924 100644
--- a/homeassistant/components/zha/manifest.json
+++ b/homeassistant/components/zha/manifest.json
@@ -21,7 +21,7 @@
"zha",
"universal_silabs_flasher"
],
- "requirements": ["zha==0.0.48"],
+ "requirements": ["zha==0.0.56"],
"usb": [
{
"vid": "10C4",
diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py
index 263f5262994..567e2a5b37a 100644
--- a/homeassistant/components/zha/number.py
+++ b/homeassistant/components/zha/number.py
@@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import UndefinedType
from .entity import ZHAEntity
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation Analog Output from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py
index fdb47b550fe..4a38738b7dd 100644
--- a/homeassistant/components/zha/select.py
+++ b/homeassistant/components/zha/select.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ZHAEntity
from .helpers import (
@@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation siren from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py
index 0506496f447..a8383857e57 100644
--- a/homeassistant/components/zha/sensor.py
+++ b/homeassistant/components/zha/sensor.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .entity import ZHAEntity
@@ -80,7 +80,7 @@ _EXTRA_STATE_ATTRIBUTES: set[str] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation sensor from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py
index 9d876d9ca4d..0c8b447cb37 100644
--- a/homeassistant/components/zha/siren.py
+++ b/homeassistant/components/zha/siren.py
@@ -26,7 +26,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ZHAEntity
from .helpers import (
@@ -41,7 +41,7 @@ from .helpers import (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation siren from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json
index c73a0989faa..79cb05c3a0e 100644
--- a/homeassistant/components/zha/strings.json
+++ b/homeassistant/components/zha/strings.json
@@ -3,11 +3,11 @@
"flow_title": "{name}",
"step": {
"choose_serial_port": {
- "title": "Select a Serial Port",
+ "title": "Select a serial port",
+ "description": "Select the serial port for your Zigbee radio",
"data": {
- "path": "Serial Device Path"
- },
- "description": "Select the serial port for your Zigbee radio"
+ "path": "Serial device path"
+ }
},
"confirm": {
"description": "Do you want to set up {name}?"
@@ -16,14 +16,14 @@
"description": "Do you want to set up {name}?"
},
"manual_pick_radio_type": {
+ "title": "Select a radio type",
+ "description": "Pick your Zigbee radio type",
"data": {
- "radio_type": "Radio Type"
- },
- "title": "[%key:component::zha::config::step::manual_pick_radio_type::data::radio_type%]",
- "description": "Pick your Zigbee radio type"
+ "radio_type": "Radio type"
+ }
},
"manual_port_config": {
- "title": "Serial Port Settings",
+ "title": "Serial port settings",
"description": "Enter the serial port settings",
"data": {
"path": "Serial device path",
@@ -36,7 +36,7 @@
"description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})."
},
"choose_formation_strategy": {
- "title": "Network Formation",
+ "title": "Network formation",
"description": "Choose the network settings for your radio.",
"menu_options": {
"form_new_network": "Erase network settings and create a new network",
@@ -47,21 +47,21 @@
}
},
"choose_automatic_backup": {
- "title": "Restore Automatic Backup",
+ "title": "Restore automatic backup",
"description": "Restore your network settings from an automatic backup",
"data": {
"choose_automatic_backup": "Choose an automatic backup"
}
},
"upload_manual_backup": {
- "title": "Upload a Manual Backup",
+ "title": "Upload a manual backup",
"description": "Restore your network settings from an uploaded backup JSON file. You can download one from a different ZHA installation from **Network Settings**, or use a Zigbee2MQTT `coordinator_backup.json` file.",
"data": {
"uploaded_backup_file": "Upload a file"
}
},
"maybe_confirm_ezsp_restore": {
- "title": "Overwrite Radio IEEE Address",
+ "title": "Overwrite radio IEEE address",
"description": "Your backup has a different IEEE address than your radio. For your network to function properly, the IEEE address of your radio should also be changed.\n\nThis is a permanent operation.",
"data": {
"overwrite_coordinator_ieee": "Permanently replace the radio IEEE address"
@@ -74,10 +74,10 @@
},
"abort": {
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]",
- "not_zha_device": "This device is not a zha device",
- "usb_probe_failed": "Failed to probe the usb device",
+ "not_zha_device": "This device is not a ZHA device",
+ "usb_probe_failed": "Failed to probe the USB device",
"wrong_firmware_installed": "Your device is running the wrong firmware and cannot be used with ZHA until the correct firmware is installed. [A repair has been created]({repair_url}) with more information and instructions for how to fix this.",
- "invalid_zeroconf_data": "The coordinator has invalid zeroconf service info and cannot be identified by ZHA"
+ "invalid_zeroconf_data": "The coordinator has invalid Zeroconf service info and cannot be identified by ZHA"
}
},
"options": {
@@ -176,7 +176,7 @@
},
"config_panel": {
"zha_options": {
- "title": "Global Options",
+ "title": "Global options",
"enhanced_light_transition": "Enable enhanced light color/temperature transition from an off-state",
"light_transitioning_flag": "Enable enhanced brightness slider during light transition",
"group_members_assume_state": "Group members assume state of group",
@@ -187,7 +187,7 @@
"consider_unavailable_battery": "Consider battery powered devices unavailable after (seconds)"
},
"zha_alarm_options": {
- "title": "Alarm Control Panel Options",
+ "title": "Alarm control panel options",
"alarm_master_code": "Master code for the alarm control panel(s)",
"alarm_failed_tries": "The number of consecutive failed code entries to trigger an alarm",
"alarm_arm_requires_code": "Code required for arming actions"
@@ -274,15 +274,15 @@
},
"source_ieee": {
"name": "Source IEEE",
- "description": "IEEE address of the joining device (must be used with the install code)."
+ "description": "IEEE address of the joining device (must be combined with the 'Install code' field)."
},
"install_code": {
"name": "Install code",
- "description": "Install code of the joining device (must be used with the source_ieee)."
+ "description": "Install code of the joining device (must be combined with the 'Source IEEE' field)."
},
"qr_code": {
"name": "QR code",
- "description": "Value of the QR install code (different between vendors)."
+ "description": "Provides both the IEEE address and the install code of the joining device (different between vendors)."
}
}
},
@@ -307,7 +307,7 @@
}
},
"set_zigbee_cluster_attribute": {
- "name": "Set zigbee cluster attribute",
+ "name": "Set Zigbee cluster attribute",
"description": "Sets an attribute value for the specified cluster on the specified entity.",
"fields": {
"ieee": {
@@ -323,7 +323,7 @@
"description": "ZCL cluster to retrieve attributes for."
},
"cluster_type": {
- "name": "Cluster Type",
+ "name": "Cluster type",
"description": "Type of the cluster."
},
"attribute": {
@@ -341,7 +341,7 @@
}
},
"issue_zigbee_cluster_command": {
- "name": "Issue zigbee cluster command",
+ "name": "Issue Zigbee cluster command",
"description": "Issues a command on the specified cluster on the specified entity.",
"fields": {
"ieee": {
@@ -383,8 +383,8 @@
}
},
"issue_zigbee_group_command": {
- "name": "Issue zigbee group command",
- "description": "Issue command on the specified cluster on the specified group.",
+ "name": "Issue Zigbee group command",
+ "description": "Issues a command on the specified cluster on the specified group.",
"fields": {
"group": {
"name": "Group",
@@ -610,6 +610,12 @@
},
"flow_switch": {
"name": "Flow switch"
+ },
+ "water_leak": {
+ "name": "Water leak"
+ },
+ "water_supply": {
+ "name": "Water supply"
}
},
"button": {
@@ -1044,6 +1050,84 @@
},
"valve_duration": {
"name": "Irrigation duration"
+ },
+ "down_movement": {
+ "name": "Down movement"
+ },
+ "sustain_time": {
+ "name": "Sustain time"
+ },
+ "up_movement": {
+ "name": "Up movement"
+ },
+ "large_motion_detection_sensitivity": {
+ "name": "Motion detection sensitivity"
+ },
+ "large_motion_detection_distance": {
+ "name": "Motion detection distance"
+ },
+ "medium_motion_detection_distance": {
+ "name": "Medium motion detection distance"
+ },
+ "medium_motion_detection_sensitivity": {
+ "name": "Medium motion detection sensitivity"
+ },
+ "small_motion_detection_distance": {
+ "name": "Small motion detection distance"
+ },
+ "small_motion_detection_sensitivity": {
+ "name": "Small motion detection sensitivity"
+ },
+ "static_detection_sensitivity": {
+ "name": "Static detection sensitivity"
+ },
+ "static_detection_distance": {
+ "name": "Static detection distance"
+ },
+ "motion_detection_sensitivity": {
+ "name": "Motion detection sensitivity"
+ },
+ "holiday_temperature": {
+ "name": "Holiday temperature"
+ },
+ "boost_time": {
+ "name": "Boost time"
+ },
+ "antifrost_temperature": {
+ "name": "Antifrost temperature"
+ },
+ "eco_temperature": {
+ "name": "Eco temperature"
+ },
+ "comfort_temperature": {
+ "name": "Comfort temperature"
+ },
+ "valve_state_auto_shutdown": {
+ "name": "Valve state auto shutdown"
+ },
+ "shutdown_timer": {
+ "name": "Shutdown timer"
+ },
+ "calibration_vertical_run_time_up": {
+ "name": "Calibration vertical run time up"
+ },
+ "calibration_vertical_run_time_down": {
+ "name": "Calibration vertical run time down"
+ },
+ "calibration_rotation_run_time_up": {
+ "name": "Calibration rotation run time up"
+ },
+ "calibration_rotation_run_time_down": {
+ "name": "Calibration rotation run time down"
+ },
+ "impulse_mode_duration": {
+ "name": "Impulse mode duration"
+ },
+ "water_duration": {
+ "name": "Water duration"
+ },
+ "water_interval": {
+ "name": "Water interval"
}
},
"select": {
@@ -1087,10 +1171,10 @@
"name": "Switch type"
},
"led_scaling_mode": {
- "name": "Led scaling mode"
+ "name": "LED scaling mode"
},
"smart_fan_led_display_levels": {
- "name": "Smart fan led display levels"
+ "name": "Smart fan LED display levels"
},
"increased_non_neutral_output": {
"name": "Non neutral output"
@@ -1235,6 +1319,36 @@
},
"eco_mode": {
"name": "Eco mode"
+ },
+ "mode": {
+ "name": "Mode"
+ },
+ "reverse": {
+ "name": "Reverse"
+ },
+ "motion_state": {
+ "name": "Motion state"
+ },
+ "motion_detection_mode": {
+ "name": "Motion detection mode"
+ },
+ "screen_orientation": {
+ "name": "Screen orientation"
+ },
+ "motor_thrust": {
+ "name": "Motor thrust"
+ },
+ "display_brightness": {
+ "name": "Display brightness"
+ },
+ "display_orientation": {
+ "name": "Display orientation"
+ },
+ "hysteresis_mode": {
+ "name": "Hysteresis mode"
+ },
+ "speed": {
+ "name": "Speed"
}
},
"sensor": {
@@ -1373,7 +1487,7 @@
"adaptation_run_status": {
"name": "Adaptation run status",
"state": {
- "nothing": "Idle",
+ "nothing": "[%key:common::state::idle%]",
"something": "State"
},
"state_attributes": {
@@ -1561,6 +1675,30 @@
},
"error_status": {
"name": "Error status"
+ },
+ "brightness_level": {
+ "name": "Brightness level"
+ },
+ "average_light_intensity_20mins": {
+ "name": "Average light intensity last 20 min"
+ },
+ "todays_max_light_intensity": {
+ "name": "Today's max light intensity"
+ },
+ "fault_code": {
+ "name": "Fault code"
+ },
+ "water_flow": {
+ "name": "Water flow"
+ },
+ "remaining_watering_time": {
+ "name": "Remaining watering time"
+ },
+ "last_watering_duration": {
+ "name": "Last watering duration"
+ },
+ "device_status": {
+ "name": "Device status"
}
},
"switch": {
@@ -1746,6 +1884,30 @@
},
"total_flow_reset_switch": {
"name": "Total flow reset switch"
+ },
+ "touch_control": {
+ "name": "Touch control"
+ },
+ "sound_enabled": {
+ "name": "Sound enabled"
+ },
+ "invert_relay": {
+ "name": "Invert relay"
+ },
+ "boost_heating": {
+ "name": "Boost heating"
+ },
+ "holiday_mode": {
+ "name": "Holiday mode"
+ },
+ "heating_stop": {
+ "name": "Heating stop"
+ },
+ "schedule_mode": {
+ "name": "Schedule mode"
+ },
+ "auto_clean": {
+ "name": "Auto clean"
}
}
}
diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py
index cb0268f98e0..dc150e2407d 100644
--- a/homeassistant/components/zha/switch.py
+++ b/homeassistant/components/zha/switch.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ZHAEntity
from .helpers import (
@@ -27,7 +27,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation switch from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zha/update.py b/homeassistant/components/zha/update.py
index 2f540da5ea7..062581fd259 100644
--- a/homeassistant/components/zha/update.py
+++ b/homeassistant/components/zha/update.py
@@ -19,7 +19,7 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
@@ -52,7 +52,7 @@ OTA_MESSAGE_RELIABILITY = (
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation update from config entry."""
zha_data = get_zha_data(hass)
diff --git a/homeassistant/components/zodiac/sensor.py b/homeassistant/components/zodiac/sensor.py
index d7cac07a322..41f200366ae 100644
--- a/homeassistant/components/zodiac/sensor.py
+++ b/homeassistant/components/zodiac/sensor.py
@@ -6,7 +6,7 @@ from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import as_local, utcnow
from .const import (
@@ -150,7 +150,7 @@ ZODIAC_BY_DATE = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize the entries."""
diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py
index 1c43a79e10e..813425c95f2 100644
--- a/homeassistant/components/zone/__init__.py
+++ b/homeassistant/components/zone/__init__.py
@@ -363,7 +363,7 @@ class Zone(collection.CollectionEntity):
"""Return entity instance initialized from storage."""
zone = cls(config)
zone.editable = True
- zone._generate_attrs() # noqa: SLF001
+ zone._generate_attrs()
return zone
@classmethod
@@ -371,7 +371,7 @@ class Zone(collection.CollectionEntity):
"""Return entity instance initialized from yaml."""
zone = cls(config)
zone.editable = False
- zone._generate_attrs() # noqa: SLF001
+ zone._generate_attrs()
return zone
@property
diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py
index c8503b1f4c6..e73bd01deba 100644
--- a/homeassistant/components/zwave_js/__init__.py
+++ b/homeassistant/components/zwave_js/__init__.py
@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
-from contextlib import suppress
+import contextlib
import logging
from typing import Any
@@ -12,7 +12,11 @@ from awesomeversion import AwesomeVersion
import voluptuous as vol
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import CommandClass, RemoveNodeReason
-from zwave_js_server.exceptions import BaseZwaveJSServerError, InvalidServerVersion
+from zwave_js_server.exceptions import (
+ BaseZwaveJSServerError,
+ InvalidServerVersion,
+ NotConnected,
+)
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.node import Node as ZwaveNode
from zwave_js_server.model.notification import (
@@ -25,7 +29,7 @@ from zwave_js_server.model.value import Value, ValueNotification
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
from homeassistant.components.persistent_notification import async_create
-from homeassistant.config_entries import ConfigEntry
+from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_DOMAIN,
@@ -36,7 +40,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import Event, HomeAssistant, callback
-from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
@@ -130,9 +134,8 @@ from .migrate import async_migrate_discovered_value
from .services import ZWaveServices
CONNECT_TIMEOUT = 10
-DATA_CLIENT_LISTEN_TASK = "client_listen_task"
DATA_DRIVER_EVENTS = "driver_events"
-DATA_START_CLIENT_TASK = "start_client_task"
+DRIVER_READY_TIMEOUT = 60
CONFIG_SCHEMA = vol.Schema(
{
@@ -145,6 +148,24 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
+PLATFORMS = [
+ Platform.BINARY_SENSOR,
+ Platform.BUTTON,
+ Platform.CLIMATE,
+ Platform.COVER,
+ Platform.EVENT,
+ Platform.FAN,
+ Platform.HUMIDIFIER,
+ Platform.LIGHT,
+ Platform.LOCK,
+ Platform.NUMBER,
+ Platform.SELECT,
+ Platform.SENSOR,
+ Platform.SIREN,
+ Platform.SWITCH,
+ Platform.UPDATE,
+]
+
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Z-Wave JS component."""
@@ -196,53 +217,99 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
raise ConfigEntryNotReady(f"Failed to connect: {err}") from err
async_delete_issue(hass, DOMAIN, "invalid_server_version")
- LOGGER.info("Connected to Zwave JS Server")
+ LOGGER.debug("Connected to Zwave JS Server")
# Set up websocket API
async_register_api(hass)
- entry.runtime_data = {}
- # Create a task to allow the config entry to be unloaded before the driver is ready.
- # Unloading the config entry is needed if the client listen task errors.
- start_client_task = hass.async_create_task(start_client(hass, entry, client))
- entry.runtime_data[DATA_START_CLIENT_TASK] = start_client_task
+ driver_ready = asyncio.Event()
+ listen_task = entry.async_create_background_task(
+ hass,
+ client_listen(hass, entry, client, driver_ready),
+ f"{DOMAIN}_{entry.title}_client_listen",
+ )
- return True
-
-
-async def start_client(
- hass: HomeAssistant, entry: ConfigEntry, client: ZwaveClient
-) -> None:
- """Start listening with the client."""
- entry.runtime_data[DATA_CLIENT] = client
- driver_events = entry.runtime_data[DATA_DRIVER_EVENTS] = DriverEvents(hass, entry)
+ entry.async_on_unload(client.disconnect)
async def handle_ha_shutdown(event: Event) -> None:
"""Handle HA shutdown."""
- await disconnect_client(hass, entry)
+ await client.disconnect()
- listen_task = asyncio.create_task(
- client_listen(hass, entry, client, driver_events.ready)
- )
- entry.runtime_data[DATA_CLIENT_LISTEN_TASK] = listen_task
entry.async_on_unload(
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, handle_ha_shutdown)
)
- try:
- await driver_events.ready.wait()
- except asyncio.CancelledError:
- LOGGER.debug("Cancelling start client")
- return
-
- LOGGER.info("Connection to Zwave JS Server initialized")
-
- assert client.driver
- async_dispatcher_send(
- hass, f"{DOMAIN}_{client.driver.controller.home_id}_connected_to_server"
+ driver_ready_task = entry.async_create_task(
+ hass,
+ driver_ready.wait(),
+ f"{DOMAIN}_{entry.title}_driver_ready",
+ )
+ done, pending = await asyncio.wait(
+ (driver_ready_task, listen_task),
+ return_when=asyncio.FIRST_COMPLETED,
+ timeout=DRIVER_READY_TIMEOUT,
)
- await driver_events.setup(client.driver)
+ if driver_ready_task in pending or listen_task in done:
+ error_message = "Driver ready timed out"
+ listen_error: BaseException | None = None
+ if listen_task.done():
+ listen_error, error_message = _get_listen_task_error(listen_task)
+ else:
+ listen_task.cancel()
+ driver_ready_task.cancel()
+ raise ConfigEntryNotReady(error_message) from listen_error
+
+ LOGGER.debug("Connection to Zwave JS Server initialized")
+
+ entry_runtime_data = entry.runtime_data = {
+ DATA_CLIENT: client,
+ }
+ entry_runtime_data[DATA_DRIVER_EVENTS] = driver_events = DriverEvents(hass, entry)
+
+ driver = client.driver
+ # When the driver is ready we know it's set on the client.
+ assert driver is not None
+
+ await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+
+ with contextlib.suppress(NotConnected):
+ # If the client isn't connected the listen task may have an exception
+ # and we'll handle the clean up below.
+ await driver_events.setup(driver)
+
+ # If the listen task is already failed, we need to raise ConfigEntryNotReady
+ if listen_task.done():
+ listen_error, error_message = _get_listen_task_error(listen_task)
+ await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
+ raise ConfigEntryNotReady(error_message) from listen_error
+
+ # Re-attach trigger listeners.
+ # Schedule this call to make sure the config entry is loaded first.
+
+ @callback
+ def on_config_entry_loaded() -> None:
+ """Signal that server connection and driver are ready."""
+ if entry.state is ConfigEntryState.LOADED:
+ async_dispatcher_send(
+ hass,
+ f"{DOMAIN}_{driver.controller.home_id}_connected_to_server",
+ )
+
+ entry.async_on_unload(entry.async_on_state_change(on_config_entry_loaded))
+
+ return True
+
+
+def _get_listen_task_error(
+ listen_task: asyncio.Task,
+) -> tuple[BaseException | None, str]:
+ """Check the listen task for errors."""
+ if listen_error := listen_task.exception():
+ error_message = f"Client listen failed: {listen_error}"
+ else:
+ error_message = "Client connection was closed"
+ return listen_error, error_message
class DriverEvents:
@@ -255,8 +322,6 @@ class DriverEvents:
self.config_entry = entry
self.dev_reg = dr.async_get(hass)
self.hass = hass
- self.platform_setup_tasks: dict[str, asyncio.Task] = {}
- self.ready = asyncio.Event()
# Make sure to not pass self to ControllerEvents until all attributes are set.
self.controller_events = ControllerEvents(hass, self)
@@ -298,11 +363,17 @@ class DriverEvents:
self.dev_reg.async_get_device(identifiers={get_device_id(driver, node)})
for node in controller.nodes.values()
]
+ provisioned_devices = [
+ self.dev_reg.async_get(entry.additional_properties["device_id"])
+ for entry in await controller.async_get_provisioning_entries()
+ if entry.additional_properties
+ and "device_id" in entry.additional_properties
+ ]
# Devices that are in the device registry that are not known by the controller
# can be removed
for device in stored_devices:
- if device not in known_devices:
+ if device not in known_devices and device not in provisioned_devices:
self.dev_reg.async_remove_device(device.id)
# run discovery on controller node
@@ -339,16 +410,6 @@ class DriverEvents:
controller.on("identify", self.controller_events.async_on_identify)
)
- async def async_setup_platform(self, platform: Platform) -> None:
- """Set up platform if needed."""
- if platform not in self.platform_setup_tasks:
- self.platform_setup_tasks[platform] = self.hass.async_create_task(
- self.hass.config_entries.async_forward_entry_setups(
- self.config_entry, [platform]
- )
- )
- await self.platform_setup_tasks[platform]
-
class ControllerEvents:
"""Represent controller events.
@@ -380,9 +441,6 @@ class ControllerEvents:
async def async_on_node_added(self, node: ZwaveNode) -> None:
"""Handle node added event."""
- # Every node including the controller will have at least one sensor
- await self.driver_events.async_setup_platform(Platform.SENSOR)
-
# Remove stale entities that may exist from a previous interview when an
# interview is started.
base_unique_id = get_valueless_base_unique_id(self.driver_events.driver, node)
@@ -396,6 +454,8 @@ class ControllerEvents:
)
)
+ await self.async_check_preprovisioned_device(node)
+
if node.is_controller_node:
# Create a controller status sensor for each device
async_dispatcher_send(
@@ -411,7 +471,6 @@ class ControllerEvents:
)
# Create a ping button for each device
- await self.driver_events.async_setup_platform(Platform.BUTTON)
async_dispatcher_send(
self.hass,
f"{DOMAIN}_{self.config_entry.entry_id}_add_ping_button_entity",
@@ -446,7 +505,7 @@ class ControllerEvents:
# we do submit the node to device registry so user has
# some visual feedback that something is (in the process of) being added
- self.register_node_in_dev_reg(node)
+ await self.async_register_node_in_dev_reg(node)
@callback
def async_on_node_removed(self, event: dict) -> None:
@@ -523,18 +582,52 @@ class ControllerEvents:
f"{DOMAIN}.identify_controller.{dev_id[1]}",
)
- @callback
- def register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry:
+ async def async_check_preprovisioned_device(self, node: ZwaveNode) -> None:
+ """Check if the node was preprovisioned and update the device registry."""
+ provisioning_entry = (
+ await self.driver_events.driver.controller.async_get_provisioning_entry(
+ node.node_id
+ )
+ )
+ if (
+ provisioning_entry
+ and provisioning_entry.additional_properties
+ and "device_id" in provisioning_entry.additional_properties
+ ):
+ preprovisioned_device = self.dev_reg.async_get(
+ provisioning_entry.additional_properties["device_id"]
+ )
+
+ if preprovisioned_device:
+ dsk = provisioning_entry.dsk
+ dsk_identifier = (DOMAIN, f"provision_{dsk}")
+
+ # If the pre-provisioned device has the DSK identifier, remove it
+ if dsk_identifier in preprovisioned_device.identifiers:
+ driver = self.driver_events.driver
+ device_id = get_device_id(driver, node)
+ device_id_ext = get_device_id_ext(driver, node)
+ new_identifiers = preprovisioned_device.identifiers.copy()
+ new_identifiers.remove(dsk_identifier)
+ new_identifiers.add(device_id)
+ if device_id_ext:
+ new_identifiers.add(device_id_ext)
+ self.dev_reg.async_update_device(
+ preprovisioned_device.id,
+ new_identifiers=new_identifiers,
+ )
+
+ async def async_register_node_in_dev_reg(self, node: ZwaveNode) -> dr.DeviceEntry:
"""Register node in dev reg."""
driver = self.driver_events.driver
device_id = get_device_id(driver, node)
device_id_ext = get_device_id_ext(driver, node)
node_id_device = self.dev_reg.async_get_device(identifiers={device_id})
- via_device_id = None
+ via_identifier = None
controller = driver.controller
# Get the controller node device ID if this node is not the controller
if controller.own_node and controller.own_node != node:
- via_device_id = get_device_id(driver, controller.own_node)
+ via_identifier = get_device_id(driver, controller.own_node)
if device_id_ext:
# If there is a device with this node ID but with a different hardware
@@ -581,7 +674,7 @@ class ControllerEvents:
model=node.device_config.label,
manufacturer=node.device_config.manufacturer,
suggested_area=node.location if node.location else UNDEFINED,
- via_device=via_device_id,
+ via_device=via_identifier,
)
async_dispatcher_send(self.hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device)
@@ -615,7 +708,7 @@ class NodeEvents:
"""Handle node ready event."""
LOGGER.debug("Processing node %s", node)
# register (or update) node in device registry
- device = self.controller_events.register_node_in_dev_reg(node)
+ device = await self.controller_events.async_register_node_in_dev_reg(node)
# Remove any old value ids if this is a reinterview.
self.controller_events.discovered_value_ids.pop(device.id, None)
@@ -668,9 +761,6 @@ class NodeEvents:
cc.id == CommandClass.FIRMWARE_UPDATE_MD.value
for cc in node.command_classes
):
- await self.controller_events.driver_events.async_setup_platform(
- Platform.UPDATE
- )
async_dispatcher_send(
self.hass,
f"{DOMAIN}_{self.config_entry.entry_id}_add_firmware_update_entity",
@@ -701,21 +791,19 @@ class NodeEvents:
value_updates_disc_info: dict[str, ZwaveDiscoveryInfo],
) -> None:
"""Handle discovery info and all dependent tasks."""
+ platform = disc_info.platform
# This migration logic was added in 2021.3 to handle a breaking change to
# the value_id format. Some time in the future, this call (as well as the
# helper functions) can be removed.
async_migrate_discovered_value(
self.hass,
self.ent_reg,
- self.controller_events.registered_unique_ids[device.id][disc_info.platform],
+ self.controller_events.registered_unique_ids[device.id][platform],
device,
self.controller_events.driver_events.driver,
disc_info,
)
- platform = disc_info.platform
- await self.controller_events.driver_events.async_setup_platform(platform)
-
LOGGER.debug("Discovered entity: %s", disc_info)
async_dispatcher_send(
self.hass,
@@ -930,63 +1018,37 @@ async def client_listen(
driver_ready: asyncio.Event,
) -> None:
"""Listen with the client."""
- should_reload = True
try:
await client.listen(driver_ready)
- except asyncio.CancelledError:
- should_reload = False
except BaseZwaveJSServerError as err:
- LOGGER.error("Failed to listen: %s", err)
- except Exception as err: # noqa: BLE001
+ if entry.state is not ConfigEntryState.LOADED:
+ raise
+ LOGGER.error("Client listen failed: %s", err)
+ except Exception as err:
# We need to guard against unknown exceptions to not crash this task.
LOGGER.exception("Unexpected exception: %s", err)
+ if entry.state is not ConfigEntryState.LOADED:
+ raise
# The entry needs to be reloaded since a new driver state
# will be acquired on reconnect.
# All model instances will be replaced when the new state is acquired.
- if should_reload:
- LOGGER.info("Disconnected from server. Reloading integration")
- hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
-
-
-async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None:
- """Disconnect client."""
- client: ZwaveClient = entry.runtime_data[DATA_CLIENT]
- listen_task: asyncio.Task = entry.runtime_data[DATA_CLIENT_LISTEN_TASK]
- start_client_task: asyncio.Task = entry.runtime_data[DATA_START_CLIENT_TASK]
- driver_events: DriverEvents = entry.runtime_data[DATA_DRIVER_EVENTS]
- listen_task.cancel()
- start_client_task.cancel()
- platform_setup_tasks = driver_events.platform_setup_tasks.values()
- for task in platform_setup_tasks:
- task.cancel()
-
- tasks = (listen_task, start_client_task, *platform_setup_tasks)
- await asyncio.gather(*tasks, return_exceptions=True)
- for task in tasks:
- with suppress(asyncio.CancelledError):
- await task
-
- if client.connected:
- await client.disconnect()
- LOGGER.info("Disconnected from Zwave JS Server")
+ if not hass.is_stopping:
+ if entry.state is not ConfigEntryState.LOADED:
+ raise HomeAssistantError("Listen task ended unexpectedly")
+ LOGGER.debug("Disconnected from server. Reloading integration")
+ hass.config_entries.async_schedule_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
- client: ZwaveClient = entry.runtime_data[DATA_CLIENT]
- driver_events: DriverEvents = entry.runtime_data[DATA_DRIVER_EVENTS]
- platforms = [
- platform
- for platform, task in driver_events.platform_setup_tasks.items()
- if not task.cancel()
- ]
- unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms)
+ unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
- if client.connected and client.driver:
- await async_disable_server_logging_if_needed(hass, entry, client.driver)
- if DATA_CLIENT_LISTEN_TASK in entry.runtime_data:
- await disconnect_client(hass, entry)
+ entry_runtime_data = entry.runtime_data
+ client: ZwaveClient = entry_runtime_data[DATA_CLIENT]
+
+ if client.connected and (driver := client.driver):
+ await async_disable_server_logging_if_needed(hass, entry, driver)
if entry.data.get(CONF_USE_ADDON) and entry.disabled_by:
addon_manager: AddonManager = get_addon_manager(hass)
diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py
index 37ce9a51c91..eb86a344c6e 100644
--- a/homeassistant/components/zwave_js/api.py
+++ b/homeassistant/components/zwave_js/api.py
@@ -91,6 +91,7 @@ from .const import (
from .helpers import (
async_enable_statistics,
async_get_node_from_device_id,
+ async_get_provisioning_entry_from_device_id,
get_device_id,
)
@@ -171,6 +172,10 @@ ADDITIONAL_PROPERTIES = "additional_properties"
STATUS = "status"
REQUESTED_SECURITY_CLASSES = "requestedSecurityClasses"
+PROTOCOL = "protocol"
+DEVICE_NAME = "device_name"
+AREA_ID = "area_id"
+
FEATURE = "feature"
STRATEGY = "strategy"
@@ -398,6 +403,7 @@ def async_register_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_subscribe_s2_inclusion)
websocket_api.async_register_command(hass, websocket_grant_security_classes)
websocket_api.async_register_command(hass, websocket_validate_dsk_and_enter_pin)
+ websocket_api.async_register_command(hass, websocket_subscribe_new_devices)
websocket_api.async_register_command(hass, websocket_provision_smart_start_node)
websocket_api.async_register_command(hass, websocket_unprovision_smart_start_node)
websocket_api.async_register_command(hass, websocket_get_provisioning_entries)
@@ -405,6 +411,7 @@ def async_register_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(
hass, websocket_try_parse_dsk_from_qr_code_string
)
+ websocket_api.async_register_command(hass, websocket_lookup_device)
websocket_api.async_register_command(hass, websocket_supports_feature)
websocket_api.async_register_command(hass, websocket_stop_inclusion)
websocket_api.async_register_command(hass, websocket_stop_exclusion)
@@ -454,6 +461,8 @@ def async_register_api(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_node_capabilities)
websocket_api.async_register_command(hass, websocket_invoke_cc_api)
websocket_api.async_register_command(hass, websocket_get_integration_settings)
+ websocket_api.async_register_command(hass, websocket_backup_nvm)
+ websocket_api.async_register_command(hass, websocket_restore_nvm)
hass.http.register_view(FirmwareUploadView(dr.async_get(hass)))
@@ -518,6 +527,7 @@ async def websocket_network_status(
"supported_function_types": controller.supported_function_types,
"suc_node_id": controller.suc_node_id,
"supports_timers": controller.supports_timers,
+ "supports_long_range": controller.supports_long_range,
"is_rebuilding_routes": controller.is_rebuilding_routes,
"inclusion_state": controller.inclusion_state,
"rf_region": controller.rf_region,
@@ -627,14 +637,38 @@ async def websocket_node_metadata(
}
)
@websocket_api.async_response
-@async_get_node
async def websocket_node_alerts(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
- node: Node,
) -> None:
"""Get the alerts for a Z-Wave JS node."""
+ try:
+ node = async_get_node_from_device_id(hass, msg[DEVICE_ID])
+ except ValueError as err:
+ if "can't be found" in err.args[0]:
+ provisioning_entry = await async_get_provisioning_entry_from_device_id(
+ hass, msg[DEVICE_ID]
+ )
+ if provisioning_entry:
+ connection.send_result(
+ msg[ID],
+ {
+ "comments": [
+ {
+ "level": "info",
+ "text": "This device has been provisioned but is not yet included in the "
+ "network.",
+ }
+ ],
+ },
+ )
+ else:
+ connection.send_error(msg[ID], ERR_NOT_FOUND, str(err))
+ else:
+ connection.send_error(msg[ID], ERR_NOT_LOADED, str(err))
+ return
+
connection.send_result(
msg[ID],
{
@@ -805,7 +839,7 @@ async def websocket_add_node(
]
msg[DATA_UNSUBSCRIBE] = unsubs
- if controller.inclusion_state == InclusionState.INCLUDING:
+ if controller.inclusion_state in (InclusionState.INCLUDING, InclusionState.BUSY):
connection.send_result(
msg[ID],
True, # Inclusion is already in progress
@@ -883,6 +917,11 @@ async def websocket_subscribe_s2_inclusion(
) -> None:
"""Subscribe to S2 inclusion initiated by the controller."""
+ @callback
+ def async_cleanup() -> None:
+ for unsub in unsubs:
+ unsub()
+
@callback
def forward_dsk(event: dict) -> None:
connection.send_message(
@@ -891,9 +930,18 @@ async def websocket_subscribe_s2_inclusion(
)
)
- unsub = driver.controller.on("validate dsk and enter pin", forward_dsk)
- connection.subscriptions[msg["id"]] = unsub
- msg[DATA_UNSUBSCRIBE] = [unsub]
+ @callback
+ def handle_requested_grant(event: dict) -> None:
+ """Accept the requested security classes without user interaction."""
+ hass.async_create_task(
+ driver.controller.async_grant_security_classes(event["requested_grant"])
+ )
+
+ connection.subscriptions[msg["id"]] = async_cleanup
+ msg[DATA_UNSUBSCRIBE] = unsubs = [
+ driver.controller.on("grant security classes", handle_requested_grant),
+ driver.controller.on("validate dsk and enter pin", forward_dsk),
+ ]
connection.send_result(msg[ID])
@@ -953,18 +1001,58 @@ async def websocket_validate_dsk_and_enter_pin(
connection.send_result(msg[ID])
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required(TYPE): "zwave_js/subscribe_new_devices",
+ vol.Required(ENTRY_ID): str,
+ }
+)
+@websocket_api.async_response
+async def websocket_subscribe_new_devices(
+ hass: HomeAssistant,
+ connection: ActiveConnection,
+ msg: dict[str, Any],
+) -> None:
+ """Subscribe to new devices."""
+
+ @callback
+ def async_cleanup() -> None:
+ for unsub in unsubs:
+ unsub()
+
+ @callback
+ def device_registered(device: dr.DeviceEntry) -> None:
+ device_details = {
+ "name": device.name,
+ "id": device.id,
+ "manufacturer": device.manufacturer,
+ "model": device.model,
+ }
+ connection.send_message(
+ websocket_api.event_message(
+ msg[ID], {"event": "device registered", "device": device_details}
+ )
+ )
+
+ connection.subscriptions[msg["id"]] = async_cleanup
+ msg[DATA_UNSUBSCRIBE] = unsubs = [
+ async_dispatcher_connect(
+ hass, EVENT_DEVICE_ADDED_TO_REGISTRY, device_registered
+ ),
+ ]
+ connection.send_result(msg[ID])
+
+
@websocket_api.require_admin
@websocket_api.websocket_command(
{
vol.Required(TYPE): "zwave_js/provision_smart_start_node",
vol.Required(ENTRY_ID): str,
- vol.Exclusive(
- PLANNED_PROVISIONING_ENTRY, "options"
- ): PLANNED_PROVISIONING_ENTRY_SCHEMA,
- vol.Exclusive(
- QR_PROVISIONING_INFORMATION, "options"
- ): QR_PROVISIONING_INFORMATION_SCHEMA,
- vol.Exclusive(QR_CODE_STRING, "options"): QR_CODE_STRING_SCHEMA,
+ vol.Required(QR_PROVISIONING_INFORMATION): QR_PROVISIONING_INFORMATION_SCHEMA,
+ vol.Optional(PROTOCOL): vol.Coerce(Protocols),
+ vol.Optional(DEVICE_NAME): str,
+ vol.Optional(AREA_ID): str,
}
)
@websocket_api.async_response
@@ -979,36 +1067,68 @@ async def websocket_provision_smart_start_node(
driver: Driver,
) -> None:
"""Pre-provision a smart start node."""
- try:
- cv.has_at_least_one_key(
- PLANNED_PROVISIONING_ENTRY, QR_PROVISIONING_INFORMATION, QR_CODE_STRING
- )(msg)
- except vol.Invalid as err:
- connection.send_error(
- msg[ID],
- ERR_INVALID_FORMAT,
- err.args[0],
- )
- return
+ qr_info = msg[QR_PROVISIONING_INFORMATION]
- provisioning_info = (
- msg.get(PLANNED_PROVISIONING_ENTRY)
- or msg.get(QR_PROVISIONING_INFORMATION)
- or msg[QR_CODE_STRING]
- )
-
- if (
- QR_PROVISIONING_INFORMATION in msg
- and provisioning_info.version == QRCodeVersion.S2
- ):
+ if qr_info.version == QRCodeVersion.S2:
connection.send_error(
msg[ID],
ERR_INVALID_FORMAT,
"QR code version S2 is not supported for this command",
)
return
+
+ provisioning_info = ProvisioningEntry(
+ dsk=qr_info.dsk,
+ security_classes=qr_info.security_classes,
+ requested_security_classes=qr_info.requested_security_classes,
+ protocol=msg.get(PROTOCOL),
+ additional_properties=qr_info.additional_properties,
+ )
+
+ device = None
+ # Create an empty device if device_name is provided
+ if device_name := msg.get(DEVICE_NAME):
+ dev_reg = dr.async_get(hass)
+
+ # Create a unique device identifier using the DSK
+ device_identifier = (DOMAIN, f"provision_{qr_info.dsk}")
+
+ manufacturer = None
+ model = None
+
+ device_info = await driver.config_manager.lookup_device(
+ qr_info.manufacturer_id,
+ qr_info.product_type,
+ qr_info.product_id,
+ )
+ if device_info:
+ manufacturer = device_info.manufacturer
+ model = device_info.label
+
+ # Create an empty device
+ device = dev_reg.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={device_identifier},
+ name=device_name,
+ manufacturer=manufacturer,
+ model=model,
+ via_device=get_device_id(driver, driver.controller.own_node)
+ if driver.controller.own_node
+ else None,
+ )
+ dev_reg.async_update_device(
+ device.id, area_id=msg.get(AREA_ID), name_by_user=device_name
+ )
+
+ if provisioning_info.additional_properties is None:
+ provisioning_info.additional_properties = {}
+ provisioning_info.additional_properties["device_id"] = device.id
+
await driver.controller.async_provision_smart_start_node(provisioning_info)
- connection.send_result(msg[ID])
+ if device:
+ connection.send_result(msg[ID], device.id)
+ else:
+ connection.send_result(msg[ID])
@websocket_api.require_admin
@@ -1042,7 +1162,24 @@ async def websocket_unprovision_smart_start_node(
)
return
dsk_or_node_id = msg.get(DSK) or msg[NODE_ID]
+ provisioning_entry = await driver.controller.async_get_provisioning_entry(
+ dsk_or_node_id
+ )
+ if (
+ provisioning_entry
+ and provisioning_entry.additional_properties
+ and "device_id" in provisioning_entry.additional_properties
+ ):
+ device_identifier = (DOMAIN, f"provision_{provisioning_entry.dsk}")
+ device_id = provisioning_entry.additional_properties["device_id"]
+ dev_reg = dr.async_get(hass)
+ device = dev_reg.async_get(device_id)
+ if device and device.identifiers == {device_identifier}:
+ # Only remove the device if nothing else has claimed it
+ dev_reg.async_remove_device(device_id)
+
await driver.controller.async_unprovision_smart_start_node(dsk_or_node_id)
+
connection.send_result(msg[ID])
@@ -1121,6 +1258,41 @@ async def websocket_try_parse_dsk_from_qr_code_string(
)
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required(TYPE): "zwave_js/lookup_device",
+ vol.Required(ENTRY_ID): str,
+ vol.Required(MANUFACTURER_ID): int,
+ vol.Required(PRODUCT_TYPE): int,
+ vol.Required(PRODUCT_ID): int,
+ vol.Optional(APPLICATION_VERSION): str,
+ }
+)
+@websocket_api.async_response
+@async_handle_failed_command
+@async_get_entry
+async def websocket_lookup_device(
+ hass: HomeAssistant,
+ connection: ActiveConnection,
+ msg: dict[str, Any],
+ entry: ConfigEntry,
+ client: Client,
+ driver: Driver,
+) -> None:
+ """Look up the definition of a given device in the configuration DB."""
+ device = await driver.config_manager.lookup_device(
+ msg[MANUFACTURER_ID],
+ msg[PRODUCT_TYPE],
+ msg[PRODUCT_ID],
+ msg.get(APPLICATION_VERSION),
+ )
+ if device is None:
+ connection.send_error(msg[ID], ERR_NOT_FOUND, "Device not found")
+ else:
+ connection.send_result(msg[ID], device.to_dict())
+
+
@websocket_api.require_admin
@websocket_api.websocket_command(
{
@@ -2765,3 +2937,126 @@ def websocket_get_integration_settings(
CONF_INSTALLER_MODE: hass.data[DOMAIN].get(CONF_INSTALLER_MODE, False),
},
)
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required(TYPE): "zwave_js/backup_nvm",
+ vol.Required(ENTRY_ID): str,
+ }
+)
+@websocket_api.async_response
+@async_handle_failed_command
+@async_get_entry
+async def websocket_backup_nvm(
+ hass: HomeAssistant,
+ connection: ActiveConnection,
+ msg: dict[str, Any],
+ entry: ConfigEntry,
+ client: Client,
+ driver: Driver,
+) -> None:
+ """Backup NVM data."""
+ controller = driver.controller
+
+ @callback
+ def async_cleanup() -> None:
+ """Remove signal listeners."""
+ for unsub in unsubs:
+ unsub()
+
+ @callback
+ def forward_progress(event: dict) -> None:
+ """Forward progress events to websocket."""
+ connection.send_message(
+ websocket_api.event_message(
+ msg[ID],
+ {
+ "event": event["event"],
+ "bytesRead": event["bytesRead"],
+ "total": event["total"],
+ },
+ )
+ )
+
+ # Set up subscription for progress events
+ connection.subscriptions[msg["id"]] = async_cleanup
+ msg[DATA_UNSUBSCRIBE] = unsubs = [
+ controller.on("nvm backup progress", forward_progress),
+ ]
+
+ result = await controller.async_backup_nvm_raw_base64()
+ # Send the finished event with the backup data
+ connection.send_message(
+ websocket_api.event_message(
+ msg[ID],
+ {
+ "event": "finished",
+ "data": result,
+ },
+ )
+ )
+ connection.send_result(msg[ID])
+
+
+@websocket_api.require_admin
+@websocket_api.websocket_command(
+ {
+ vol.Required(TYPE): "zwave_js/restore_nvm",
+ vol.Required(ENTRY_ID): str,
+ vol.Required("data"): str,
+ }
+)
+@websocket_api.async_response
+@async_handle_failed_command
+@async_get_entry
+async def websocket_restore_nvm(
+ hass: HomeAssistant,
+ connection: ActiveConnection,
+ msg: dict[str, Any],
+ entry: ConfigEntry,
+ client: Client,
+ driver: Driver,
+) -> None:
+ """Restore NVM data."""
+ controller = driver.controller
+
+ @callback
+ def async_cleanup() -> None:
+ """Remove signal listeners."""
+ for unsub in unsubs:
+ unsub()
+
+ @callback
+ def forward_progress(event: dict) -> None:
+ """Forward progress events to websocket."""
+ connection.send_message(
+ websocket_api.event_message(
+ msg[ID],
+ {
+ "event": event["event"],
+ "bytesRead": event.get("bytesRead"),
+ "bytesWritten": event.get("bytesWritten"),
+ "total": event["total"],
+ },
+ )
+ )
+
+ # Set up subscription for progress events
+ connection.subscriptions[msg["id"]] = async_cleanup
+ msg[DATA_UNSUBSCRIBE] = unsubs = [
+ controller.on("nvm convert progress", forward_progress),
+ controller.on("nvm restore progress", forward_progress),
+ ]
+
+ await controller.async_restore_nvm_base64(msg["data"])
+ connection.send_message(
+ websocket_api.event_message(
+ msg[ID],
+ {
+ "event": "finished",
+ },
+ )
+ )
+ connection.send_result(msg[ID])
diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py
index 0f1495fc6e6..1439aa0ca0f 100644
--- a/homeassistant/components/zwave_js/binary_sensor.py
+++ b/homeassistant/components/zwave_js/binary_sensor.py
@@ -22,7 +22,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_CLIENT, DOMAIN
from .discovery import ZwaveDiscoveryInfo
@@ -67,7 +67,45 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription):
# Mappings for Notification sensors
-# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json
+# https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx
+#
+# Mapping rules:
+# The catch all description should not have a device class and be marked as diagnostic.
+#
+# The following notifications have been moved to diagnostic:
+# Smoke Alarm
+# - Alarm silenced
+# - Replacement required
+# - Replacement required, End-of-life
+# - Maintenance required, planned periodic inspection
+# - Maintenance required, dust in device
+# CO Alarm
+# - Carbon monoxide test
+# - Replacement required
+# - Replacement required, End-of-life
+# - Alarm silenced
+# - Maintenance required, planned periodic inspection
+# CO2 Alarm
+# - Carbon dioxide test
+# - Replacement required
+# - Replacement required, End-of-life
+# - Alarm silenced
+# - Maintenance required, planned periodic inspection
+# Heat Alarm
+# - Rapid temperature rise (location provided)
+# - Rapid temperature rise
+# - Rapid temperature fall (location provided)
+# - Rapid temperature fall
+# - Heat alarm test
+# - Alarm silenced
+# - Replacement required, End-of-life
+# - Maintenance required, dust in device
+# - Maintenance required, planned periodic inspection
+
+# Water Alarm
+# - Replace water filter
+# - Sump pump failure
+
NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = (
NotificationZWaveJSEntityDescription(
# NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected
@@ -75,10 +113,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
states=("1", "2"),
device_class=BinarySensorDeviceClass.SMOKE,
),
+ NotificationZWaveJSEntityDescription(
+ # NotificationType 1: Smoke Alarm - State Id's 4, 5, 7, 8
+ key=NOTIFICATION_SMOKE_ALARM,
+ states=("4", "5", "7", "8"),
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
NotificationZWaveJSEntityDescription(
# NotificationType 1: Smoke Alarm - All other State Id's
key=NOTIFICATION_SMOKE_ALARM,
- device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 2: Carbon Monoxide - State Id's 1 and 2
@@ -86,10 +131,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
states=("1", "2"),
device_class=BinarySensorDeviceClass.CO,
),
+ NotificationZWaveJSEntityDescription(
+ # NotificationType 2: Carbon Monoxide - State Id 4, 5, 7
+ key=NOTIFICATION_CARBON_MONOOXIDE,
+ states=("4", "5", "7"),
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
NotificationZWaveJSEntityDescription(
# NotificationType 2: Carbon Monoxide - All other State Id's
key=NOTIFICATION_CARBON_MONOOXIDE,
- device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 3: Carbon Dioxide - State Id's 1 and 2
@@ -97,10 +149,17 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
states=("1", "2"),
device_class=BinarySensorDeviceClass.GAS,
),
+ NotificationZWaveJSEntityDescription(
+ # NotificationType 3: Carbon Dioxide - State Id's 4, 5, 7
+ key=NOTIFICATION_CARBON_DIOXIDE,
+ states=("4", "5", "7"),
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
NotificationZWaveJSEntityDescription(
# NotificationType 3: Carbon Dioxide - All other State Id's
key=NOTIFICATION_CARBON_DIOXIDE,
- device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 4: Heat - State Id's 1, 2, 5, 6 (heat/underheat)
@@ -109,20 +168,34 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
device_class=BinarySensorDeviceClass.HEAT,
),
NotificationZWaveJSEntityDescription(
- # NotificationType 4: Heat - All other State Id's
+ # NotificationType 4: Heat - State ID's 8, A, B
key=NOTIFICATION_HEAT,
+ states=("8", "10", "11"),
device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
- # NotificationType 5: Water - State Id's 1, 2, 3, 4
+ # NotificationType 4: Heat - All other State Id's
+ key=NOTIFICATION_HEAT,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ NotificationZWaveJSEntityDescription(
+ # NotificationType 5: Water - State Id's 1, 2, 3, 4, 6, 7, 8, 9, 0A
key=NOTIFICATION_WATER,
- states=("1", "2", "3", "4"),
+ states=("1", "2", "3", "4", "6", "7", "8", "9", "10"),
device_class=BinarySensorDeviceClass.MOISTURE,
),
+ NotificationZWaveJSEntityDescription(
+ # NotificationType 5: Water - State Id's B
+ key=NOTIFICATION_WATER,
+ states=("11",),
+ device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
NotificationZWaveJSEntityDescription(
# NotificationType 5: Water - All other State Id's
key=NOTIFICATION_WATER,
- device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
NotificationZWaveJSEntityDescription(
# NotificationType 6: Access Control - State Id's 1, 2, 3, 4 (Lock)
@@ -214,16 +287,22 @@ NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] =
device_class=BinarySensorDeviceClass.SOUND,
),
NotificationZWaveJSEntityDescription(
- # NotificationType 18: Gas
+ # NotificationType 18: Gas - State Id's 1, 2, 3, 4
key=NOTIFICATION_GAS,
states=("1", "2", "3", "4"),
device_class=BinarySensorDeviceClass.GAS,
),
NotificationZWaveJSEntityDescription(
- # NotificationType 18: Gas
+ # NotificationType 18: Gas - State Id 6
key=NOTIFICATION_GAS,
states=("6",),
device_class=BinarySensorDeviceClass.PROBLEM,
+ entity_category=EntityCategory.DIAGNOSTIC,
+ ),
+ NotificationZWaveJSEntityDescription(
+ # NotificationType 18: Gas - All other State Id's
+ key=NOTIFICATION_GAS,
+ entity_category=EntityCategory.DIAGNOSTIC,
),
)
@@ -261,7 +340,7 @@ def is_valid_notification_binary_sensor(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave binary sensor from config entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py
index 7fd42700a05..f3a1d5af04d 100644
--- a/homeassistant/components/zwave_js/button.py
+++ b/homeassistant/components/zwave_js/button.py
@@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_CLIENT, DOMAIN, LOGGER
from .discovery import ZwaveDiscoveryInfo
@@ -24,7 +24,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave button from config entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py
index 580694cae11..b27dbdad1a0 100644
--- a/homeassistant/components/zwave_js/climate.py
+++ b/homeassistant/components/zwave_js/climate.py
@@ -35,7 +35,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import DATA_CLIENT, DOMAIN
@@ -97,7 +97,7 @@ ATTR_FAN_STATE = "fan_state"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave climate from config entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py
index 44adf6a12ab..1877658ce42 100644
--- a/homeassistant/components/zwave_js/config_flow.py
+++ b/homeassistant/components/zwave_js/config_flow.py
@@ -4,12 +4,17 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import asyncio
+from datetime import datetime
import logging
+from pathlib import Path
from typing import Any
import aiohttp
from serial.tools import list_ports
import voluptuous as vol
+from zwave_js_server.client import Client
+from zwave_js_server.exceptions import FailedCommand
+from zwave_js_server.model.driver import Driver
from zwave_js_server.version import VersionInfo, get_server_version
from homeassistant.components import usb
@@ -21,19 +26,16 @@ from homeassistant.components.hassio import (
)
from homeassistant.config_entries import (
SOURCE_USB,
- ConfigEntriesFlowManager,
ConfigEntry,
ConfigEntryBaseFlow,
ConfigEntryState,
ConfigFlow,
- ConfigFlowContext,
ConfigFlowResult,
OptionsFlow,
- OptionsFlowManager,
)
from homeassistant.const import CONF_NAME, CONF_URL
from homeassistant.core import HomeAssistant, callback
-from homeassistant.data_entry_flow import AbortFlow, FlowManager
+from homeassistant.data_entry_flow import AbortFlow
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.hassio import is_hassio
@@ -42,7 +44,6 @@ from homeassistant.helpers.service_info.usb import UsbServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from homeassistant.helpers.typing import VolDictType
-from . import disconnect_client
from .addon import get_addon_manager
from .const import (
ADDON_SLUG,
@@ -65,6 +66,7 @@ from .const import (
CONF_S2_UNAUTHENTICATED_KEY,
CONF_USB_PATH,
CONF_USE_ADDON,
+ DATA_CLIENT,
DOMAIN,
)
@@ -79,6 +81,9 @@ CONF_EMULATE_HARDWARE = "emulate_hardware"
CONF_LOG_LEVEL = "log_level"
SERVER_VERSION_TIMEOUT = 10
+OPTIONS_INTENT_MIGRATE = "intent_migrate"
+OPTIONS_INTENT_RECONFIGURE = "intent_reconfigure"
+
ADDON_LOG_LEVELS = {
"error": "Error",
"warn": "Warn",
@@ -192,11 +197,6 @@ class BaseZwaveJSFlow(ConfigEntryBaseFlow, ABC):
self.start_task: asyncio.Task | None = None
self.version_info: VersionInfo | None = None
- @property
- @abstractmethod
- def flow_manager(self) -> FlowManager[ConfigFlowContext, ConfigFlowResult]:
- """Return the flow manager of the flow."""
-
async def async_step_install_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -356,11 +356,6 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN):
self.use_addon = False
self._usb_discovery = False
- @property
- def flow_manager(self) -> ConfigEntriesFlowManager:
- """Return the correct flow manager."""
- return self.hass.config_entries.flow
-
@staticmethod
@callback
def async_get_options_flow(
@@ -434,17 +429,20 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
dev_path = discovery_info.device
self.usb_path = dev_path
- self._title = usb.human_readable_device_name(
- dev_path,
- serial_number,
- manufacturer,
- description,
- vid,
- pid,
- )
- self.context["title_placeholders"] = {
- CONF_NAME: self._title.split(" - ")[0].strip()
- }
+ if manufacturer == "Nabu Casa" and description == "ZWA-2 - Nabu Casa ZWA-2":
+ title = "Home Assistant Connect ZWA-2"
+ else:
+ human_name = usb.human_readable_device_name(
+ dev_path,
+ serial_number,
+ manufacturer,
+ description,
+ vid,
+ pid,
+ )
+ title = human_name.split(" - ")[0].strip()
+ self.context["title_placeholders"] = {CONF_NAME: title}
+ self._title = title
return await self.async_step_usb_confirm()
async def async_step_usb_confirm(
@@ -648,7 +646,12 @@ class ZWaveJSConfigFlow(BaseZwaveJSFlow, ConfigFlow, domain=DOMAIN):
}
if not self._usb_discovery:
- ports = await async_get_usb_ports(self.hass)
+ try:
+ ports = await async_get_usb_ports(self.hass)
+ except OSError as err:
+ _LOGGER.error("Failed to get USB ports: %s", err)
+ return self.async_abort(reason="usb_ports_failed")
+
schema = {
vol.Required(CONF_USB_PATH, default=usb_path): vol.In(ports),
**schema,
@@ -729,11 +732,10 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
super().__init__()
self.original_addon_config: dict[str, Any] | None = None
self.revert_reason: str | None = None
-
- @property
- def flow_manager(self) -> OptionsFlowManager:
- """Return the correct flow manager."""
- return self.hass.config_entries.options
+ self.backup_task: asyncio.Task | None = None
+ self.restore_backup_task: asyncio.Task | None = None
+ self.backup_data: bytes | None = None
+ self.backup_filepath: str | None = None
@callback
def _async_update_entry(self, data: dict[str, Any]) -> None:
@@ -742,6 +744,18 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
async def async_step_init(
self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm if we are migrating adapters or just re-configuring."""
+ return self.async_show_menu(
+ step_id="init",
+ menu_options=[
+ OPTIONS_INTENT_RECONFIGURE,
+ OPTIONS_INTENT_MIGRATE,
+ ],
+ )
+
+ async def async_step_intent_reconfigure(
+ self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Manage the options."""
if is_hassio(self.hass):
@@ -749,6 +763,91 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
return await self.async_step_manual()
+ async def async_step_intent_migrate(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Confirm the user wants to reset their current controller."""
+ if not self.config_entry.data.get(CONF_USE_ADDON):
+ return self.async_abort(reason="addon_required")
+
+ if user_input is not None:
+ return await self.async_step_backup_nvm()
+
+ return self.async_show_form(step_id="intent_migrate")
+
+ async def async_step_backup_nvm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Backup the current network."""
+ if self.backup_task is None:
+ self.backup_task = self.hass.async_create_task(self._async_backup_network())
+
+ if not self.backup_task.done():
+ return self.async_show_progress(
+ step_id="backup_nvm",
+ progress_action="backup_nvm",
+ progress_task=self.backup_task,
+ )
+
+ try:
+ await self.backup_task
+ except AbortFlow as err:
+ _LOGGER.error(err)
+ return self.async_show_progress_done(next_step_id="backup_failed")
+ finally:
+ self.backup_task = None
+
+ return self.async_show_progress_done(next_step_id="instruct_unplug")
+
+ async def async_step_restore_nvm(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Restore the backup."""
+ if self.restore_backup_task is None:
+ self.restore_backup_task = self.hass.async_create_task(
+ self._async_restore_network_backup()
+ )
+
+ if not self.restore_backup_task.done():
+ return self.async_show_progress(
+ step_id="restore_nvm",
+ progress_action="restore_nvm",
+ progress_task=self.restore_backup_task,
+ )
+
+ try:
+ await self.restore_backup_task
+ except AbortFlow as err:
+ _LOGGER.error(err)
+ return self.async_show_progress_done(next_step_id="restore_failed")
+ finally:
+ self.restore_backup_task = None
+
+ return self.async_show_progress_done(next_step_id="migration_done")
+
+ async def async_step_instruct_unplug(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Reset the current controller, and instruct the user to unplug it."""
+
+ if user_input is not None:
+ # Now that the old controller is gone, we can scan for serial ports again
+ return await self.async_step_choose_serial_port()
+
+ # reset the old controller
+ try:
+ await self._get_driver().async_hard_reset()
+ except FailedCommand as err:
+ _LOGGER.error("Failed to reset controller: %s", err)
+ return self.async_abort(reason="reset_failed")
+
+ return self.async_show_form(
+ step_id="instruct_unplug",
+ description_placeholders={
+ "file_path": str(self.backup_filepath),
+ },
+ )
+
async def async_step_manual(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -803,7 +902,21 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
{CONF_USE_ADDON: self.config_entry.data.get(CONF_USE_ADDON, True)}
),
)
+
if not user_input[CONF_USE_ADDON]:
+ if self.config_entry.data.get(CONF_USE_ADDON):
+ # Unload the config entry before stopping the add-on.
+ await self.hass.config_entries.async_unload(self.config_entry.entry_id)
+ addon_manager = get_addon_manager(self.hass)
+ _LOGGER.debug("Stopping Z-Wave JS add-on")
+ try:
+ await addon_manager.async_stop_addon()
+ except AddonError as err:
+ _LOGGER.error(err)
+ self.hass.config_entries.async_schedule_reload(
+ self.config_entry.entry_id
+ )
+ raise AbortFlow("addon_stop_failed") from err
return await self.async_step_manual()
addon_info = await self._async_get_addon_info()
@@ -856,12 +969,9 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
if addon_info.state == AddonState.RUNNING and not self.restart_addon:
return await self.async_step_finish_addon_setup()
- if (
- self.config_entry.data.get(CONF_USE_ADDON)
- and self.config_entry.state == ConfigEntryState.LOADED
- ):
+ if self.config_entry.data.get(CONF_USE_ADDON):
# Disconnect integration before restarting add-on.
- await disconnect_client(self.hass, self.config_entry)
+ await self.hass.config_entries.async_unload(self.config_entry.entry_id)
return await self.async_step_start_addon()
@@ -887,7 +997,11 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
log_level = addon_config.get(CONF_ADDON_LOG_LEVEL, "info")
emulate_hardware = addon_config.get(CONF_ADDON_EMULATE_HARDWARE, False)
- ports = await async_get_usb_ports(self.hass)
+ try:
+ ports = await async_get_usb_ports(self.hass)
+ except OSError as err:
+ _LOGGER.error("Failed to get USB ports: %s", err)
+ return self.async_abort(reason="usb_ports_failed")
data_schema = vol.Schema(
{
@@ -917,12 +1031,64 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
return self.async_show_form(step_id="configure_addon", data_schema=data_schema)
+ async def async_step_choose_serial_port(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Choose a serial port."""
+ if user_input is not None:
+ addon_info = await self._async_get_addon_info()
+ addon_config = addon_info.options
+ self.usb_path = user_input[CONF_USB_PATH]
+ new_addon_config = {
+ **addon_config,
+ CONF_ADDON_DEVICE: self.usb_path,
+ }
+ if addon_info.state == AddonState.RUNNING:
+ self.restart_addon = True
+ # Copy the add-on config to keep the objects separate.
+ self.original_addon_config = dict(addon_config)
+ await self._async_set_addon_config(new_addon_config)
+ return await self.async_step_start_addon()
+
+ try:
+ ports = await async_get_usb_ports(self.hass)
+ except OSError as err:
+ _LOGGER.error("Failed to get USB ports: %s", err)
+ return self.async_abort(reason="usb_ports_failed")
+
+ data_schema = vol.Schema(
+ {
+ vol.Required(CONF_USB_PATH): vol.In(ports),
+ }
+ )
+ return self.async_show_form(
+ step_id="choose_serial_port", data_schema=data_schema
+ )
+
async def async_step_start_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Add-on start failed."""
return await self.async_revert_addon_config(reason="addon_start_failed")
+ async def async_step_backup_failed(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Backup failed."""
+ return self.async_abort(reason="backup_failed")
+
+ async def async_step_restore_failed(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Restore failed."""
+ return self.async_abort(reason="restore_failed")
+
+ async def async_step_migration_done(
+ self, user_input: dict[str, Any] | None = None
+ ) -> ConfigFlowResult:
+ """Migration done."""
+ return self.async_create_entry(title=TITLE, data={})
+
async def async_step_finish_addon_setup(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -949,12 +1115,16 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
except CannotConnect:
return await self.async_revert_addon_config(reason="cannot_connect")
- if self.config_entry.unique_id != str(self.version_info.home_id):
+ if self.backup_data is None and self.config_entry.unique_id != str(
+ self.version_info.home_id
+ ):
return await self.async_revert_addon_config(reason="different_device")
self._async_update_entry(
{
**self.config_entry.data,
+ # this will only be different in a migration flow
+ "unique_id": str(self.version_info.home_id),
CONF_URL: self.ws_address,
CONF_USB_PATH: self.usb_path,
CONF_S0_LEGACY_KEY: self.s0_legacy_key,
@@ -967,6 +1137,9 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
CONF_INTEGRATION_CREATED_ADDON: self.integration_created_addon,
}
)
+ if self.backup_data:
+ return await self.async_step_restore_nvm()
+
# Always reload entry since we may have disconnected the client.
self.hass.config_entries.async_schedule_reload(self.config_entry.entry_id)
return self.async_create_entry(title=TITLE, data={})
@@ -996,6 +1169,74 @@ class OptionsFlowHandler(BaseZwaveJSFlow, OptionsFlow):
_LOGGER.debug("Reverting add-on options, reason: %s", reason)
return await self.async_step_configure_addon(addon_config_input)
+ async def _async_backup_network(self) -> None:
+ """Backup the current network."""
+
+ @callback
+ def forward_progress(event: dict) -> None:
+ """Forward progress events to frontend."""
+ self.async_update_progress(event["bytesRead"] / event["total"])
+
+ controller = self._get_driver().controller
+ unsub = controller.on("nvm backup progress", forward_progress)
+ try:
+ self.backup_data = await controller.async_backup_nvm_raw()
+ except FailedCommand as err:
+ raise AbortFlow(f"Failed to backup network: {err}") from err
+ finally:
+ unsub()
+
+ # save the backup to a file just in case
+ self.backup_filepath = self.hass.config.path(
+ f"zwavejs_nvm_backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.bin"
+ )
+ try:
+ await self.hass.async_add_executor_job(
+ Path(self.backup_filepath).write_bytes,
+ self.backup_data,
+ )
+ except OSError as err:
+ raise AbortFlow(f"Failed to save backup file: {err}") from err
+
+ async def _async_restore_network_backup(self) -> None:
+ """Restore the backup."""
+ assert self.backup_data is not None
+
+ # Reload the config entry to reconnect the client after the addon restart
+ await self.hass.config_entries.async_reload(self.config_entry.entry_id)
+
+ @callback
+ def forward_progress(event: dict) -> None:
+ """Forward progress events to frontend."""
+ if event["event"] == "nvm convert progress":
+ # assume convert is 50% of the total progress
+ self.async_update_progress(event["bytesRead"] / event["total"] * 0.5)
+ elif event["event"] == "nvm restore progress":
+ # assume restore is the rest of the progress
+ self.async_update_progress(
+ event["bytesWritten"] / event["total"] * 0.5 + 0.5
+ )
+
+ controller = self._get_driver().controller
+ unsubs = [
+ controller.on("nvm convert progress", forward_progress),
+ controller.on("nvm restore progress", forward_progress),
+ ]
+ try:
+ await controller.async_restore_nvm(self.backup_data)
+ except FailedCommand as err:
+ raise AbortFlow(f"Failed to restore network: {err}") from err
+ finally:
+ for unsub in unsubs:
+ unsub()
+
+ def _get_driver(self) -> Driver:
+ if self.config_entry.state != ConfigEntryState.LOADED:
+ raise AbortFlow("Configuration entry is not loaded")
+ client: Client = self.config_entry.runtime_data[DATA_CLIENT]
+ assert client.driver is not None
+ return client.driver
+
class CannotConnect(HomeAssistantError):
"""Indicate connection error."""
diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py
index 218c5cc82fe..dc44f46a3ce 100644
--- a/homeassistant/components/zwave_js/cover.py
+++ b/homeassistant/components/zwave_js/cover.py
@@ -37,7 +37,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
COVER_POSITION_PROPERTY_KEYS,
@@ -55,7 +55,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave Cover from Config Entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/event.py b/homeassistant/components/zwave_js/event.py
index 8dae66c26ac..66959aa9b75 100644
--- a/homeassistant/components/zwave_js/event.py
+++ b/homeassistant/components/zwave_js/event.py
@@ -10,7 +10,7 @@ from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_VALUE, DATA_CLIENT, DOMAIN
from .discovery import ZwaveDiscoveryInfo
@@ -22,7 +22,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave Event entity from Config Entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py
index d83132e4b95..ae36e0afb42 100644
--- a/homeassistant/components/zwave_js/fan.py
+++ b/homeassistant/components/zwave_js/fan.py
@@ -24,7 +24,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
@@ -46,7 +46,7 @@ ATTR_FAN_STATE = "fan_state"
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave Fan from Config Entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py
index 904a26acc78..ded87b590a4 100644
--- a/homeassistant/components/zwave_js/helpers.py
+++ b/homeassistant/components/zwave_js/helpers.py
@@ -15,7 +15,7 @@ from zwave_js_server.const import (
ConfigurationValueType,
LogLevel,
)
-from zwave_js_server.model.controller import Controller
+from zwave_js_server.model.controller import Controller, ProvisioningEntry
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.log_config import LogConfig
from zwave_js_server.model.node import Node as ZwaveNode
@@ -187,7 +187,7 @@ async def async_disable_server_logging_if_needed(
old_server_log_level,
)
await driver.async_update_log_config(LogConfig(level=old_server_log_level))
- await driver.client.disable_server_logging()
+ driver.client.disable_server_logging()
LOGGER.info("Zwave-js-server logging is enabled")
@@ -233,7 +233,7 @@ def get_home_and_node_id_from_device_entry(
),
None,
)
- if device_id is None:
+ if device_id is None or device_id.startswith("provision_"):
return None
id_ = device_id.split("-")
return (id_[0], int(id_[1]))
@@ -264,12 +264,12 @@ def async_get_node_from_device_id(
),
None,
)
- if entry and entry.state != ConfigEntryState.LOADED:
- raise ValueError(f"Device {device_id} config entry is not loaded")
if entry is None:
raise ValueError(
f"Device {device_id} is not from an existing zwave_js config entry"
)
+ if entry.state != ConfigEntryState.LOADED:
+ raise ValueError(f"Device {device_id} config entry is not loaded")
client: ZwaveClient = entry.runtime_data[DATA_CLIENT]
driver = client.driver
@@ -289,6 +289,53 @@ def async_get_node_from_device_id(
return driver.controller.nodes[node_id]
+async def async_get_provisioning_entry_from_device_id(
+ hass: HomeAssistant, device_id: str
+) -> ProvisioningEntry | None:
+ """Get provisioning entry from a device ID.
+
+ Raises ValueError if device is invalid
+ """
+ dev_reg = dr.async_get(hass)
+
+ if not (device_entry := dev_reg.async_get(device_id)):
+ raise ValueError(f"Device ID {device_id} is not valid")
+
+ # Use device config entry ID's to validate that this is a valid zwave_js device
+ # and to get the client
+ config_entry_ids = device_entry.config_entries
+ entry = next(
+ (
+ entry
+ for entry in hass.config_entries.async_entries(DOMAIN)
+ if entry.entry_id in config_entry_ids
+ ),
+ None,
+ )
+ if entry is None:
+ raise ValueError(
+ f"Device {device_id} is not from an existing zwave_js config entry"
+ )
+ if entry.state != ConfigEntryState.LOADED:
+ raise ValueError(f"Device {device_id} config entry is not loaded")
+
+ client: ZwaveClient = entry.runtime_data[DATA_CLIENT]
+ driver = client.driver
+
+ if driver is None:
+ raise ValueError("Driver is not ready.")
+
+ provisioning_entries = await driver.controller.async_get_provisioning_entries()
+ for provisioning_entry in provisioning_entries:
+ if (
+ provisioning_entry.additional_properties
+ and provisioning_entry.additional_properties.get("device_id") == device_id
+ ):
+ return provisioning_entry
+
+ return None
+
+
@callback
def async_get_node_from_entity_id(
hass: HomeAssistant,
diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py
index e883858036b..2b85bd4449f 100644
--- a/homeassistant/components/zwave_js/humidifier.py
+++ b/homeassistant/components/zwave_js/humidifier.py
@@ -26,7 +26,7 @@ from homeassistant.components.humidifier import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_CLIENT, DOMAIN
from .discovery import ZwaveDiscoveryInfo
@@ -70,7 +70,7 @@ DEHUMIDIFIER_ENTITY_DESCRIPTION = ZwaveHumidifierEntityDescription(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave humidifier from config entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py
index 0a2ca95a2b0..f60e129cc77 100644
--- a/homeassistant/components/zwave_js/light.py
+++ b/homeassistant/components/zwave_js/light.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import Any, cast
+from typing import TYPE_CHECKING, Any, cast
from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import (
@@ -41,7 +41,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .const import DATA_CLIENT, DOMAIN
@@ -67,7 +67,7 @@ MAX_MIREDS = 370 # 2700K as a safe default
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave Light from Config Entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
@@ -483,7 +483,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
red = multi_color.get(COLOR_SWITCH_COMBINED_RED, red_val.value)
green = multi_color.get(COLOR_SWITCH_COMBINED_GREEN, green_val.value)
blue = multi_color.get(COLOR_SWITCH_COMBINED_BLUE, blue_val.value)
- if None not in (red, green, blue):
+ if red is not None and green is not None and blue is not None:
# convert to HS
self._hs_color = color_util.color_RGB_to_hs(red, green, blue)
# Light supports color, set color mode to hs
@@ -496,7 +496,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
# Calculate color temps based on whites
if cold_white or warm_white:
self._color_temp = color_util.color_temperature_mired_to_kelvin(
- MAX_MIREDS - ((cold_white / 255) * (MAX_MIREDS - MIN_MIREDS))
+ MAX_MIREDS
+ - ((cast(int, cold_white) / 255) * (MAX_MIREDS - MIN_MIREDS))
)
# White channels turned on, set color mode to color_temp
self._color_mode = ColorMode.COLOR_TEMP
@@ -505,6 +506,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
# only one white channel (warm white) = rgbw support
elif red_val and green_val and blue_val and ww_val:
white = multi_color.get(COLOR_SWITCH_COMBINED_WARM_WHITE, ww_val.value)
+ if TYPE_CHECKING:
+ assert (
+ red is not None
+ and green is not None
+ and blue is not None
+ and white is not None
+ )
self._rgbw_color = (red, green, blue, white)
# Light supports rgbw, set color mode to rgbw
self._color_mode = ColorMode.RGBW
@@ -512,6 +520,13 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
elif cw_val:
self._supports_rgbw = True
white = multi_color.get(COLOR_SWITCH_COMBINED_COLD_WHITE, cw_val.value)
+ if TYPE_CHECKING:
+ assert (
+ red is not None
+ and green is not None
+ and blue is not None
+ and white is not None
+ )
self._rgbw_color = (red, green, blue, white)
# Light supports rgbw, set color mode to rgbw
self._color_mode = ColorMode.RGBW
diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py
index c14517f4b03..f609084955c 100644
--- a/homeassistant/components/zwave_js/lock.py
+++ b/homeassistant/components/zwave_js/lock.py
@@ -25,7 +25,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
ATTR_AUTO_RELOCK_TIME,
@@ -62,7 +62,7 @@ UNIT16_SCHEMA = vol.All(vol.Coerce(int), vol.Range(min=0, max=65535))
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave lock from config entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json
index 011776f4556..6f415ce257d 100644
--- a/homeassistant/components/zwave_js/manifest.json
+++ b/homeassistant/components/zwave_js/manifest.json
@@ -9,7 +9,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["zwave_js_server"],
- "requirements": ["pyserial==3.5", "zwave-js-server-python==0.60.0"],
+ "requirements": ["pyserial==3.5", "zwave-js-server-python==0.62.0"],
"usb": [
{
"vid": "0658",
@@ -21,6 +21,13 @@
"pid": "8A2A",
"description": "*z-wave*",
"known_devices": ["Nortek HUSBZB-1"]
+ },
+ {
+ "vid": "303A",
+ "pid": "4001",
+ "description": "*nabu casa zwa-2*",
+ "manufacturer": "nabu casa",
+ "known_devices": ["Nabu Casa Connect ZWA-2"]
}
],
"zeroconf": ["_zwave-js-server._tcp.local."]
diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py
index 54162488d89..2e2d93bbdbe 100644
--- a/homeassistant/components/zwave_js/number.py
+++ b/homeassistant/components/zwave_js/number.py
@@ -16,7 +16,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_RESERVED_VALUES, DATA_CLIENT, DOMAIN
from .discovery import ZwaveDiscoveryInfo
@@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave Number entity from Config Entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py
index 49ad1868005..8a6ccc57c17 100644
--- a/homeassistant/components/zwave_js/select.py
+++ b/homeassistant/components/zwave_js/select.py
@@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_CLIENT, DOMAIN
from .discovery import ZwaveDiscoveryInfo
@@ -27,7 +27,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave Select entity from Config Entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py
index b259711d21b..4db14d003b1 100644
--- a/homeassistant/components/zwave_js/sensor.py
+++ b/homeassistant/components/zwave_js/sensor.py
@@ -48,7 +48,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_platform
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import UNDEFINED, StateType
from .binary_sensor import is_valid_notification_binary_sensor
@@ -552,7 +552,7 @@ def get_entity_description(
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave sensor from config entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py
index 3a09049def3..f0526171a70 100644
--- a/homeassistant/components/zwave_js/siren.py
+++ b/homeassistant/components/zwave_js/siren.py
@@ -18,7 +18,7 @@ from homeassistant.components.siren import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_CLIENT, DOMAIN
from .discovery import ZwaveDiscoveryInfo
@@ -30,7 +30,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave Siren entity from Config Entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json
index e2d7720189d..8f445beaf23 100644
--- a/homeassistant/components/zwave_js/strings.json
+++ b/homeassistant/components/zwave_js/strings.json
@@ -11,7 +11,11 @@
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"discovery_requires_supervisor": "Discovery requires the supervisor.",
"not_zwave_device": "Discovered device is not a Z-Wave device.",
- "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on."
+ "not_zwave_js_addon": "Discovered add-on is not the official Z-Wave add-on.",
+ "backup_failed": "Failed to backup network.",
+ "restore_failed": "Failed to restore network.",
+ "reset_failed": "Failed to reset controller.",
+ "usb_ports_failed": "Failed to get USB devices."
},
"error": {
"addon_start_failed": "Failed to start the Z-Wave add-on. Check the configuration.",
@@ -22,7 +26,9 @@
"flow_title": "{name}",
"progress": {
"install_addon": "Please wait while the Z-Wave add-on installation finishes. This can take several minutes.",
- "start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds."
+ "start_addon": "Please wait while the Z-Wave add-on start completes. This may take some seconds.",
+ "backup_nvm": "Please wait while the network backup completes.",
+ "restore_nvm": "Please wait while the network restore completes."
},
"step": {
"configure_addon": {
@@ -214,9 +220,15 @@
"addon_install_failed": "[%key:component::zwave_js::config::abort::addon_install_failed%]",
"addon_set_config_failed": "[%key:component::zwave_js::config::abort::addon_set_config_failed%]",
"addon_start_failed": "[%key:component::zwave_js::config::abort::addon_start_failed%]",
+ "addon_stop_failed": "Failed to stop the Z-Wave add-on.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
- "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device."
+ "different_device": "The connected USB device is not the same as previously configured for this config entry. Please instead create a new config entry for the new device.",
+ "addon_required": "The Z-Wave migration flow requires the integration to be configured using the Z-Wave Supervisor add-on. You can still use the Backup and Restore buttons to migrate your network manually.",
+ "backup_failed": "[%key:component::zwave_js::config::abort::backup_failed%]",
+ "restore_failed": "[%key:component::zwave_js::config::abort::restore_failed%]",
+ "reset_failed": "[%key:component::zwave_js::config::abort::reset_failed%]",
+ "usb_ports_failed": "[%key:component::zwave_js::config::abort::usb_ports_failed%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -225,9 +237,27 @@
},
"progress": {
"install_addon": "[%key:component::zwave_js::config::progress::install_addon%]",
- "start_addon": "[%key:component::zwave_js::config::progress::start_addon%]"
+ "start_addon": "[%key:component::zwave_js::config::progress::start_addon%]",
+ "backup_nvm": "[%key:component::zwave_js::config::progress::backup_nvm%]",
+ "restore_nvm": "[%key:component::zwave_js::config::progress::restore_nvm%]"
},
"step": {
+ "init": {
+ "title": "Migrate or re-configure",
+ "description": "Are you migrating to a new controller or re-configuring the current controller?",
+ "menu_options": {
+ "intent_migrate": "Migrate to a new controller",
+ "intent_reconfigure": "Re-configure the current controller"
+ }
+ },
+ "intent_migrate": {
+ "title": "[%key:component::zwave_js::options::step::init::menu_options::intent_migrate%]",
+ "description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?"
+ },
+ "instruct_unplug": {
+ "title": "Unplug your old controller",
+ "description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing."
+ },
"configure_addon": {
"data": {
"emulate_hardware": "Emulate Hardware",
@@ -241,6 +271,12 @@
"description": "[%key:component::zwave_js::config::step::configure_addon::description%]",
"title": "[%key:component::zwave_js::config::step::configure_addon::title%]"
},
+ "choose_serial_port": {
+ "data": {
+ "usb_path": "[%key:common::config_flow::data::usb_path%]"
+ },
+ "title": "Select your Z-Wave device"
+ },
"install_addon": {
"title": "[%key:component::zwave_js::config::step::install_addon::title%]"
},
@@ -344,8 +380,8 @@
"name": "[%key:component::zwave_js::services::set_value::fields::area_id::name%]"
},
"broadcast": {
- "description": "Whether command should be broadcast to all devices on the network.",
- "name": "Broadcast?"
+ "description": "Whether the command should be broadcast to all devices on the network.",
+ "name": "Broadcast"
},
"command_class": {
"description": "[%key:component::zwave_js::services::set_value::fields::command_class::description%]",
@@ -434,8 +470,8 @@
"name": "Entities"
},
"refresh_all_values": {
- "description": "Whether to refresh all values (true) or just the primary value (false).",
- "name": "Refresh all values?"
+ "description": "Whether to refresh all values or just the primary value.",
+ "name": "Refresh all values"
}
},
"name": "Refresh values"
@@ -516,8 +552,8 @@
"name": "Auto relock time"
},
"block_to_block": {
- "description": "Enable block-to-block functionality.",
- "name": "Block to block"
+ "description": "Whether the lock should run the motor until it hits resistance.",
+ "name": "Block to Block"
},
"hold_and_release_time": {
"description": "Duration in seconds the latch stays retracted.",
@@ -529,11 +565,11 @@
},
"operation_type": {
"description": "The operation type of the lock.",
- "name": "Operation Type"
+ "name": "Operation type"
},
"twist_assist": {
- "description": "Enable Twist Assist.",
- "name": "Twist assist"
+ "description": "Whether the motor should help in locking and unlocking.",
+ "name": "Twist Assist"
}
},
"name": "Set lock configuration"
@@ -592,8 +628,8 @@
"name": "[%key:component::zwave_js::services::set_config_parameter::fields::value::name%]"
},
"wait_for_result": {
- "description": "Whether or not to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If set to `true`, note that the action can take a while if setting a value on an asleep battery device.",
- "name": "Wait for result?"
+ "description": "Whether to wait for a response from the node. If not included in the payload, the integration will decide whether to wait or not. If enabled, the action can take a while if setting a value on an asleep battery device.",
+ "name": "Wait for result"
}
},
"name": "Set a value (advanced)"
diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py
index ef769209b31..2ff80d8505e 100644
--- a/homeassistant/components/zwave_js/switch.py
+++ b/homeassistant/components/zwave_js/switch.py
@@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DATA_CLIENT, DOMAIN
from .discovery import ZwaveDiscoveryInfo
@@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave sensor from config entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py
index d060abe007d..985c4a86813 100644
--- a/homeassistant/components/zwave_js/update.py
+++ b/homeassistant/components/zwave_js/update.py
@@ -32,7 +32,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import CoreState, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.restore_state import ExtraStoredData
@@ -77,7 +77,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData):
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Z-Wave update entity from config entry."""
client: ZwaveClient = config_entry.runtime_data[DATA_CLIENT]
diff --git a/homeassistant/components/zwave_me/binary_sensor.py b/homeassistant/components/zwave_me/binary_sensor.py
index d121c17770b..8563ef76ce1 100644
--- a/homeassistant/components/zwave_me/binary_sensor.py
+++ b/homeassistant/components/zwave_me/binary_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.binary_sensor import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ZWaveMeController
from .const import DOMAIN, ZWaveMePlatform
@@ -33,7 +33,7 @@ DEVICE_NAME = ZWaveMePlatform.BINARY_SENSOR
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the binary sensor platform."""
diff --git a/homeassistant/components/zwave_me/button.py b/homeassistant/components/zwave_me/button.py
index 50ddf01aeab..27d95a14199 100644
--- a/homeassistant/components/zwave_me/button.py
+++ b/homeassistant/components/zwave_me/button.py
@@ -4,7 +4,7 @@ from homeassistant.components.button import ButtonEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, ZWaveMePlatform
from .entity import ZWaveMeEntity
@@ -15,7 +15,7 @@ DEVICE_NAME = ZWaveMePlatform.BUTTON
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the number platform."""
diff --git a/homeassistant/components/zwave_me/climate.py b/homeassistant/components/zwave_me/climate.py
index b8eed88b505..d54cc6a9310 100644
--- a/homeassistant/components/zwave_me/climate.py
+++ b/homeassistant/components/zwave_me/climate.py
@@ -15,7 +15,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, ZWaveMePlatform
from .entity import ZWaveMeEntity
@@ -28,7 +28,7 @@ DEVICE_NAME = ZWaveMePlatform.CLIMATE
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the climate platform."""
diff --git a/homeassistant/components/zwave_me/cover.py b/homeassistant/components/zwave_me/cover.py
index c9359402c01..3ae8ec894e1 100644
--- a/homeassistant/components/zwave_me/cover.py
+++ b/homeassistant/components/zwave_me/cover.py
@@ -12,7 +12,7 @@ from homeassistant.components.cover import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, ZWaveMePlatform
from .entity import ZWaveMeEntity
@@ -23,7 +23,7 @@ DEVICE_NAME = ZWaveMePlatform.COVER
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the cover platform."""
diff --git a/homeassistant/components/zwave_me/fan.py b/homeassistant/components/zwave_me/fan.py
index bd0feba0dfb..6ab1df618cb 100644
--- a/homeassistant/components/zwave_me/fan.py
+++ b/homeassistant/components/zwave_me/fan.py
@@ -8,7 +8,7 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, ZWaveMePlatform
from .entity import ZWaveMeEntity
@@ -19,7 +19,7 @@ DEVICE_NAME = ZWaveMePlatform.FAN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the fan platform."""
diff --git a/homeassistant/components/zwave_me/light.py b/homeassistant/components/zwave_me/light.py
index ef3eca5d389..f8ed397ea25 100644
--- a/homeassistant/components/zwave_me/light.py
+++ b/homeassistant/components/zwave_me/light.py
@@ -15,7 +15,7 @@ from homeassistant.components.light import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ZWaveMeController
from .const import DOMAIN, ZWaveMePlatform
@@ -25,7 +25,7 @@ from .entity import ZWaveMeEntity
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the rgb platform."""
diff --git a/homeassistant/components/zwave_me/lock.py b/homeassistant/components/zwave_me/lock.py
index 0bcc8f092ae..cdc8b6471c1 100644
--- a/homeassistant/components/zwave_me/lock.py
+++ b/homeassistant/components/zwave_me/lock.py
@@ -10,7 +10,7 @@ from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, ZWaveMePlatform
from .entity import ZWaveMeEntity
@@ -21,7 +21,7 @@ DEVICE_NAME = ZWaveMePlatform.LOCK
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the lock platform."""
diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json
index d5c5a69cb96..43a39de29c5 100644
--- a/homeassistant/components/zwave_me/manifest.json
+++ b/homeassistant/components/zwave_me/manifest.json
@@ -6,7 +6,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zwave_me",
"iot_class": "local_push",
- "requirements": ["zwave-me-ws==0.4.3", "url-normalize==1.4.3"],
+ "requirements": ["zwave-me-ws==0.4.3", "url-normalize==2.2.0"],
"zeroconf": [
{
"type": "_hap._tcp.local.",
diff --git a/homeassistant/components/zwave_me/number.py b/homeassistant/components/zwave_me/number.py
index 9a98a4f8d00..2d6b88840f4 100644
--- a/homeassistant/components/zwave_me/number.py
+++ b/homeassistant/components/zwave_me/number.py
@@ -4,7 +4,7 @@ from homeassistant.components.number import NumberEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, ZWaveMePlatform
from .entity import ZWaveMeEntity
@@ -15,7 +15,7 @@ DEVICE_NAME = ZWaveMePlatform.NUMBER
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the number platform."""
diff --git a/homeassistant/components/zwave_me/sensor.py b/homeassistant/components/zwave_me/sensor.py
index be0b0bae284..fa9ccdfee99 100644
--- a/homeassistant/components/zwave_me/sensor.py
+++ b/homeassistant/components/zwave_me/sensor.py
@@ -26,7 +26,7 @@ from homeassistant.const import (
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ZWaveMeController
from .const import DOMAIN, ZWaveMePlatform
@@ -118,7 +118,7 @@ DEVICE_NAME = ZWaveMePlatform.SENSOR
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
diff --git a/homeassistant/components/zwave_me/siren.py b/homeassistant/components/zwave_me/siren.py
index 443b2cc7b37..7bfbf2b2cd4 100644
--- a/homeassistant/components/zwave_me/siren.py
+++ b/homeassistant/components/zwave_me/siren.py
@@ -6,7 +6,7 @@ from homeassistant.components.siren import SirenEntity, SirenEntityFeature
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, ZWaveMePlatform
from .entity import ZWaveMeEntity
@@ -17,7 +17,7 @@ DEVICE_NAME = ZWaveMePlatform.SIREN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the siren platform."""
diff --git a/homeassistant/components/zwave_me/switch.py b/homeassistant/components/zwave_me/switch.py
index 05cf06484e9..26d832ca022 100644
--- a/homeassistant/components/zwave_me/switch.py
+++ b/homeassistant/components/zwave_me/switch.py
@@ -11,7 +11,7 @@ from homeassistant.components.switch import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, ZWaveMePlatform
from .entity import ZWaveMeEntity
@@ -30,7 +30,7 @@ SWITCH_MAP: dict[str, SwitchEntityDescription] = {
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the switch platform."""
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index 620e4bc8197..c58a33ad68d 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -15,6 +15,7 @@ from collections.abc import (
)
from contextvars import ContextVar
from copy import deepcopy
+from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum, StrEnum
import functools
@@ -22,14 +23,13 @@ from functools import cache
import logging
from random import randint
from types import MappingProxyType
-from typing import TYPE_CHECKING, Any, Self, cast
+from typing import TYPE_CHECKING, Any, Self, TypedDict, cast
from async_interrupt import interrupt
from propcache.api import cached_property
import voluptuous as vol
from . import data_entry_flow, loader
-from .components import persistent_notification
from .const import (
CONF_NAME,
EVENT_HOMEASSISTANT_STARTED,
@@ -72,12 +72,12 @@ from .helpers.json import json_bytes, json_bytes_sorted, json_fragment
from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType
from .loader import async_suggest_report_issue
from .setup import (
- DATA_SETUP_DONE,
SetupPhases,
async_pause_setup,
async_process_deps_reqs,
async_setup_component,
async_start_setup,
+ async_wait_component,
)
from .util import ulid as ulid_util
from .util.async_ import create_eager_task
@@ -127,7 +127,7 @@ HANDLERS: Registry[str, type[ConfigFlow]] = Registry()
STORAGE_KEY = "core.config_entries"
STORAGE_VERSION = 1
-STORAGE_VERSION_MINOR = 4
+STORAGE_VERSION_MINOR = 5
SAVE_DELAY = 1
@@ -154,6 +154,8 @@ class ConfigEntryState(Enum):
"""An error occurred when trying to unload the entry"""
SETUP_IN_PROGRESS = "setup_in_progress", False
"""The config entry is setting up."""
+ UNLOAD_IN_PROGRESS = "unload_in_progress", False
+ """The config entry is being unloaded."""
_recoverable: bool
@@ -175,7 +177,6 @@ class ConfigEntryState(Enum):
DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id"
-DISCOVERY_NOTIFICATION_ID = "config_entry_discovery"
DISCOVERY_SOURCES = {
SOURCE_BLUETOOTH,
SOURCE_DHCP,
@@ -192,8 +193,6 @@ DISCOVERY_SOURCES = {
SOURCE_ZEROCONF,
}
-RECONFIGURE_NOTIFICATION_ID = "config_entry_reconfigure"
-
EVENT_FLOW_DISCOVERED = "config_entry_discovered"
SIGNAL_CONFIG_ENTRY_CHANGED = SignalType["ConfigEntryChange", "ConfigEntry"](
@@ -253,6 +252,10 @@ class UnknownEntry(ConfigError):
"""Unknown entry specified."""
+class UnknownSubEntry(ConfigError):
+ """Unknown subentry specified."""
+
+
class OperationNotAllowed(ConfigError):
"""Raised when a config entry operation is not allowed."""
@@ -297,6 +300,7 @@ class ConfigFlowResult(FlowResult[ConfigFlowContext, str], total=False):
minor_version: int
options: Mapping[str, Any]
+ subentries: Iterable[ConfigSubentryData]
version: int
@@ -310,6 +314,61 @@ def _validate_item(*, disabled_by: ConfigEntryDisabler | Any | None = None) -> N
)
+class ConfigSubentryData(TypedDict):
+ """Container for configuration subentry data.
+
+ Returned by integrations, a subentry_id will be assigned automatically.
+ """
+
+ data: Mapping[str, Any]
+ subentry_type: str
+ title: str
+ unique_id: str | None
+
+
+class ConfigSubentryDataWithId(ConfigSubentryData):
+ """Container for configuration subentry data.
+
+ This type is used when loading existing subentries from storage.
+ """
+
+ subentry_id: str
+
+
+class SubentryFlowContext(FlowContext, total=False):
+ """Typed context dict for subentry flow."""
+
+ entry_id: str
+ subentry_id: str
+
+
+class SubentryFlowResult(FlowResult[SubentryFlowContext, tuple[str, str]], total=False):
+ """Typed result dict for subentry flow."""
+
+ unique_id: str | None
+
+
+@dataclass(frozen=True, kw_only=True)
+class ConfigSubentry:
+ """Container for a configuration subentry."""
+
+ data: MappingProxyType[str, Any]
+ subentry_id: str = field(default_factory=ulid_util.ulid_now)
+ subentry_type: str
+ title: str
+ unique_id: str | None
+
+ def as_dict(self) -> ConfigSubentryDataWithId:
+ """Return dictionary version of this subentry."""
+ return {
+ "data": dict(self.data),
+ "subentry_id": self.subentry_id,
+ "subentry_type": self.subentry_type,
+ "title": self.title,
+ "unique_id": self.unique_id,
+ }
+
+
class ConfigEntry[_DataT = Any]:
"""Hold a configuration entry."""
@@ -319,6 +378,7 @@ class ConfigEntry[_DataT = Any]:
data: MappingProxyType[str, Any]
runtime_data: _DataT
options: MappingProxyType[str, Any]
+ subentries: MappingProxyType[str, ConfigSubentry]
unique_id: str | None
state: ConfigEntryState
reason: str | None
@@ -334,9 +394,11 @@ class ConfigEntry[_DataT = Any]:
supports_remove_device: bool | None
_supports_options: bool | None
_supports_reconfigure: bool | None
+ _supported_subentry_types: dict[str, dict[str, bool]] | None
update_listeners: list[UpdateListenerType]
_async_cancel_retry_setup: Callable[[], Any] | None
_on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None
+ _on_state_change: list[CALLBACK_TYPE] | None
setup_lock: asyncio.Lock
_reauth_lock: asyncio.Lock
_tasks: set[asyncio.Future[Any]]
@@ -363,6 +425,7 @@ class ConfigEntry[_DataT = Any]:
pref_disable_polling: bool | None = None,
source: str,
state: ConfigEntryState = ConfigEntryState.NOT_LOADED,
+ subentries_data: Iterable[ConfigSubentryData | ConfigSubentryDataWithId] | None,
title: str,
unique_id: str | None,
version: int,
@@ -388,6 +451,25 @@ class ConfigEntry[_DataT = Any]:
# Entry options
_setter(self, "options", MappingProxyType(options or {}))
+ # Subentries
+ subentries_data = subentries_data or ()
+ subentries = {}
+ for subentry_data in subentries_data:
+ subentry_kwargs = {}
+ if "subentry_id" in subentry_data:
+ # If subentry_data has key "subentry_id", we're loading from storage
+ subentry_kwargs["subentry_id"] = subentry_data["subentry_id"] # type: ignore[typeddict-item]
+ subentry = ConfigSubentry(
+ data=MappingProxyType(subentry_data["data"]),
+ subentry_type=subentry_data["subentry_type"],
+ title=subentry_data["title"],
+ unique_id=subentry_data.get("unique_id"),
+ **subentry_kwargs,
+ )
+ subentries[subentry.subentry_id] = subentry
+
+ _setter(self, "subentries", MappingProxyType(subentries))
+
# Entry system options
if pref_disable_new_entities is None:
pref_disable_new_entities = False
@@ -424,6 +506,9 @@ class ConfigEntry[_DataT = Any]:
# Supports reconfigure
_setter(self, "_supports_reconfigure", None)
+ # Supports subentries
+ _setter(self, "_supported_subentry_types", None)
+
# Listeners to call on update
_setter(self, "update_listeners", [])
@@ -438,6 +523,9 @@ class ConfigEntry[_DataT = Any]:
# Hold list for actions to call on unload.
_setter(self, "_on_unload", None)
+ # Hold list for actions to call on state change.
+ _setter(self, "_on_state_change", None)
+
# Reload lock to prevent conflicting reloads
_setter(self, "setup_lock", asyncio.Lock())
# Reauth lock to prevent concurrent reauth flows
@@ -496,6 +584,28 @@ class ConfigEntry[_DataT = Any]:
)
return self._supports_reconfigure or False
+ @property
+ def supported_subentry_types(self) -> dict[str, dict[str, bool]]:
+ """Return supported subentry types."""
+ if self._supported_subentry_types is None and (
+ handler := HANDLERS.get(self.domain)
+ ):
+ # work out sub entries supported by the handler
+ supported_flows = handler.async_get_supported_subentry_types(self)
+ object.__setattr__(
+ self,
+ "_supported_subentry_types",
+ {
+ subentry_flow_type: {
+ "supports_reconfigure": hasattr(
+ subentry_flow_handler, "async_step_reconfigure"
+ )
+ }
+ for subentry_flow_type, subentry_flow_handler in supported_flows.items()
+ },
+ )
+ return self._supported_subentry_types or {}
+
def clear_state_cache(self) -> None:
"""Clear cached properties that are included in as_json_fragment."""
self.__dict__.pop("as_json_fragment", None)
@@ -515,12 +625,14 @@ class ConfigEntry[_DataT = Any]:
"supports_remove_device": self.supports_remove_device or False,
"supports_unload": self.supports_unload or False,
"supports_reconfigure": self.supports_reconfigure,
+ "supported_subentry_types": self.supported_subentry_types,
"pref_disable_new_entities": self.pref_disable_new_entities,
"pref_disable_polling": self.pref_disable_polling,
"disabled_by": self.disabled_by,
"reason": self.reason,
"error_reason_translation_key": self.error_reason_translation_key,
"error_reason_translation_placeholders": self.error_reason_translation_placeholders,
+ "num_subentries": len(self.subentries),
}
return json_fragment(json_bytes(json_repr))
@@ -845,18 +957,25 @@ class ConfigEntry[_DataT = Any]:
)
return False
+ if domain_is_integration:
+ self._async_set_state(hass, ConfigEntryState.UNLOAD_IN_PROGRESS, None)
try:
result = await component.async_unload_entry(hass, self)
assert isinstance(result, bool)
- # Only adjust state if we unloaded the component
- if domain_is_integration and result:
- await self._async_process_on_unload(hass)
- if hasattr(self, "runtime_data"):
- object.__delattr__(self, "runtime_data")
+ # Only do side effects if we unloaded the integration
+ if domain_is_integration:
+ if result:
+ await self._async_process_on_unload(hass)
+ if hasattr(self, "runtime_data"):
+ object.__delattr__(self, "runtime_data")
- self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None)
+ self._async_set_state(hass, ConfigEntryState.NOT_LOADED, None)
+ else:
+ self._async_set_state(
+ hass, ConfigEntryState.FAILED_UNLOAD, "Unload failed"
+ )
except Exception as exc:
_LOGGER.exception(
@@ -939,6 +1058,8 @@ class ConfigEntry[_DataT = Any]:
hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self
)
+ self._async_process_on_state_change()
+
async def async_migrate(self, hass: HomeAssistant) -> bool:
"""Migrate an entry.
@@ -1012,6 +1133,7 @@ class ConfigEntry[_DataT = Any]:
"pref_disable_new_entities": self.pref_disable_new_entities,
"pref_disable_polling": self.pref_disable_polling,
"source": self.source,
+ "subentries": [subentry.as_dict() for subentry in self.subentries.values()],
"title": self.title,
"unique_id": self.unique_id,
"version": self.version,
@@ -1052,6 +1174,28 @@ class ConfigEntry[_DataT = Any]:
task,
)
+ @callback
+ def async_on_state_change(self, func: CALLBACK_TYPE) -> CALLBACK_TYPE:
+ """Add a function to call when a config entry changes its state."""
+ if self._on_state_change is None:
+ self._on_state_change = []
+ self._on_state_change.append(func)
+ return lambda: cast(list, self._on_state_change).remove(func)
+
+ def _async_process_on_state_change(self) -> None:
+ """Process the on_state_change callbacks and wait for pending tasks."""
+ if self._on_state_change is None:
+ return
+ for func in self._on_state_change:
+ try:
+ func()
+ except Exception:
+ _LOGGER.exception(
+ "Error calling on_state_change callback for %s (%s)",
+ self.title,
+ self.domain,
+ )
+
@callback
def async_start_reauth(
self,
@@ -1223,14 +1367,15 @@ class ConfigEntriesFlowManager(
self._initialize_futures: defaultdict[str, set[asyncio.Future[None]]] = (
defaultdict(set)
)
- self._discovery_debouncer = Debouncer[None](
+ self._discovery_event_debouncer = Debouncer[None](
hass,
_LOGGER,
cooldown=DISCOVERY_COOLDOWN,
immediate=True,
- function=self._async_discovery,
+ function=self._async_fire_discovery_event,
background=True,
)
+ self._flow_subscriptions: list[Callable[[str, str], None]] = []
async def async_wait_import_flow_initialized(self, handler: str) -> None:
"""Wait till all import flows in progress are initialized."""
@@ -1239,14 +1384,6 @@ class ConfigEntriesFlowManager(
await asyncio.wait(current.values())
- @callback
- def _async_has_other_discovery_flows(self, flow_id: str) -> bool:
- """Check if there are any other discovery flows in progress."""
- for flow in self._progress.values():
- if flow.flow_id != flow_id and flow.context["source"] in DISCOVERY_SOURCES:
- return True
- return False
-
async def async_init(
self,
handler: str,
@@ -1318,8 +1455,19 @@ class ConfigEntriesFlowManager(
if not self._pending_import_flows[handler]:
del self._pending_import_flows[handler]
- if result["type"] != data_entry_flow.FlowResultType.ABORT:
- await self.async_post_init(flow, result)
+ if (
+ result["type"] != data_entry_flow.FlowResultType.ABORT
+ and source in DISCOVERY_SOURCES
+ ):
+ # Fire discovery event
+ await self._discovery_event_debouncer.async_call()
+
+ if result["type"] != data_entry_flow.FlowResultType.ABORT and source in (
+ DISCOVERY_SOURCES | {SOURCE_REAUTH}
+ ):
+ # Notify listeners that a flow is created
+ for subscription in self._flow_subscriptions:
+ subscription("added", flow.flow_id)
return result
@@ -1361,7 +1509,22 @@ class ConfigEntriesFlowManager(
for future_list in self._initialize_futures.values():
for future in future_list:
future.set_result(None)
- self._discovery_debouncer.async_shutdown()
+ self._discovery_event_debouncer.async_shutdown()
+
+ @callback
+ def async_flow_removed(
+ self,
+ flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult],
+ ) -> None:
+ """Handle a removed config flow."""
+ flow = cast(ConfigFlow, flow)
+
+ # Clean up issue if this is a reauth flow
+ if flow.context["source"] == SOURCE_REAUTH:
+ if (entry_id := flow.context.get("entry_id")) is not None:
+ # The config entry's domain is flow.handler
+ issue_id = f"config_entry_reauth_{flow.handler}_{entry_id}"
+ ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id)
async def async_finish_flow(
self,
@@ -1375,26 +1538,8 @@ class ConfigEntriesFlowManager(
"""
flow = cast(ConfigFlow, flow)
- # Mark the step as done.
- # We do this to avoid a circular dependency where async_finish_flow sets up a
- # new entry, which needs the integration to be set up, which is waiting for
- # init to be done.
- self._set_pending_import_done(flow)
-
- # Remove notification if no other discovery config entries in progress
- if not self._async_has_other_discovery_flows(flow.flow_id):
- persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_ID)
-
- # Clean up issue if this is a reauth flow
- if flow.context["source"] == SOURCE_REAUTH:
- if (entry_id := flow.context.get("entry_id")) is not None and (
- entry := self.config_entries.async_get_entry(entry_id)
- ) is not None:
- issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}"
- ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id)
-
if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
- # If there's an ignored config entry with a matching unique ID,
+ # If there's a config entry with a matching unique ID,
# update the discovery key.
if (
(discovery_key := flow.context.get("discovery_key"))
@@ -1431,6 +1576,12 @@ class ConfigEntriesFlowManager(
)
return result
+ # Mark the step as done.
+ # We do this to avoid a circular dependency where async_finish_flow sets up a
+ # new entry, which needs the integration to be set up, which is waiting for
+ # init to be done.
+ self._set_pending_import_done(flow)
+
# Avoid adding a config entry for a integration
# that only supports a single config entry, but already has an entry
if (
@@ -1480,6 +1631,27 @@ class ConfigEntriesFlowManager(
result["handler"], flow.unique_id
)
+ if (
+ existing_entry is not None
+ and flow.handler != "mobile_app"
+ and existing_entry.source != SOURCE_IGNORE
+ ):
+ # This causes the old entry to be removed and replaced, when the flow
+ # should instead be aborted.
+ # In case of manual flows, integrations should implement options, reauth,
+ # reconfigure to allow the user to change settings.
+ # In case of non user visible flows, the integration should optionally
+ # update the existing entry before aborting.
+ # see https://developers.home-assistant.io/blog/2025/03/01/config-flow-unique-id/
+ report_usage(
+ "creates a config entry when another entry with the same unique ID "
+ "exists",
+ core_behavior=ReportBehavior.LOG,
+ core_integration_behavior=ReportBehavior.LOG,
+ custom_integration_behavior=ReportBehavior.LOG,
+ integration_domain=flow.handler,
+ )
+
# Unload the entry before setting up the new one.
if existing_entry is not None and existing_entry.state.recoverable:
await self.config_entries.async_unload(existing_entry.entry_id)
@@ -1497,6 +1669,7 @@ class ConfigEntriesFlowManager(
minor_version=result["minor_version"],
options=result["options"],
source=flow.context["source"],
+ subentries_data=result["subentries"],
title=result["title"],
unique_id=flow.unique_id,
version=result["version"],
@@ -1537,43 +1710,12 @@ class ConfigEntriesFlowManager(
flow.init_step = context["source"]
return flow
- async def async_post_init(
- self,
- flow: data_entry_flow.FlowHandler[ConfigFlowContext, ConfigFlowResult],
- result: ConfigFlowResult,
- ) -> None:
- """After a flow is initialised trigger new flow notifications."""
- source = flow.context["source"]
-
- # Create notification.
- if source in DISCOVERY_SOURCES:
- await self._discovery_debouncer.async_call()
- elif source == SOURCE_REAUTH:
- persistent_notification.async_create(
- self.hass,
- title="Integration requires reconfiguration",
- message=(
- "At least one of your integrations requires reconfiguration to "
- "continue functioning. [Check it out](/config/integrations)."
- ),
- notification_id=RECONFIGURE_NOTIFICATION_ID,
- )
-
@callback
- def _async_discovery(self) -> None:
- """Handle discovery."""
+ def _async_fire_discovery_event(self) -> None:
+ """Fire discovery event."""
# async_fire_internal is used here because this is only
# called from the Debouncer so we know the usage is safe
self.hass.bus.async_fire_internal(EVENT_FLOW_DISCOVERED)
- persistent_notification.async_create(
- self.hass,
- title="New devices discovered",
- message=(
- "We have discovered new devices on your network. "
- "[Check it out](/config/integrations)."
- ),
- notification_id=DISCOVERY_NOTIFICATION_ID,
- )
@callback
def async_has_matching_discovery_flow(
@@ -1604,6 +1746,29 @@ class ConfigEntriesFlowManager(
return True
return False
+ @callback
+ def async_subscribe_flow(
+ self, listener: Callable[[str, str], None]
+ ) -> CALLBACK_TYPE:
+ """Subscribe to non user initiated flow init or remove."""
+ self._flow_subscriptions.append(listener)
+ return lambda: self._flow_subscriptions.remove(listener)
+
+ @callback
+ def _async_remove_flow_progress(self, flow_id: str) -> None:
+ """Remove a flow from in progress."""
+ flow = self._progress.get(flow_id)
+ super()._async_remove_flow_progress(flow_id)
+ # Fire remove event for initialized non user initiated flows
+ if (
+ not flow
+ or flow.cur_step is None
+ or flow.source not in (DISCOVERY_SOURCES | {SOURCE_REAUTH})
+ ):
+ return
+ for listeners in self._flow_subscriptions:
+ listeners("removed", flow_id)
+
class ConfigEntryItems(UserDict[str, ConfigEntry]):
"""Container for config items, maps config_entry_id -> entry.
@@ -1787,6 +1952,11 @@ class ConfigEntryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
for entry in data["entries"]:
entry["discovery_keys"] = {}
+ if old_minor_version < 5:
+ # Version 1.4 adds config subentries
+ for entry in data["entries"]:
+ entry.setdefault("subentries", entry.get("subentries", {}))
+
if old_major_version > 1:
raise NotImplementedError
return data
@@ -1803,6 +1973,7 @@ class ConfigEntries:
self.hass = hass
self.flow = ConfigEntriesFlowManager(hass, self, hass_config)
self.options = OptionsFlowManager(hass)
+ self.subentries = ConfigSubentryFlowManager(hass)
self._hass_config = hass_config
self._entries = ConfigEntryItems(hass)
self._store = ConfigEntryStore(hass)
@@ -1834,7 +2005,7 @@ class ConfigEntries:
Raises UnknownEntry if entry is not found.
"""
if (entry := self.async_get_entry(entry_id)) is None:
- raise UnknownEntry
+ raise UnknownEntry(entry_id)
return entry
@callback
@@ -1934,9 +2105,9 @@ class ConfigEntries:
else:
unload_success = await self.async_unload(entry_id, _lock=False)
+ del self._entries[entry.entry_id]
await entry.async_remove(self.hass)
- del self._entries[entry.entry_id]
self.async_update_issues()
self._async_schedule_save()
@@ -1956,13 +2127,7 @@ class ConfigEntries:
# If the configuration entry is removed during reauth, it should
# abort any reauth flow that is active for the removed entry and
# linked issues.
- for progress_flow in self.hass.config_entries.flow.async_progress_by_handler(
- entry.domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH}
- ):
- if "flow_id" in progress_flow:
- self.hass.config_entries.flow.async_abort(progress_flow["flow_id"])
- issue_id = f"config_entry_reauth_{entry.domain}_{entry.entry_id}"
- ir.async_delete_issue(self.hass, HOMEASSISTANT_DOMAIN, issue_id)
+ _abort_reauth_flows(self.hass, entry.domain, entry_id)
self._async_dispatch(ConfigEntryChange.REMOVED, entry)
@@ -2005,6 +2170,7 @@ class ConfigEntries:
pref_disable_new_entities=entry["pref_disable_new_entities"],
pref_disable_polling=entry["pref_disable_polling"],
source=entry["source"],
+ subentries_data=entry["subentries"],
title=entry["title"],
unique_id=entry["unique_id"],
version=entry["version"],
@@ -2093,6 +2259,9 @@ class ConfigEntries:
# attempts.
entry.async_cancel_retry_setup()
+ # Abort any in-progress reauth flow and linked issues
+ _abort_reauth_flows(self.hass, entry.domain, entry_id)
+
if entry.domain not in self.hass.config.components:
# If the component is not loaded, just load it as
# the config entry will be loaded as well. We need
@@ -2164,6 +2333,44 @@ class ConfigEntries:
If the entry was changed, the update_listeners are
fired and this function returns True
+ If the entry was not changed, the update_listeners are
+ not fired and this function returns False
+ """
+ return self._async_update_entry(
+ entry,
+ data=data,
+ discovery_keys=discovery_keys,
+ minor_version=minor_version,
+ options=options,
+ pref_disable_new_entities=pref_disable_new_entities,
+ pref_disable_polling=pref_disable_polling,
+ title=title,
+ unique_id=unique_id,
+ version=version,
+ )
+
+ @callback
+ def _async_update_entry(
+ self,
+ entry: ConfigEntry,
+ *,
+ data: Mapping[str, Any] | UndefinedType = UNDEFINED,
+ discovery_keys: MappingProxyType[str, tuple[DiscoveryKey, ...]]
+ | UndefinedType = UNDEFINED,
+ minor_version: int | UndefinedType = UNDEFINED,
+ options: Mapping[str, Any] | UndefinedType = UNDEFINED,
+ pref_disable_new_entities: bool | UndefinedType = UNDEFINED,
+ pref_disable_polling: bool | UndefinedType = UNDEFINED,
+ subentries: dict[str, ConfigSubentry] | UndefinedType = UNDEFINED,
+ title: str | UndefinedType = UNDEFINED,
+ unique_id: str | None | UndefinedType = UNDEFINED,
+ version: int | UndefinedType = UNDEFINED,
+ ) -> bool:
+ """Update a config entry.
+
+ If the entry was changed, the update_listeners are
+ fired and this function returns True
+
If the entry was not changed, the update_listeners are
not fired and this function returns False
"""
@@ -2177,12 +2384,7 @@ class ConfigEntries:
if unique_id is not UNDEFINED and entry.unique_id != unique_id:
# Deprecated in 2024.11, should fail in 2025.11
if (
- # flipr creates duplicates during migration, and asks users to
- # remove the duplicate. We don't need warn about it here too.
- # We should remove the special case for "flipr" in HA Core 2025.4,
- # when the flipr migration period ends
- entry.domain != "flipr"
- and unique_id is not None
+ unique_id is not None
and self.async_entry_for_domain_unique_id(entry.domain, unique_id)
is not None
):
@@ -2226,11 +2428,21 @@ class ConfigEntries:
changed = True
_setter(entry, "options", MappingProxyType(options))
+ if subentries is not UNDEFINED:
+ if entry.subentries != subentries:
+ changed = True
+ _setter(entry, "subentries", MappingProxyType(subentries))
+
if not changed:
return False
_setter(entry, "modified_at", utcnow())
+ self._async_save_and_notify(entry)
+ return True
+
+ @callback
+ def _async_save_and_notify(self, entry: ConfigEntry) -> None:
for listener in entry.update_listeners:
self.hass.async_create_task(
listener(self.hass, entry),
@@ -2241,8 +2453,92 @@ class ConfigEntries:
entry.clear_state_cache()
entry.clear_storage_cache()
self._async_dispatch(ConfigEntryChange.UPDATED, entry)
+
+ @callback
+ def async_add_subentry(self, entry: ConfigEntry, subentry: ConfigSubentry) -> bool:
+ """Add a subentry to a config entry."""
+ self._raise_if_subentry_unique_id_exists(entry, subentry.unique_id)
+
+ return self._async_update_entry(
+ entry,
+ subentries=entry.subentries | {subentry.subentry_id: subentry},
+ )
+
+ @callback
+ def async_remove_subentry(self, entry: ConfigEntry, subentry_id: str) -> bool:
+ """Remove a subentry from a config entry."""
+ subentries = dict(entry.subentries)
+ try:
+ subentries.pop(subentry_id)
+ except KeyError as err:
+ raise UnknownSubEntry from err
+
+ result = self._async_update_entry(entry, subentries=subentries)
+ dev_reg = dr.async_get(self.hass)
+ ent_reg = er.async_get(self.hass)
+
+ dev_reg.async_clear_config_subentry(entry.entry_id, subentry_id)
+ ent_reg.async_clear_config_subentry(entry.entry_id, subentry_id)
+ return result
+
+ @callback
+ def async_update_subentry(
+ self,
+ entry: ConfigEntry,
+ subentry: ConfigSubentry,
+ *,
+ data: Mapping[str, Any] | UndefinedType = UNDEFINED,
+ title: str | UndefinedType = UNDEFINED,
+ unique_id: str | None | UndefinedType = UNDEFINED,
+ ) -> bool:
+ """Update a config subentry.
+
+ If the subentry was changed, the update_listeners are
+ fired and this function returns True
+
+ If the subentry was not changed, the update_listeners are
+ not fired and this function returns False
+ """
+ if entry.entry_id not in self._entries:
+ raise UnknownEntry(entry.entry_id)
+ if subentry.subentry_id not in entry.subentries:
+ raise UnknownSubEntry(subentry.subentry_id)
+
+ self.hass.verify_event_loop_thread("hass.config_entries.async_update_subentry")
+ changed = False
+ _setter = object.__setattr__
+
+ if unique_id is not UNDEFINED and subentry.unique_id != unique_id:
+ self._raise_if_subentry_unique_id_exists(entry, unique_id)
+ changed = True
+ _setter(subentry, "unique_id", unique_id)
+
+ if title is not UNDEFINED and subentry.title != title:
+ changed = True
+ _setter(subentry, "title", title)
+
+ if data is not UNDEFINED and subentry.data != data:
+ changed = True
+ _setter(subentry, "data", MappingProxyType(data))
+
+ if not changed:
+ return False
+
+ _setter(entry, "modified_at", utcnow())
+
+ self._async_save_and_notify(entry)
return True
+ def _raise_if_subentry_unique_id_exists(
+ self, entry: ConfigEntry, unique_id: str | None
+ ) -> None:
+ """Raise if a subentry with the same unique_id exists."""
+ if unique_id is None:
+ return
+ for existing_subentry in entry.subentries.values():
+ if existing_subentry.unique_id == unique_id:
+ raise data_entry_flow.AbortFlow("already_configured")
+
@callback
def _async_dispatch(
self, change_type: ConfigEntryChange, entry: ConfigEntry
@@ -2435,11 +2731,7 @@ class ConfigEntries:
Config entries which are created after Home Assistant is started can't be waited
for, the function will just return if the config entry is loaded or not.
"""
- setup_done = self.hass.data.get(DATA_SETUP_DONE, {})
- if setup_future := setup_done.get(entry.domain):
- await setup_future
- # The component was not loaded.
- if entry.domain not in self.hass.config.components:
+ if not await async_wait_component(self.hass, entry.domain):
return False
return entry.state is ConfigEntryState.LOADED
@@ -2459,12 +2751,6 @@ class ConfigEntries:
issues.add(issue.issue_id)
for domain, unique_ids in self._entries._domain_unique_id_index.items(): # noqa: SLF001
- # flipr creates duplicates during migration, and asks users to
- # remove the duplicate. We don't need warn about it here too.
- # We should remove the special case for "flipr" in HA Core 2025.4,
- # when the flipr migration period ends
- if domain == "flipr":
- continue
for unique_id, entries in unique_ids.items():
# We might mutate the list of entries, so we need a copy to not mess up
# the index
@@ -2579,6 +2865,14 @@ class ConfigFlow(ConfigEntryBaseFlow):
"""Return options flow support for this handler."""
return cls.async_get_options_flow is not ConfigFlow.async_get_options_flow
+ @classmethod
+ @callback
+ def async_get_supported_subentry_types(
+ cls, config_entry: ConfigEntry
+ ) -> dict[str, type[ConfigSubentryFlow]]:
+ """Return subentries supported by this handler."""
+ return {}
+
@callback
def _async_abort_entries_match(
self, match_dict: dict[str, Any] | None = None
@@ -2619,6 +2913,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
reload_on_update: bool = True,
*,
error: str = "already_configured",
+ description_placeholders: Mapping[str, str] | None = None,
) -> None:
"""Abort if the unique ID is already configured.
@@ -2659,7 +2954,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
return
if should_reload:
self.hass.config_entries.async_schedule_reload(entry.entry_id)
- raise data_entry_flow.AbortFlow(error)
+ raise data_entry_flow.AbortFlow(error, description_placeholders)
async def async_set_unique_id(
self, unique_id: str | None = None, *, raise_on_progress: bool = True
@@ -2673,8 +2968,11 @@ class ConfigFlow(ConfigEntryBaseFlow):
return None
if raise_on_progress:
- if self._async_in_progress(
- include_uninitialized=True, match_context={"unique_id": unique_id}
+ if any(
+ flow["context"]["source"] != SOURCE_REAUTH
+ for flow in self._async_in_progress(
+ include_uninitialized=True, match_context={"unique_id": unique_id}
+ )
):
raise data_entry_flow.AbortFlow("already_in_progress")
@@ -2803,29 +3101,6 @@ class ConfigFlow(ConfigEntryBaseFlow):
"""Handle a flow initialized by discovery."""
return await self._async_step_discovery_without_unique_id()
- @callback
- def async_abort(
- self,
- *,
- reason: str,
- description_placeholders: Mapping[str, str] | None = None,
- ) -> ConfigFlowResult:
- """Abort the config flow."""
- # Remove reauth notification if no reauth flows are in progress
- if self.source == SOURCE_REAUTH and not any(
- ent["flow_id"] != self.flow_id
- for ent in self.hass.config_entries.flow.async_progress_by_handler(
- self.handler, match_context={"source": SOURCE_REAUTH}
- )
- ):
- persistent_notification.async_dismiss(
- self.hass, RECONFIGURE_NOTIFICATION_ID
- )
-
- return super().async_abort(
- reason=reason, description_placeholders=description_placeholders
- )
-
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
@@ -2887,6 +3162,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
description: str | None = None,
description_placeholders: Mapping[str, str] | None = None,
options: Mapping[str, Any] | None = None,
+ subentries: Iterable[ConfigSubentryData] | None = None,
) -> ConfigFlowResult:
"""Finish config flow and create a config entry."""
if self.source in {SOURCE_REAUTH, SOURCE_RECONFIGURE}:
@@ -2906,6 +3182,7 @@ class ConfigFlow(ConfigEntryBaseFlow):
result["minor_version"] = self.MINOR_VERSION
result["options"] = options or {}
+ result["subentries"] = subentries or ()
result["version"] = self.VERSION
return result
@@ -3020,17 +3297,193 @@ class ConfigFlow(ConfigEntryBaseFlow):
)
-class OptionsFlowManager(
- data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult]
-):
- """Flow to set options for a configuration entry."""
+class _ConfigSubFlowManager:
+ """Mixin class for flow managers which manage flows tied to a config entry."""
- _flow_result = ConfigFlowResult
+ hass: HomeAssistant
def _async_get_config_entry(self, config_entry_id: str) -> ConfigEntry:
"""Return config entry or raise if not found."""
return self.hass.config_entries.async_get_known_entry(config_entry_id)
+
+class ConfigSubentryFlowManager(
+ data_entry_flow.FlowManager[
+ SubentryFlowContext, SubentryFlowResult, tuple[str, str]
+ ],
+ _ConfigSubFlowManager,
+):
+ """Manage all the config subentry flows that are in progress."""
+
+ _flow_result = SubentryFlowResult
+
+ async def async_create_flow(
+ self,
+ handler_key: tuple[str, str],
+ *,
+ context: FlowContext | None = None,
+ data: dict[str, Any] | None = None,
+ ) -> ConfigSubentryFlow:
+ """Create a subentry flow for a config entry.
+
+ The entry_id and flow.handler[0] is the same thing to map entry with flow.
+ """
+ if not context or "source" not in context:
+ raise KeyError("Context not set or doesn't have a source set")
+
+ entry_id, subentry_type = handler_key
+ entry = self._async_get_config_entry(entry_id)
+ handler = await _async_get_flow_handler(self.hass, entry.domain, {})
+ subentry_types = handler.async_get_supported_subentry_types(entry)
+ if subentry_type not in subentry_types:
+ raise data_entry_flow.UnknownHandler(
+ f"Config entry '{entry.domain}' does not support subentry '{subentry_type}'"
+ )
+ subentry_flow = subentry_types[subentry_type]()
+ subentry_flow.init_step = context["source"]
+ return subentry_flow
+
+ async def async_finish_flow(
+ self,
+ flow: data_entry_flow.FlowHandler[
+ SubentryFlowContext, SubentryFlowResult, tuple[str, str]
+ ],
+ result: SubentryFlowResult,
+ ) -> SubentryFlowResult:
+ """Finish a subentry flow and add a new subentry to the configuration entry.
+
+ The flow.handler[0] and entry_id is the same thing to map flow with entry.
+ """
+ flow = cast(ConfigSubentryFlow, flow)
+
+ if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY:
+ return result
+
+ entry_id, subentry_type = flow.handler
+ entry = self.hass.config_entries.async_get_entry(entry_id)
+ if entry is None:
+ raise UnknownEntry(entry_id)
+
+ unique_id = result.get("unique_id")
+ if unique_id is not None and not isinstance(unique_id, str):
+ raise HomeAssistantError("unique_id must be a string")
+
+ self.hass.config_entries.async_add_subentry(
+ entry,
+ ConfigSubentry(
+ data=MappingProxyType(result["data"]),
+ subentry_type=subentry_type,
+ title=result["title"],
+ unique_id=unique_id,
+ ),
+ )
+
+ result["result"] = True
+ return result
+
+
+class ConfigSubentryFlow(
+ data_entry_flow.FlowHandler[
+ SubentryFlowContext, SubentryFlowResult, tuple[str, str]
+ ]
+):
+ """Base class for config subentry flows."""
+
+ _flow_result = SubentryFlowResult
+ handler: tuple[str, str]
+
+ @callback
+ def async_create_entry(
+ self,
+ *,
+ title: str | None = None,
+ data: Mapping[str, Any],
+ description: str | None = None,
+ description_placeholders: Mapping[str, str] | None = None,
+ unique_id: str | None = None,
+ ) -> SubentryFlowResult:
+ """Finish config flow and create a config entry."""
+ if self.source != SOURCE_USER:
+ raise ValueError(f"Source is {self.source}, expected {SOURCE_USER}")
+
+ result = super().async_create_entry(
+ title=title,
+ data=data,
+ description=description,
+ description_placeholders=description_placeholders,
+ )
+
+ result["unique_id"] = unique_id
+
+ return result
+
+ @callback
+ def async_update_and_abort(
+ self,
+ entry: ConfigEntry,
+ subentry: ConfigSubentry,
+ *,
+ unique_id: str | None | UndefinedType = UNDEFINED,
+ title: str | UndefinedType = UNDEFINED,
+ data: Mapping[str, Any] | UndefinedType = UNDEFINED,
+ data_updates: Mapping[str, Any] | UndefinedType = UNDEFINED,
+ ) -> SubentryFlowResult:
+ """Update config subentry and finish subentry flow.
+
+ :param data: replace the subentry data with new data
+ :param data_updates: add items from data_updates to subentry data - existing
+ keys are overridden
+ :param title: replace the title of the subentry
+ :param unique_id: replace the unique_id of the subentry
+ """
+ if data_updates is not UNDEFINED:
+ if data is not UNDEFINED:
+ raise ValueError("Cannot set both data and data_updates")
+ data = subentry.data | data_updates
+ self.hass.config_entries.async_update_subentry(
+ entry=entry,
+ subentry=subentry,
+ unique_id=unique_id,
+ title=title,
+ data=data,
+ )
+ return self.async_abort(reason="reconfigure_successful")
+
+ @property
+ def _entry_id(self) -> str:
+ """Return config entry id."""
+ return self.handler[0]
+
+ @callback
+ def _get_entry(self) -> ConfigEntry:
+ """Return the config entry linked to the current context."""
+ return self.hass.config_entries.async_get_known_entry(self._entry_id)
+
+ @property
+ def _reconfigure_subentry_id(self) -> str:
+ """Return reconfigure subentry id."""
+ if self.source != SOURCE_RECONFIGURE:
+ raise ValueError(f"Source is {self.source}, expected {SOURCE_RECONFIGURE}")
+ return self.context["subentry_id"]
+
+ @callback
+ def _get_reconfigure_subentry(self) -> ConfigSubentry:
+ """Return the reconfigure config subentry linked to the current context."""
+ entry = self.hass.config_entries.async_get_known_entry(self._entry_id)
+ subentry_id = self._reconfigure_subentry_id
+ if subentry_id not in entry.subentries:
+ raise UnknownSubEntry(subentry_id)
+ return entry.subentries[subentry_id]
+
+
+class OptionsFlowManager(
+ data_entry_flow.FlowManager[ConfigFlowContext, ConfigFlowResult],
+ _ConfigSubFlowManager,
+):
+ """Manage all the config entry option flows that are in progress."""
+
+ _flow_result = ConfigFlowResult
+
async def async_create_flow(
self,
handler_key: str,
@@ -3040,7 +3493,7 @@ class OptionsFlowManager(
) -> OptionsFlow:
"""Create an options flow for a config entry.
- Entry_id and flow.handler is the same thing to map entry with flow.
+ The entry_id and the flow.handler is the same thing to map entry with flow.
"""
entry = self._async_get_config_entry(handler_key)
handler = await _async_get_flow_handler(self.hass, entry.domain, {})
@@ -3056,7 +3509,7 @@ class OptionsFlowManager(
This method is called when a flow step returns FlowResultType.ABORT or
FlowResultType.CREATE_ENTRY.
- Flow.handler and entry_id is the same thing to map flow with entry.
+ The flow.handler and the entry_id is the same thing to map flow with entry.
"""
flow = cast(OptionsFlow, flow)
@@ -3330,3 +3783,13 @@ async def _async_get_flow_handler(
return handler
raise data_entry_flow.UnknownHandler
+
+
+@callback
+def _abort_reauth_flows(hass: HomeAssistant, domain: str, entry_id: str) -> None:
+ """Abort reauth flows for an entry."""
+ for progress_flow in hass.config_entries.flow.async_progress_by_handler(
+ domain, match_context={"entry_id": entry_id, "source": SOURCE_REAUTH}
+ ):
+ if "flow_id" in progress_flow:
+ hass.config_entries.flow.async_abort(progress_flow["flow_id"])
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 7775b618795..a7ace52a0da 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -24,12 +24,12 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2025
-MINOR_VERSION: Final = 3
+MINOR_VERSION: Final = 5
PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
-REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0)
-REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0)
+REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
+REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
# Truthy date string triggers showing related deprecation warning messages.
REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = ""
@@ -603,6 +603,7 @@ class UnitOfReactivePower(StrEnum):
"""Reactive power units."""
VOLT_AMPERE_REACTIVE = "var"
+ KILO_VOLT_AMPERE_REACTIVE = "kvar"
_DEPRECATED_POWER_VOLT_AMPERE_REACTIVE: Final = DeprecatedConstantEnum(
diff --git a/homeassistant/core.py b/homeassistant/core.py
index 46ae499e2ca..b33e9496c7c 100644
--- a/homeassistant/core.py
+++ b/homeassistant/core.py
@@ -38,6 +38,7 @@ from typing import (
TypedDict,
TypeVar,
cast,
+ final,
overload,
)
@@ -324,6 +325,7 @@ class HassJobType(enum.Enum):
Executor = 3
+@final # Final to allow direct checking of the type instead of using isinstance
class HassJob[**_P, _R_co]:
"""Represent a job to be run later.
@@ -1317,6 +1319,7 @@ class EventOrigin(enum.Enum):
return next((idx for idx, origin in enumerate(EventOrigin) if origin is self))
+@final # Final to allow direct checking of the type instead of using isinstance
class Event(Generic[_DataT]):
"""Representation of an event within the bus."""
@@ -1935,13 +1938,14 @@ class State:
# to avoid callers outside of this module
# from misusing it by mistake.
context = state_context._as_dict # noqa: SLF001
+ last_changed_timestamp = self.last_changed_timestamp
compressed_state: CompressedState = {
COMPRESSED_STATE_STATE: self.state,
COMPRESSED_STATE_ATTRIBUTES: self.attributes,
COMPRESSED_STATE_CONTEXT: context,
- COMPRESSED_STATE_LAST_CHANGED: self.last_changed_timestamp,
+ COMPRESSED_STATE_LAST_CHANGED: last_changed_timestamp,
}
- if self.last_changed != self.last_updated:
+ if last_changed_timestamp != self.last_updated_timestamp:
compressed_state[COMPRESSED_STATE_LAST_UPDATED] = (
self.last_updated_timestamp
)
diff --git a/homeassistant/core_config.py b/homeassistant/core_config.py
index f080705fced..9cd232097a7 100644
--- a/homeassistant/core_config.py
+++ b/homeassistant/core_config.py
@@ -581,9 +581,7 @@ class Config:
self.all_components: set[str] = set()
# Set of loaded components
- self.components: _ComponentSet = _ComponentSet(
- self.top_level_components, self.all_components
- )
+ self.components = _ComponentSet(self.top_level_components, self.all_components)
# API (HTTP) server configuration
self.api: ApiConfig | None = None
diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py
index e5ee5a79922..9286f9c78f5 100644
--- a/homeassistant/data_entry_flow.py
+++ b/homeassistant/data_entry_flow.py
@@ -40,6 +40,7 @@ class FlowResultType(StrEnum):
# Event that is fired when a flow is progressed via external or progress source.
EVENT_DATA_ENTRY_FLOW_PROGRESSED = "data_entry_flow_progressed"
+EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE = "data_entry_flow_progress_update"
FLOW_NOT_COMPLETE_STEPS = {
FlowResultType.FORM,
@@ -207,6 +208,13 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
Handler key is the domain of the component that we want to set up.
"""
+ @callback
+ def async_flow_removed(
+ self,
+ flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT],
+ ) -> None:
+ """Handle a removed data entry flow."""
+
@abc.abstractmethod
async def async_finish_flow(
self,
@@ -219,13 +227,6 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
FlowResultType.CREATE_ENTRY.
"""
- async def async_post_init(
- self,
- flow: FlowHandler[_FlowContextT, _FlowResultT, _HandlerT],
- result: _FlowResultT,
- ) -> None:
- """Entry has finished executing its first step asynchronously."""
-
@callback
def async_get(self, flow_id: str) -> _FlowResultT:
"""Return a flow in progress as a partial FlowResult."""
@@ -312,12 +313,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
flow.init_data = data
self._async_add_flow_progress(flow)
- result = await self._async_handle_step(flow, flow.init_step, data)
-
- if result["type"] != FlowResultType.ABORT:
- await self.async_post_init(flow, result)
-
- return result
+ return await self._async_handle_step(flow, flow.init_step, data)
async def async_configure(
self, flow_id: str, user_input: dict | None = None
@@ -469,6 +465,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
"""Remove a flow from in progress."""
if (flow := self._progress.pop(flow_id, None)) is None:
raise UnknownFlow
+ self.async_flow_removed(flow)
self._async_remove_flow_from_index(flow)
flow.async_cancel_progress_task()
try:
@@ -497,6 +494,13 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
description_placeholders=err.description_placeholders,
)
+ if flow.flow_id not in self._progress:
+ # The flow was removed during the step, raise UnknownFlow
+ # unless the result is an abort
+ if result["type"] != FlowResultType.ABORT:
+ raise UnknownFlow
+ return result
+
# Setup the flow handler's preview if needed
if result.get("preview") is not None:
await self._async_setup_preview(flow)
@@ -547,7 +551,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
flow.cur_step = result
return result
- # Abort and Success results both finish the flow
+ # Abort and Success results both finish the flow.
self._async_remove_flow_progress(flow.flow_id)
return result
@@ -561,7 +565,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]):
if not hasattr(flow, method):
self._async_remove_flow_progress(flow.flow_id)
raise UnknownStep(
- f"Handler {self.__class__.__name__} doesn't support step {step_id}"
+ f"Handler {flow.__class__.__name__} doesn't support step {step_id}"
)
async def _async_setup_preview(
@@ -657,6 +661,19 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
):
continue
+ # Process the section schema options
+ if (
+ suggested_values is not None
+ and isinstance(val, section)
+ and key in suggested_values
+ ):
+ new_section_key = copy.copy(key)
+ schema[new_section_key] = val
+ val.schema = self.add_suggested_values_to_schema(
+ val.schema, suggested_values[key]
+ )
+ continue
+
new_key = key
if (
suggested_values
@@ -813,6 +830,14 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
flow_result["step_id"] = step_id
return flow_result
+ @callback
+ def async_update_progress(self, progress: float) -> None:
+ """Update the progress of a flow. `progress` must be between 0 and 1."""
+ self.hass.bus.async_fire_internal(
+ EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE,
+ {"handler": self.handler, "flow_id": self.flow_id, "progress": progress},
+ )
+
@callback
def async_show_progress_done(self, *, next_step_id: str) -> _FlowResultT:
"""Mark the progress done."""
diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py
index 08fe28e4df5..2f088716f8c 100644
--- a/homeassistant/generated/application_credentials.py
+++ b/homeassistant/generated/application_credentials.py
@@ -19,15 +19,19 @@ APPLICATION_CREDENTIALS = [
"iotty",
"lametric",
"lyric",
+ "mcp",
"microbees",
+ "miele",
"monzo",
"myuplink",
"neato",
"nest",
"netatmo",
+ "ondilo_ico",
"onedrive",
"point",
"senz",
+ "smartthings",
"spotify",
"tesla_fleet",
"twitch",
diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py
index 447b6d284f0..de7369b9479 100644
--- a/homeassistant/generated/bluetooth.py
+++ b/homeassistant/generated/bluetooth.py
@@ -356,6 +356,36 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "inkbird",
"local_name": "tps",
},
+ {
+ "connectable": False,
+ "domain": "inkbird",
+ "local_name": "ITH-11-B",
+ },
+ {
+ "connectable": False,
+ "domain": "inkbird",
+ "local_name": "ITH-13-B",
+ },
+ {
+ "connectable": False,
+ "domain": "inkbird",
+ "local_name": "ITH-21-B",
+ },
+ {
+ "connectable": True,
+ "domain": "inkbird",
+ "local_name": "Ink@IAM-T1",
+ },
+ {
+ "connectable": True,
+ "domain": "inkbird",
+ "manufacturer_data_start": [
+ 65,
+ 67,
+ 45,
+ ],
+ "manufacturer_id": 12628,
+ },
{
"connectable": True,
"domain": "iron_os",
@@ -374,6 +404,10 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "keymitt_ble",
"local_name": "mib*",
},
+ {
+ "domain": "kulersky",
+ "service_uuid": "8d96a001-0002-64c2-0001-9acc4838521c",
+ },
{
"domain": "lamarzocco",
"local_name": "MICRA_*",
@@ -688,6 +722,15 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"manufacturer_id": 17,
"service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb",
},
+ {
+ "connectable": False,
+ "domain": "thermobeacon",
+ "manufacturer_data_start": [
+ 0,
+ ],
+ "manufacturer_id": 20,
+ "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb",
+ },
{
"connectable": False,
"domain": "thermobeacon",
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index d0a8e821f8d..c53c83bad38 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -79,6 +79,7 @@ FLOWS = {
"azure_data_explorer",
"azure_devops",
"azure_event_hub",
+ "azure_storage",
"baf",
"balboa",
"bang_olufsen",
@@ -90,6 +91,7 @@ FLOWS = {
"bluetooth",
"bmw_connected_drive",
"bond",
+ "bosch_alarm",
"bosch_shc",
"braviatv",
"bring",
@@ -283,6 +285,7 @@ FLOWS = {
"ifttt",
"igloohome",
"imap",
+ "imeon_inverter",
"imgw_pib",
"improv_ble",
"incomfort",
@@ -375,6 +378,7 @@ FLOWS = {
"meteoclimatic",
"metoffice",
"microbees",
+ "miele",
"mikrotik",
"mill",
"minecraft_server",
@@ -467,6 +471,7 @@ FLOWS = {
"peco",
"pegel_online",
"permobil",
+ "pglab",
"philips_js",
"pi_hole",
"picnic",
@@ -486,6 +491,7 @@ FLOWS = {
"proximity",
"prusalink",
"ps4",
+ "pterodactyl",
"pure_energie",
"purpleair",
"pushbullet",
@@ -511,6 +517,7 @@ FLOWS = {
"rdw",
"recollect_waste",
"refoss",
+ "remote_calendar",
"renault",
"renson",
"reolink",
@@ -545,6 +552,7 @@ FLOWS = {
"sensirion_ble",
"sensorpro",
"sensorpush",
+ "sensorpush_cloud",
"sensoterra",
"sentry",
"senz",
@@ -573,6 +581,7 @@ FLOWS = {
"smlight",
"sms",
"snapcast",
+ "snoo",
"snooz",
"solaredge",
"solarlog",
@@ -689,6 +698,7 @@ FLOWS = {
"weatherflow",
"weatherflow_cloud",
"weatherkit",
+ "webdav",
"webmin",
"webostv",
"weheat",
diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py
index 3dba5a98f3c..39854ff0af6 100644
--- a/homeassistant/generated/dhcp.py
+++ b/homeassistant/generated/dhcp.py
@@ -84,6 +84,16 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "blink*",
"macaddress": "20A171*",
},
+ {
+ "domain": "bond",
+ "hostname": "bond-*",
+ "macaddress": "3C6A2C1*",
+ },
+ {
+ "domain": "bond",
+ "hostname": "bond-*",
+ "macaddress": "F44E38*",
+ },
{
"domain": "broadlink",
"registered_devices": True,
@@ -498,6 +508,18 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"hostname": "ring*",
"macaddress": "341513*",
},
+ {
+ "domain": "roborock",
+ "macaddress": "249E7D*",
+ },
+ {
+ "domain": "roborock",
+ "macaddress": "B04A39*",
+ },
+ {
+ "domain": "roborock",
+ "hostname": "roborock-*",
+ },
{
"domain": "roomba",
"hostname": "irobot-*",
@@ -591,6 +613,15 @@ DHCP: Final[list[dict[str, str | bool]]] = [
"domain": "sleepiq",
"macaddress": "64DBA0*",
},
+ {
+ "domain": "sma",
+ "hostname": "sma*",
+ "macaddress": "0015BB*",
+ },
+ {
+ "domain": "sma",
+ "registered_devices": True,
+ },
{
"domain": "smartthings",
"hostname": "st*",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 6c688e07f5c..8dda9de3705 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -345,6 +345,11 @@
"config_flow": true,
"iot_class": "local_polling"
},
+ "apollo_automation": {
+ "name": "Apollo Automation",
+ "integration_type": "virtual",
+ "supported_by": "esphome"
+ },
"appalachianpower": {
"name": "Appalachian Power",
"integration_type": "virtual",
@@ -606,6 +611,13 @@
"config_flow": true,
"iot_class": "local_push"
},
+ "backup": {
+ "name": "Backup",
+ "integration_type": "service",
+ "config_flow": false,
+ "iot_class": "calculated",
+ "single_config_entry": true
+ },
"baf": {
"name": "Big Ass Fans",
"integration_type": "hub",
@@ -618,6 +630,11 @@
"config_flow": false,
"iot_class": "cloud_push"
},
+ "balay": {
+ "name": "Balay",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+ },
"balboa": {
"name": "Balboa Spa Client",
"integration_type": "hub",
@@ -747,11 +764,28 @@
"config_flow": true,
"iot_class": "local_push"
},
- "bosch_shc": {
- "name": "Bosch SHC",
- "integration_type": "hub",
- "config_flow": true,
- "iot_class": "local_push"
+ "bosch": {
+ "name": "Bosch",
+ "integrations": {
+ "bosch_alarm": {
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "local_push",
+ "name": "Bosch Alarm"
+ },
+ "bosch_shc": {
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_push",
+ "name": "Bosch SHC"
+ },
+ "home_connect": {
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_push",
+ "name": "Home Connect"
+ }
+ }
},
"brandt": {
"name": "Brandt Smart Control",
@@ -850,6 +884,11 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "burbank_water_and_power": {
+ "name": "Burbank Water and Power (BWP)",
+ "integration_type": "virtual",
+ "supported_by": "opower"
+ },
"caldav": {
"name": "CalDAV",
"integration_type": "hub",
@@ -1032,6 +1071,11 @@
"integration_type": "virtual",
"supported_by": "opower"
},
+ "constructa": {
+ "name": "Constructa",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+ },
"control4": {
"name": "Control4",
"integration_type": "hub",
@@ -1781,6 +1825,12 @@
}
}
},
+ "eve": {
+ "name": "Eve",
+ "iot_standards": [
+ "matter"
+ ]
+ },
"evergy": {
"name": "Evergy",
"integration_type": "virtual",
@@ -2041,6 +2091,11 @@
"config_flow": false,
"iot_class": "cloud_push"
},
+ "frankever": {
+ "name": "FrankEver",
+ "integration_type": "virtual",
+ "supported_by": "shelly"
+ },
"free_mobile": {
"name": "Free Mobile",
"integration_type": "hub",
@@ -2135,6 +2190,11 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "gaggenau": {
+ "name": "Gaggenau",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+ },
"garadget": {
"name": "Garadget",
"integration_type": "hub",
@@ -2474,6 +2534,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "hardkernel": {
+ "name": "Hardkernel",
+ "integration_type": "hardware",
+ "config_flow": false,
+ "single_config_entry": true
+ },
"harman_kardon_avr": {
"name": "Harman Kardon AVR",
"integration_type": "hub",
@@ -2605,18 +2671,28 @@
"config_flow": true,
"iot_class": "local_polling"
},
- "home_connect": {
- "name": "Home Connect",
- "integration_type": "hub",
- "config_flow": true,
- "iot_class": "cloud_push",
- "single_config_entry": true
- },
"home_plus_control": {
"name": "Legrand Home+ Control",
"integration_type": "virtual",
"supported_by": "netatmo"
},
+ "homeassistant_green": {
+ "name": "Home Assistant Green",
+ "integration_type": "hardware",
+ "config_flow": false,
+ "single_config_entry": true
+ },
+ "homeassistant_sky_connect": {
+ "name": "Home Assistant Connect ZBT-1",
+ "integration_type": "hardware",
+ "config_flow": true
+ },
+ "homeassistant_yellow": {
+ "name": "Home Assistant Yellow",
+ "integration_type": "hardware",
+ "config_flow": false,
+ "single_config_entry": true
+ },
"homee": {
"name": "Homee",
"integration_type": "hub",
@@ -2859,6 +2935,12 @@
"config_flow": true,
"iot_class": "cloud_push"
},
+ "imeon_inverter": {
+ "name": "Imeon Inverter",
+ "integration_type": "device",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"imgw_pib": {
"name": "IMGW-PIB",
"integration_type": "hub",
@@ -3247,7 +3329,7 @@
"name": "La Marzocco",
"integration_type": "device",
"config_flow": true,
- "iot_class": "cloud_polling"
+ "iot_class": "cloud_push"
},
"lametric": {
"name": "LaMetric",
@@ -3392,12 +3474,22 @@
"config_flow": false,
"iot_class": "assumed_state"
},
+ "linak": {
+ "name": "LINAK",
+ "integration_type": "virtual",
+ "supported_by": "idasen_desk"
+ },
"linear_garage_door": {
"name": "Linear Garage Door",
"integration_type": "hub",
"config_flow": true,
"iot_class": "cloud_polling"
},
+ "linkedgo": {
+ "name": "LinkedGo",
+ "integration_type": "virtual",
+ "supported_by": "shelly"
+ },
"linkplay": {
"name": "LinkPlay",
"integration_type": "hub",
@@ -3625,7 +3717,7 @@
"iot_class": "cloud_push"
},
"matter": {
- "name": "Matter (BETA)",
+ "name": "Matter",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push"
@@ -3795,6 +3887,12 @@
"iot_class": "cloud_push",
"name": "Azure Service Bus"
},
+ "azure_storage": {
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling",
+ "name": "Azure Storage"
+ },
"microsoft_face_detect": {
"integration_type": "hub",
"config_flow": false,
@@ -3839,6 +3937,13 @@
}
}
},
+ "miele": {
+ "name": "Miele",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_push",
+ "single_config_entry": true
+ },
"mijndomein_energie": {
"name": "Mijndomein Energie",
"integration_type": "virtual",
@@ -3969,7 +4074,10 @@
"iot_class": "assumed_state",
"name": "Motionblinds Bluetooth"
}
- }
+ },
+ "iot_standards": [
+ "matter"
+ ]
},
"motioneye": {
"name": "motionEye",
@@ -4127,6 +4235,11 @@
"config_flow": false,
"iot_class": "cloud_polling"
},
+ "neff": {
+ "name": "Neff",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+ },
"ness_alarm": {
"name": "Ness Alarm",
"integration_type": "hub",
@@ -4395,6 +4508,11 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "ogemray": {
+ "name": "Ogemray",
+ "integration_type": "virtual",
+ "supported_by": "shelly"
+ },
"ohmconnect": {
"name": "OhmConnect",
"integration_type": "hub",
@@ -4739,6 +4857,13 @@
"integration_type": "virtual",
"supported_by": "opower"
},
+ "pglab": {
+ "name": "PG LAB Electronics",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_push",
+ "single_config_entry": true
+ },
"philips": {
"name": "Philips",
"integrations": {
@@ -4808,6 +4933,11 @@
"integration_type": "virtual",
"supported_by": "wyoming"
},
+ "pitsos": {
+ "name": "Pitsos",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+ },
"pjlink": {
"name": "PJLink",
"integration_type": "hub",
@@ -4883,6 +5013,11 @@
"config_flow": true,
"single_config_entry": true
},
+ "profilo": {
+ "name": "Profilo",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+ },
"progettihwsw": {
"name": "ProgettiHWSW Automation",
"integration_type": "hub",
@@ -4945,6 +5080,12 @@
"integration_type": "virtual",
"supported_by": "opower"
},
+ "pterodactyl": {
+ "name": "Pterodactyl",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_polling"
+ },
"pulseaudio_loopback": {
"name": "PulseAudio Loopback",
"integration_type": "hub",
@@ -5149,6 +5290,11 @@
"raspberry_pi": {
"name": "Raspberry Pi",
"integrations": {
+ "raspberry_pi": {
+ "integration_type": "hardware",
+ "config_flow": false,
+ "name": "Raspberry Pi"
+ },
"rpi_camera": {
"integration_type": "hub",
"config_flow": false,
@@ -5222,6 +5368,11 @@
"config_flow": false,
"iot_class": "cloud_push"
},
+ "remote_calendar": {
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"renault": {
"name": "Renault",
"integration_type": "hub",
@@ -5575,9 +5726,20 @@
},
"sensorpush": {
"name": "SensorPush",
- "integration_type": "hub",
- "config_flow": true,
- "iot_class": "local_push"
+ "integrations": {
+ "sensorpush": {
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "local_push",
+ "name": "SensorPush"
+ },
+ "sensorpush_cloud": {
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_polling",
+ "name": "SensorPush Cloud"
+ }
+ }
},
"sensoterra": {
"name": "Sensoterra",
@@ -5668,6 +5830,11 @@
"config_flow": true,
"iot_class": "local_push"
},
+ "siemens": {
+ "name": "Siemens",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+ },
"sigfox": {
"name": "Sigfox",
"integration_type": "hub",
@@ -5893,6 +6060,12 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "snoo": {
+ "name": "Happiest Baby Snoo",
+ "integration_type": "hub",
+ "config_flow": true,
+ "iot_class": "cloud_push"
+ },
"snooz": {
"name": "Snooz",
"integration_type": "hub",
@@ -6414,6 +6587,11 @@
"config_flow": false,
"iot_class": "local_polling"
},
+ "thermador": {
+ "name": "Thermador",
+ "integration_type": "virtual",
+ "supported_by": "home_connect"
+ },
"thermobeacon": {
"name": "ThermoBeacon",
"integration_type": "hub",
@@ -7063,6 +7241,12 @@
}
}
},
+ "webdav": {
+ "name": "WebDAV",
+ "integration_type": "service",
+ "config_flow": true,
+ "iot_class": "cloud_polling"
+ },
"webmin": {
"name": "Webmin",
"integration_type": "device",
@@ -7624,6 +7808,7 @@
"plant",
"proximity",
"random",
+ "remote_calendar",
"rpi_power",
"schedule",
"season",
diff --git a/homeassistant/generated/mqtt.py b/homeassistant/generated/mqtt.py
index 72f160ee2ec..c4eb8708b0e 100644
--- a/homeassistant/generated/mqtt.py
+++ b/homeassistant/generated/mqtt.py
@@ -16,6 +16,9 @@ MQTT = {
"fully_kiosk": [
"fully/deviceInfo/+",
],
+ "pglab": [
+ "pglab/discovery/#",
+ ],
"qbus": [
"cloudapp/QBUSMQTTGW/state",
"cloudapp/QBUSMQTTGW/config",
diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py
index 5bbc178ba17..acbb74645a3 100644
--- a/homeassistant/generated/ssdp.py
+++ b/homeassistant/generated/ssdp.py
@@ -166,6 +166,13 @@ SSDP = {
"st": "urn:hyperion-project.org:device:basic:1",
},
],
+ "imeon_inverter": [
+ {
+ "deviceType": "urn:schemas-upnp-org:device:Basic:1",
+ "manufacturer": "IMEON",
+ "st": "upnp:rootdevice",
+ },
+ ],
"isy994": [
{
"deviceType": "urn:udi-com:device:X_Insteon_Lighting_Device:1",
diff --git a/homeassistant/generated/usb.py b/homeassistant/generated/usb.py
index e66a5861d18..8aea15df283 100644
--- a/homeassistant/generated/usb.py
+++ b/homeassistant/generated/usb.py
@@ -148,4 +148,11 @@ USB = [
"pid": "8A2A",
"vid": "10C4",
},
+ {
+ "description": "*nabu casa zwa-2*",
+ "domain": "zwave_js",
+ "manufacturer": "nabu casa",
+ "pid": "4001",
+ "vid": "303A",
+ },
]
diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py
index ab965e27472..cc1683a3603 100644
--- a/homeassistant/generated/zeroconf.py
+++ b/homeassistant/generated/zeroconf.py
@@ -803,6 +803,11 @@ ZEROCONF = {
"domain": "russound_rio",
},
],
+ "_shelly._tcp.local.": [
+ {
+ "domain": "shelly",
+ },
+ ],
"_sideplay._tcp.local.": [
{
"domain": "ecobee",
diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py
index 5601ce4032d..ba02ed51f6b 100644
--- a/homeassistant/helpers/area_registry.py
+++ b/homeassistant/helpers/area_registry.py
@@ -20,6 +20,7 @@ from .json import json_bytes, json_fragment
from .normalized_name_base_registry import (
NormalizedNameBaseRegistryEntry,
NormalizedNameBaseRegistryItems,
+ normalize_name,
)
from .registry import BaseRegistry, RegistryIndexType
from .singleton import singleton
@@ -169,6 +170,7 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]):
super().__init__()
self._labels_index: RegistryIndexType = defaultdict(dict)
self._floors_index: RegistryIndexType = defaultdict(dict)
+ self._aliases_index: RegistryIndexType = defaultdict(dict)
def _index_entry(self, key: str, entry: AreaEntry) -> None:
"""Index an entry."""
@@ -177,6 +179,9 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]):
self._floors_index[entry.floor_id][key] = True
for label in entry.labels:
self._labels_index[label][key] = True
+ for alias in entry.aliases:
+ normalized_alias = normalize_name(alias)
+ self._aliases_index[normalized_alias][key] = True
def _unindex_entry(
self, key: str, replacement_entry: AreaEntry | None = None
@@ -184,6 +189,10 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]):
# always call base class before other indices
super()._unindex_entry(key, replacement_entry)
entry = self.data[key]
+ if aliases := entry.aliases:
+ for alias in aliases:
+ normalized_alias = normalize_name(alias)
+ self._unindex_entry_value(key, normalized_alias, self._aliases_index)
if labels := entry.labels:
for label in labels:
self._unindex_entry_value(key, label, self._labels_index)
@@ -200,6 +209,12 @@ class AreaRegistryItems(NormalizedNameBaseRegistryItems[AreaEntry]):
data = self.data
return [data[key] for key in self._floors_index.get(floor, ())]
+ def get_areas_for_alias(self, alias: str) -> list[AreaEntry]:
+ """Get areas for alias."""
+ data = self.data
+ normalized_alias = normalize_name(alias)
+ return [data[key] for key in self._aliases_index.get(normalized_alias, ())]
+
class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
"""Class to hold a registry of areas."""
@@ -232,6 +247,11 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]):
"""Get area by name."""
return self.areas.get_by_name(name)
+ @callback
+ def async_get_areas_by_alias(self, alias: str) -> list[AreaEntry]:
+ """Get areas by alias."""
+ return self.areas.get_areas_for_alias(alias)
+
@callback
def async_list_areas(self) -> Iterable[AreaEntry]:
"""Get all areas."""
diff --git a/homeassistant/helpers/backup.py b/homeassistant/helpers/backup.py
new file mode 100644
index 00000000000..b3607f6653c
--- /dev/null
+++ b/homeassistant/helpers/backup.py
@@ -0,0 +1,94 @@
+"""Helpers for the backup integration."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.util.hass_dict import HassKey
+
+if TYPE_CHECKING:
+ from homeassistant.components.backup import (
+ BackupManager,
+ BackupPlatformEvent,
+ ManagerStateEvent,
+ )
+
+DATA_BACKUP: HassKey[BackupData] = HassKey("backup_data")
+DATA_MANAGER: HassKey[BackupManager] = HassKey("backup")
+
+
+@dataclass(slots=True)
+class BackupData:
+ """Backup data stored in hass.data."""
+
+ backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = field(
+ default_factory=list
+ )
+ backup_platform_event_subscriptions: list[Callable[[BackupPlatformEvent], None]] = (
+ field(default_factory=list)
+ )
+ manager_ready: asyncio.Future[None] = field(default_factory=asyncio.Future)
+
+
+@callback
+def async_initialize_backup(hass: HomeAssistant) -> None:
+ """Initialize backup data.
+
+ This creates the BackupData instance stored in hass.data[DATA_BACKUP] and
+ registers the basic backup websocket API which is used by frontend to subscribe
+ to backup events.
+ """
+ # pylint: disable-next=import-outside-toplevel
+ from homeassistant.components.backup import basic_websocket
+
+ hass.data[DATA_BACKUP] = BackupData()
+ basic_websocket.async_register_websocket_handlers(hass)
+
+
+async def async_get_manager(hass: HomeAssistant) -> BackupManager:
+ """Get the backup manager instance.
+
+ Raises HomeAssistantError if the backup integration is not available.
+ """
+ if DATA_BACKUP not in hass.data:
+ raise HomeAssistantError("Backup integration is not available")
+
+ await hass.data[DATA_BACKUP].manager_ready
+ return hass.data[DATA_MANAGER]
+
+
+@callback
+def async_subscribe_events(
+ hass: HomeAssistant,
+ on_event: Callable[[ManagerStateEvent], None],
+) -> Callable[[], None]:
+ """Subscribe to backup events."""
+ backup_event_subscriptions = hass.data[DATA_BACKUP].backup_event_subscriptions
+
+ def remove_subscription() -> None:
+ backup_event_subscriptions.remove(on_event)
+
+ backup_event_subscriptions.append(on_event)
+ return remove_subscription
+
+
+@callback
+def async_subscribe_platform_events(
+ hass: HomeAssistant,
+ on_event: Callable[[BackupPlatformEvent], None],
+) -> Callable[[], None]:
+ """Subscribe to backup platform events."""
+ backup_platform_event_subscriptions = hass.data[
+ DATA_BACKUP
+ ].backup_platform_event_subscriptions
+
+ def remove_subscription() -> None:
+ backup_platform_event_subscriptions.remove(on_event)
+
+ backup_platform_event_subscriptions.append(on_event)
+ return remove_subscription
diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py
index 0841585e1a1..836536da9ee 100644
--- a/homeassistant/helpers/check_config.py
+++ b/homeassistant/helpers/check_config.py
@@ -8,6 +8,7 @@ import os
from pathlib import Path
from typing import NamedTuple, Self
+from annotatedyaml import loader as yaml_loader
import voluptuous as vol
from homeassistant import loader
@@ -29,7 +30,6 @@ from homeassistant.requirements import (
async_clear_install_history,
async_get_integration_with_requirements,
)
-from homeassistant.util.yaml import loader as yaml_loader
from . import config_validation as cv
from .typing import ConfigType
diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py
index 24a9de5b562..1cff90031c2 100644
--- a/homeassistant/helpers/config_entry_oauth2_flow.py
+++ b/homeassistant/helpers/config_entry_oauth2_flow.py
@@ -11,7 +11,9 @@ from __future__ import annotations
from abc import ABC, ABCMeta, abstractmethod
import asyncio
from asyncio import Lock
+import base64
from collections.abc import Awaitable, Callable
+import hashlib
from http import HTTPStatus
from json import JSONDecodeError
import logging
@@ -25,11 +27,11 @@ import voluptuous as vol
from yarl import URL
from homeassistant import config_entries
-from homeassistant.components import http
from homeassistant.core import HomeAssistant, callback
from homeassistant.loader import async_get_application_credentials
from homeassistant.util.hass_dict import HassKey
+from . import http
from .aiohttp_client import async_get_clientsession
from .network import NoURLAvailableError
@@ -166,6 +168,11 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
"""Extra data that needs to be appended to the authorize url."""
return {}
+ @property
+ def extra_token_resolve_data(self) -> dict:
+ """Extra data for the token resolve request."""
+ return {}
+
async def async_generate_authorize_url(self, flow_id: str) -> str:
"""Generate a url for the user to authorize."""
redirect_uri = self.redirect_uri
@@ -186,13 +193,13 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
async def async_resolve_external_data(self, external_data: Any) -> dict:
"""Resolve the authorization code to tokens."""
- return await self._token_request(
- {
- "grant_type": "authorization_code",
- "code": external_data["code"],
- "redirect_uri": external_data["state"]["redirect_uri"],
- }
- )
+ request_data: dict = {
+ "grant_type": "authorization_code",
+ "code": external_data["code"],
+ "redirect_uri": external_data["state"]["redirect_uri"],
+ }
+ request_data.update(self.extra_token_resolve_data)
+ return await self._token_request(request_data)
async def _async_refresh_token(self, token: dict) -> dict:
"""Refresh tokens."""
@@ -211,7 +218,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
data["client_id"] = self.client_id
- if self.client_secret is not None:
+ if self.client_secret:
data["client_secret"] = self.client_secret
_LOGGER.debug("Sending token request to %s", self.token_url)
@@ -233,6 +240,100 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
return cast(dict, await resp.json())
+class LocalOAuth2ImplementationWithPkce(LocalOAuth2Implementation):
+ """Local OAuth2 implementation with PKCE."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ domain: str,
+ client_id: str,
+ authorize_url: str,
+ token_url: str,
+ client_secret: str = "",
+ code_verifier_length: int = 128,
+ ) -> None:
+ """Initialize local auth implementation."""
+ super().__init__(
+ hass,
+ domain,
+ client_id,
+ client_secret,
+ authorize_url,
+ token_url,
+ )
+
+ # Generate code verifier
+ self.code_verifier = LocalOAuth2ImplementationWithPkce.generate_code_verifier(
+ code_verifier_length
+ )
+
+ @property
+ def extra_authorize_data(self) -> dict:
+ """Extra data that needs to be appended to the authorize url.
+
+ If you want to override this method,
+ calling super is mandatory (for adding scopes):
+ ```
+ @def extra_authorize_data(self) -> dict:
+ data: dict = {
+ "scope": "openid profile email",
+ }
+ data.update(super().extra_authorize_data)
+ return data
+ ```
+ """
+ return {
+ "code_challenge": LocalOAuth2ImplementationWithPkce.compute_code_challenge(
+ self.code_verifier
+ ),
+ "code_challenge_method": "S256",
+ }
+
+ @property
+ def extra_token_resolve_data(self) -> dict:
+ """Extra data that needs to be included in the token resolve request.
+
+ If you want to override this method,
+ calling super is mandatory (for adding `someKey`):
+ ```
+ @def extra_token_resolve_data(self) -> dict:
+ data: dict = {
+ "someKey": "someValue",
+ }
+ data.update(super().extra_token_resolve_data)
+ return data
+ ```
+ """
+
+ return {"code_verifier": self.code_verifier}
+
+ @staticmethod
+ def generate_code_verifier(code_verifier_length: int = 128) -> str:
+ """Generate a code verifier."""
+ if not 43 <= code_verifier_length <= 128:
+ msg = (
+ "Parameter `code_verifier_length` must validate"
+ "`43 <= code_verifier_length <= 128`."
+ )
+ raise ValueError(msg)
+ return secrets.token_urlsafe(96)[:code_verifier_length]
+
+ @staticmethod
+ def compute_code_challenge(code_verifier: str) -> str:
+ """Compute the code challenge."""
+ if not 43 <= len(code_verifier) <= 128:
+ msg = (
+ "Parameter `code_verifier` must validate "
+ "`43 <= len(code_verifier) <= 128`."
+ )
+ raise ValueError(msg)
+
+ hashed = hashlib.sha256(code_verifier.encode("ascii")).digest()
+ encoded = base64.urlsafe_b64encode(hashed)
+ return encoded.decode("ascii").replace("=", "")
+
+
class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
"""Handle a config flow."""
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index 4978158c0f6..5c1a7c99565 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -1153,41 +1153,6 @@ def _custom_serializer(schema: Any, *, allow_section: bool) -> Any:
return voluptuous_serialize.UNSUPPORTED
-def expand_condition_shorthand(value: Any | None) -> Any:
- """Expand boolean condition shorthand notations."""
-
- if not isinstance(value, dict) or CONF_CONDITIONS in value:
- return value
-
- for key, schema in (
- ("and", AND_CONDITION_SHORTHAND_SCHEMA),
- ("or", OR_CONDITION_SHORTHAND_SCHEMA),
- ("not", NOT_CONDITION_SHORTHAND_SCHEMA),
- ):
- try:
- schema(value)
- return {
- CONF_CONDITION: key,
- CONF_CONDITIONS: value[key],
- **{k: value[k] for k in value if k != key},
- }
- except vol.MultipleInvalid:
- pass
-
- if isinstance(value.get(CONF_CONDITION), list):
- try:
- CONDITION_SHORTHAND_SCHEMA(value)
- return {
- CONF_CONDITION: "and",
- CONF_CONDITIONS: value[CONF_CONDITION],
- **{k: value[k] for k in value if k != CONF_CONDITION},
- }
- except vol.MultipleInvalid:
- pass
-
- return value
-
-
# Schemas
def empty_config_schema(domain: str) -> Callable[[dict], dict]:
"""Return a config schema which logs if there are configuration parameters."""
@@ -1683,7 +1648,43 @@ DEVICE_CONDITION_BASE_SCHEMA = vol.Schema(
DEVICE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA)
-dynamic_template_condition_action = vol.All(
+
+def expand_condition_shorthand(value: Any | None) -> Any:
+ """Expand boolean condition shorthand notations."""
+
+ if not isinstance(value, dict) or CONF_CONDITIONS in value:
+ return value
+
+ for key, schema in (
+ ("and", AND_CONDITION_SHORTHAND_SCHEMA),
+ ("or", OR_CONDITION_SHORTHAND_SCHEMA),
+ ("not", NOT_CONDITION_SHORTHAND_SCHEMA),
+ ):
+ try:
+ schema(value)
+ return {
+ CONF_CONDITION: key,
+ CONF_CONDITIONS: value[key],
+ **{k: value[k] for k in value if k != key},
+ }
+ except vol.MultipleInvalid:
+ pass
+
+ if isinstance(value.get(CONF_CONDITION), list):
+ try:
+ CONDITION_SHORTHAND_SCHEMA(value)
+ return {
+ CONF_CONDITION: "and",
+ CONF_CONDITIONS: value[CONF_CONDITION],
+ **{k: value[k] for k in value if k != CONF_CONDITION},
+ }
+ except vol.MultipleInvalid:
+ pass
+
+ return value
+
+
+dynamic_template_condition = vol.All(
# Wrap a shorthand template condition in a template condition
dynamic_template,
lambda config: {
@@ -1724,7 +1725,7 @@ CONDITION_SCHEMA: vol.Schema = vol.Schema(
},
),
),
- dynamic_template_condition_action,
+ dynamic_template_condition,
)
)
@@ -1873,12 +1874,8 @@ _SCRIPT_REPEAT_SCHEMA = vol.Schema(
vol.Exclusive(CONF_FOR_EACH, "repeat"): vol.Any(
dynamic_template, vol.All(list, template_complex)
),
- vol.Exclusive(CONF_WHILE, "repeat"): vol.All(
- ensure_list, [CONDITION_SCHEMA]
- ),
- vol.Exclusive(CONF_UNTIL, "repeat"): vol.All(
- ensure_list, [CONDITION_SCHEMA]
- ),
+ vol.Exclusive(CONF_WHILE, "repeat"): CONDITIONS_SCHEMA,
+ vol.Exclusive(CONF_UNTIL, "repeat"): CONDITIONS_SCHEMA,
vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA,
},
has_at_least_one_key(CONF_COUNT, CONF_FOR_EACH, CONF_WHILE, CONF_UNTIL),
@@ -1894,9 +1891,7 @@ _SCRIPT_CHOOSE_SCHEMA = vol.Schema(
[
{
vol.Optional(CONF_ALIAS): string,
- vol.Required(CONF_CONDITIONS): vol.All(
- ensure_list, [CONDITION_SCHEMA]
- ),
+ vol.Required(CONF_CONDITIONS): CONDITIONS_SCHEMA,
vol.Required(CONF_SEQUENCE): SCRIPT_SCHEMA,
}
],
@@ -1917,7 +1912,7 @@ _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA = vol.Schema(
_SCRIPT_IF_SCHEMA = vol.Schema(
{
**SCRIPT_ACTION_BASE_SCHEMA,
- vol.Required(CONF_IF): vol.All(ensure_list, [CONDITION_SCHEMA]),
+ vol.Required(CONF_IF): CONDITIONS_SCHEMA,
vol.Required(CONF_THEN): SCRIPT_SCHEMA,
vol.Optional(CONF_ELSE): SCRIPT_SCHEMA,
}
diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py
index b15d8b9e607..65eb2786aaf 100644
--- a/homeassistant/helpers/data_entry_flow.py
+++ b/homeassistant/helpers/data_entry_flow.py
@@ -17,7 +17,7 @@ from . import config_validation as cv
_FlowManagerT = TypeVar(
"_FlowManagerT",
- bound=data_entry_flow.FlowManager[Any, Any],
+ bound=data_entry_flow.FlowManager[Any, Any, Any],
default=data_entry_flow.FlowManager,
)
@@ -70,7 +70,7 @@ class FlowManagerIndexView(_BaseFlowManagerView[_FlowManagerT]):
async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response:
"""Initialize a POST request.
- Override `_post_impl` in subclasses which need
+ Override `post` and call `_post_impl` in subclasses which need
to implement their own `RequestDataValidator`
"""
return await self._post_impl(request, data)
diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py
index f02c6507d02..101b9731caf 100644
--- a/homeassistant/helpers/deprecation.py
+++ b/homeassistant/helpers/deprecation.py
@@ -244,35 +244,35 @@ def _print_deprecation_warning_internal_impl(
)
-class DeprecatedConstant(NamedTuple):
+class DeprecatedConstant[T](NamedTuple):
"""Deprecated constant."""
- value: Any
+ value: T
replacement: str
breaks_in_ha_version: str | None
-class DeprecatedConstantEnum(NamedTuple):
+class DeprecatedConstantEnum[T: (StrEnum | IntEnum | IntFlag)](NamedTuple):
"""Deprecated constant."""
- enum: StrEnum | IntEnum | IntFlag
+ enum: T
breaks_in_ha_version: str | None
-class DeprecatedAlias(NamedTuple):
+class DeprecatedAlias[T](NamedTuple):
"""Deprecated alias."""
- value: Any
+ value: T
replacement: str
breaks_in_ha_version: str | None
-class DeferredDeprecatedAlias:
+class DeferredDeprecatedAlias[T]:
"""Deprecated alias with deferred evaluation of the value."""
def __init__(
self,
- value_fn: Callable[[], Any],
+ value_fn: Callable[[], T],
replacement: str,
breaks_in_ha_version: str | None,
) -> None:
@@ -282,7 +282,7 @@ class DeferredDeprecatedAlias:
self._value_fn = value_fn
@functools.cached_property
- def value(self) -> Any:
+ def value(self) -> T:
"""Return the value."""
return self._value_fn()
@@ -369,7 +369,7 @@ class EnumWithDeprecatedMembers(EnumType):
"""Enum with deprecated members."""
def __new__(
- mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass
+ mcs,
cls: str,
bases: tuple[type, ...],
classdict: _EnumDict,
diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py
index 92101dd0e21..79d6774c407 100644
--- a/homeassistant/helpers/device_registry.py
+++ b/homeassistant/helpers/device_registry.py
@@ -56,7 +56,7 @@ EVENT_DEVICE_REGISTRY_UPDATED: EventType[EventDeviceRegistryUpdatedData] = Event
)
STORAGE_KEY = "core.device_registry"
STORAGE_VERSION_MAJOR = 1
-STORAGE_VERSION_MINOR = 8
+STORAGE_VERSION_MINOR = 9
CLEANUP_DELAY = 10
@@ -272,6 +272,7 @@ class DeviceEntry:
area_id: str | None = attr.ib(default=None)
config_entries: set[str] = attr.ib(converter=set, factory=set)
+ config_entries_subentries: dict[str, set[str | None]] = attr.ib(factory=dict)
configuration_url: str | None = attr.ib(default=None)
connections: set[tuple[str, str]] = attr.ib(converter=set, factory=set)
created_at: datetime = attr.ib(factory=utcnow)
@@ -311,6 +312,10 @@ class DeviceEntry:
"area_id": self.area_id,
"configuration_url": self.configuration_url,
"config_entries": list(self.config_entries),
+ "config_entries_subentries": {
+ config_entry_id: list(subentries)
+ for config_entry_id, subentries in self.config_entries_subentries.items()
+ },
"connections": list(self.connections),
"created_at": self.created_at.timestamp(),
"disabled_by": self.disabled_by,
@@ -354,7 +359,13 @@ class DeviceEntry:
json_bytes(
{
"area_id": self.area_id,
+ # The config_entries list can be removed from the storage
+ # representation in HA Core 2026.2
"config_entries": list(self.config_entries),
+ "config_entries_subentries": {
+ config_entry_id: list(subentries)
+ for config_entry_id, subentries in self.config_entries_subentries.items()
+ },
"configuration_url": self.configuration_url,
"connections": list(self.connections),
"created_at": self.created_at,
@@ -384,6 +395,7 @@ class DeletedDeviceEntry:
"""Deleted Device Registry Entry."""
config_entries: set[str] = attr.ib()
+ config_entries_subentries: dict[str, set[str | None]] = attr.ib()
connections: set[tuple[str, str]] = attr.ib()
identifiers: set[tuple[str, str]] = attr.ib()
id: str = attr.ib()
@@ -395,6 +407,7 @@ class DeletedDeviceEntry:
def to_device_entry(
self,
config_entry_id: str,
+ config_subentry_id: str | None,
connections: set[tuple[str, str]],
identifiers: set[tuple[str, str]],
) -> DeviceEntry:
@@ -402,6 +415,7 @@ class DeletedDeviceEntry:
return DeviceEntry(
# type ignores: likely https://github.com/python/mypy/issues/8625
config_entries={config_entry_id}, # type: ignore[arg-type]
+ config_entries_subentries={config_entry_id: {config_subentry_id}},
connections=self.connections & connections, # type: ignore[arg-type]
created_at=self.created_at,
identifiers=self.identifiers & identifiers, # type: ignore[arg-type]
@@ -415,7 +429,13 @@ class DeletedDeviceEntry:
return json_fragment(
json_bytes(
{
+ # The config_entries list can be removed from the storage
+ # representation in HA Core 2026.2
"config_entries": list(self.config_entries),
+ "config_entries_subentries": {
+ config_entry_id: list(subentries)
+ for config_entry_id, subentries in self.config_entries_subentries.items()
+ },
"connections": list(self.connections),
"created_at": self.created_at,
"identifiers": list(self.identifiers),
@@ -458,7 +478,10 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
old_data: dict[str, list[dict[str, Any]]],
) -> dict[str, Any]:
"""Migrate to the new version."""
- if old_major_version < 2:
+ # Support for a future major version bump to 2 added in HA Core 2025.2.
+ # Major versions 1 and 2 will be the same, except that version 2 will no
+ # longer store a list of config_entries.
+ if old_major_version < 3:
if old_minor_version < 2:
# Version 1.2 implements migration and freezes the available keys,
# populate keys which were introduced before version 1.2
@@ -505,8 +528,20 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
device["created_at"] = device["modified_at"] = created_at
for device in old_data["deleted_devices"]:
device["created_at"] = device["modified_at"] = created_at
+ if old_minor_version < 9:
+ # Introduced in 2025.2
+ for device in old_data["devices"]:
+ device["config_entries_subentries"] = {
+ config_entry_id: {None}
+ for config_entry_id in device["config_entries"]
+ }
+ for device in old_data["deleted_devices"]:
+ device["config_entries_subentries"] = {
+ config_entry_id: {None}
+ for config_entry_id in device["config_entries"]
+ }
- if old_major_version > 1:
+ if old_major_version > 2:
raise NotImplementedError
return old_data
@@ -546,8 +581,8 @@ class DeviceRegistryItems[_EntryTypeT: (DeviceEntry, DeletedDeviceEntry)](
def get_entry(
self,
- identifiers: set[tuple[str, str]] | None,
- connections: set[tuple[str, str]] | None,
+ identifiers: set[tuple[str, str]] | None = None,
+ connections: set[tuple[str, str]] | None = None,
) -> _EntryTypeT | None:
"""Get entry from identifiers or connections."""
if identifiers:
@@ -674,22 +709,6 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
"""Check if device is registered."""
return self.devices.get_entry(identifiers, connections)
- def _async_get_deleted_device(
- self,
- identifiers: set[tuple[str, str]],
- connections: set[tuple[str, str]],
- ) -> DeletedDeviceEntry | None:
- """Check if device is deleted."""
- return self.deleted_devices.get_entry(identifiers, connections)
-
- def _async_get_deleted_devices(
- self,
- identifiers: set[tuple[str, str]] | None = None,
- connections: set[tuple[str, str]] | None = None,
- ) -> Iterable[DeletedDeviceEntry]:
- """List devices that are deleted."""
- return self.deleted_devices.get_entries(identifiers, connections)
-
def _substitute_name_placeholders(
self,
domain: str,
@@ -722,6 +741,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
self,
*,
config_entry_id: str,
+ config_subentry_id: str | None | UndefinedType = UNDEFINED,
configuration_url: str | URL | None | UndefinedType = UNDEFINED,
connections: set[tuple[str, str]] | None | UndefinedType = UNDEFINED,
created_at: str | datetime | UndefinedType = UNDEFINED, # will be ignored
@@ -803,16 +823,22 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
else:
connections = _normalize_connections(connections)
- device = self.async_get_device(identifiers=identifiers, connections=connections)
+ device = self.devices.get_entry(
+ identifiers=identifiers, connections=connections
+ )
if device is None:
- deleted_device = self._async_get_deleted_device(identifiers, connections)
+ deleted_device = self.deleted_devices.get_entry(identifiers, connections)
if deleted_device is None:
device = DeviceEntry(is_new=True)
else:
self.deleted_devices.pop(deleted_device.id)
device = deleted_device.to_device_entry(
- config_entry_id, connections, identifiers
+ config_entry_id,
+ # Interpret not specifying a subentry as None
+ config_subentry_id if config_subentry_id is not UNDEFINED else None,
+ connections,
+ identifiers,
)
self.devices[device.id] = device
# If creating a new device, default to the config entry name
@@ -829,7 +855,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
name = default_name
if via_device is not None and via_device is not UNDEFINED:
- if (via := self.async_get_device(identifiers={via_device})) is None:
+ if (via := self.devices.get_entry(identifiers={via_device})) is None:
report_usage(
"calls `device_registry.async_get_or_create` referencing a "
f"non existing `via_device` {via_device}, "
@@ -846,6 +872,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
device.id,
allow_collisions=True,
add_config_entry_id=config_entry_id,
+ add_config_subentry_id=config_subentry_id,
configuration_url=configuration_url,
device_info_type=device_info_type,
disabled_by=disabled_by,
@@ -874,6 +901,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
device_id: str,
*,
add_config_entry_id: str | UndefinedType = UNDEFINED,
+ add_config_subentry_id: str | None | UndefinedType = UNDEFINED,
# Temporary flag so we don't blow up when collisions are implicitly introduced
# by calls to async_get_or_create. Must not be set by integrations.
allow_collisions: bool = False,
@@ -894,25 +922,58 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
new_connections: set[tuple[str, str]] | UndefinedType = UNDEFINED,
new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED,
remove_config_entry_id: str | UndefinedType = UNDEFINED,
+ remove_config_subentry_id: str | None | UndefinedType = UNDEFINED,
serial_number: str | None | UndefinedType = UNDEFINED,
suggested_area: str | None | UndefinedType = UNDEFINED,
sw_version: str | None | UndefinedType = UNDEFINED,
via_device_id: str | None | UndefinedType = UNDEFINED,
) -> DeviceEntry | None:
- """Update device attributes."""
+ """Update device attributes.
+
+ :param add_config_subentry_id: Add the device to a specific subentry of add_config_entry_id
+ :param remove_config_subentry_id: Remove the device from a specific subentry of remove_config_subentry_id
+ """
old = self.devices[device_id]
new_values: dict[str, Any] = {} # Dict with new key/value pairs
old_values: dict[str, Any] = {} # Dict with old key/value pairs
config_entries = old.config_entries
+ config_entries_subentries = old.config_entries_subentries
if add_config_entry_id is not UNDEFINED:
- if self.hass.config_entries.async_get_entry(add_config_entry_id) is None:
+ if (
+ add_config_entry := self.hass.config_entries.async_get_entry(
+ add_config_entry_id
+ )
+ ) is None:
raise HomeAssistantError(
f"Can't link device to unknown config entry {add_config_entry_id}"
)
+ if add_config_subentry_id is not UNDEFINED:
+ if add_config_entry_id is UNDEFINED:
+ raise HomeAssistantError(
+ "Can't add config subentry without specifying config entry"
+ )
+ if (
+ add_config_subentry_id
+ # mypy says add_config_entry can be None. That's impossible, because we
+ # raise above if that happens
+ and add_config_subentry_id not in add_config_entry.subentries # type: ignore[union-attr]
+ ):
+ raise HomeAssistantError(
+ f"Config entry {add_config_entry_id} has no subentry {add_config_subentry_id}"
+ )
+
+ if (
+ remove_config_subentry_id is not UNDEFINED
+ and remove_config_entry_id is UNDEFINED
+ ):
+ raise HomeAssistantError(
+ "Can't remove config subentry without specifying config entry"
+ )
+
if not new_connections and not new_identifiers:
raise HomeAssistantError(
"A device must have at least one of identifiers or connections"
@@ -943,6 +1004,10 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
area_id = area.id
if add_config_entry_id is not UNDEFINED:
+ if add_config_subentry_id is UNDEFINED:
+ # Interpret not specifying a subentry as None (the main entry)
+ add_config_subentry_id = None
+
primary_entry_id = old.primary_config_entry
if (
device_info_type == "primary"
@@ -962,25 +1027,59 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if add_config_entry_id not in old.config_entries:
config_entries = old.config_entries | {add_config_entry_id}
+ config_entries_subentries = old.config_entries_subentries | {
+ add_config_entry_id: {add_config_subentry_id}
+ }
+ elif (
+ add_config_subentry_id
+ not in old.config_entries_subentries[add_config_entry_id]
+ ):
+ config_entries_subentries = old.config_entries_subentries | {
+ add_config_entry_id: old.config_entries_subentries[
+ add_config_entry_id
+ ]
+ | {add_config_subentry_id}
+ }
if (
remove_config_entry_id is not UNDEFINED
and remove_config_entry_id in config_entries
):
- if config_entries == {remove_config_entry_id}:
- self.async_remove_device(device_id)
- return None
+ if remove_config_subentry_id is UNDEFINED:
+ config_entries_subentries = dict(old.config_entries_subentries)
+ del config_entries_subentries[remove_config_entry_id]
+ elif (
+ remove_config_subentry_id
+ in old.config_entries_subentries[remove_config_entry_id]
+ ):
+ config_entries_subentries = old.config_entries_subentries | {
+ remove_config_entry_id: old.config_entries_subentries[
+ remove_config_entry_id
+ ]
+ - {remove_config_subentry_id}
+ }
+ if not config_entries_subentries[remove_config_entry_id]:
+ del config_entries_subentries[remove_config_entry_id]
- if remove_config_entry_id == old.primary_config_entry:
- new_values["primary_config_entry"] = None
- old_values["primary_config_entry"] = old.primary_config_entry
+ if remove_config_entry_id not in config_entries_subentries:
+ if config_entries == {remove_config_entry_id}:
+ self.async_remove_device(device_id)
+ return None
- config_entries = config_entries - {remove_config_entry_id}
+ if remove_config_entry_id == old.primary_config_entry:
+ new_values["primary_config_entry"] = None
+ old_values["primary_config_entry"] = old.primary_config_entry
+
+ config_entries = config_entries - {remove_config_entry_id}
if config_entries != old.config_entries:
new_values["config_entries"] = config_entries
old_values["config_entries"] = old.config_entries
+ if config_entries_subentries != old.config_entries_subentries:
+ new_values["config_entries_subentries"] = config_entries_subentries
+ old_values["config_entries_subentries"] = old.config_entries_subentries
+
added_connections: set[tuple[str, str]] | None = None
added_identifiers: set[tuple[str, str]] | None = None
@@ -1059,7 +1158,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
# NOTE: Once we solve the broader issue of duplicated devices, we might
# want to revisit it. Instead of simply removing the duplicated deleted device,
# we might want to merge the information from it into the non-deleted device.
- for deleted_device in self._async_get_deleted_devices(
+ for deleted_device in self.deleted_devices.get_entries(
added_identifiers, added_connections
):
del self.deleted_devices[deleted_device.id]
@@ -1101,7 +1200,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
# conflict, the index will only see the last one and we will not
# be able to tell which one caused the conflict
if (
- existing_device := self.async_get_device(connections={connection})
+ existing_device := self.devices.get_entry(connections={connection})
) and existing_device.id != device_id:
raise DeviceConnectionCollisionError(
normalized_connections, existing_device
@@ -1125,7 +1224,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
# conflict, the index will only see the last one and we will not
# be able to tell which one caused the conflict
if (
- existing_device := self.async_get_device(identifiers={identifier})
+ existing_device := self.devices.get_entry(identifiers={identifier})
) and existing_device.id != device_id:
raise DeviceIdentifierCollisionError(identifiers, existing_device)
@@ -1138,6 +1237,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
device = self.devices.pop(device_id)
self.deleted_devices[device_id] = DeletedDeviceEntry(
config_entries=device.config_entries,
+ config_entries_subentries=device.config_entries_subentries,
connections=device.connections,
created_at=device.created_at,
identifiers=device.identifiers,
@@ -1168,7 +1268,13 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
for device in data["devices"]:
devices[device["id"]] = DeviceEntry(
area_id=device["area_id"],
- config_entries=set(device["config_entries"]),
+ config_entries=set(device["config_entries_subentries"]),
+ config_entries_subentries={
+ config_entry_id: set(subentries)
+ for config_entry_id, subentries in device[
+ "config_entries_subentries"
+ ].items()
+ },
configuration_url=device["configuration_url"],
# type ignores (if tuple arg was cast): likely https://github.com/python/mypy/issues/8625
connections={
@@ -1208,6 +1314,12 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
for device in data["deleted_devices"]:
deleted_devices[device["id"]] = DeletedDeviceEntry(
config_entries=set(device["config_entries"]),
+ config_entries_subentries={
+ config_entry_id: set(subentries)
+ for config_entry_id, subentries in device[
+ "config_entries_subentries"
+ ].items()
+ },
connections={tuple(conn) for conn in device["connections"]},
created_at=datetime.fromisoformat(device["created_at"]),
identifiers={tuple(iden) for iden in device["identifiers"]},
@@ -1243,14 +1355,70 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]):
if config_entries == {config_entry_id}:
# Add a time stamp when the deleted device became orphaned
self.deleted_devices[deleted_device.id] = attr.evolve(
- deleted_device, orphaned_timestamp=now_time, config_entries=set()
+ deleted_device,
+ orphaned_timestamp=now_time,
+ config_entries=set(),
+ config_entries_subentries={},
)
else:
config_entries = config_entries - {config_entry_id}
+ config_entries_subentries = dict(
+ deleted_device.config_entries_subentries
+ )
+ del config_entries_subentries[config_entry_id]
# No need to reindex here since we currently
# do not have a lookup by config entry
self.deleted_devices[deleted_device.id] = attr.evolve(
- deleted_device, config_entries=config_entries
+ deleted_device,
+ config_entries=config_entries,
+ config_entries_subentries=config_entries_subentries,
+ )
+ self.async_schedule_save()
+
+ @callback
+ def async_clear_config_subentry(
+ self, config_entry_id: str, config_subentry_id: str
+ ) -> None:
+ """Clear config entry from registry entries."""
+ now_time = time.time()
+ now_time = time.time()
+ for device in self.devices.get_devices_for_config_entry_id(config_entry_id):
+ self.async_update_device(
+ device.id,
+ remove_config_entry_id=config_entry_id,
+ remove_config_subentry_id=config_subentry_id,
+ )
+ for deleted_device in list(self.deleted_devices.values()):
+ config_entries = deleted_device.config_entries
+ config_entries_subentries = deleted_device.config_entries_subentries
+ if (
+ config_entry_id not in config_entries_subentries
+ or config_subentry_id not in config_entries_subentries[config_entry_id]
+ ):
+ continue
+ if config_entries_subentries == {config_entry_id: {config_subentry_id}}:
+ # We're removing the last config subentry from the last config
+ # entry, add a time stamp when the deleted device became orphaned
+ self.deleted_devices[deleted_device.id] = attr.evolve(
+ deleted_device,
+ orphaned_timestamp=now_time,
+ config_entries=set(),
+ config_entries_subentries={},
+ )
+ else:
+ config_entries_subentries = config_entries_subentries | {
+ config_entry_id: config_entries_subentries[config_entry_id]
+ - {config_subentry_id}
+ }
+ if not config_entries_subentries[config_entry_id]:
+ del config_entries_subentries[config_entry_id]
+ config_entries = config_entries - {config_entry_id}
+ # No need to reindex here since we currently
+ # do not have a lookup by config entry
+ self.deleted_devices[deleted_device.id] = attr.evolve(
+ deleted_device,
+ config_entries=config_entries,
+ config_entries_subentries=config_entries_subentries,
)
self.async_schedule_save()
diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py
index 2b9f2d7069e..bdcda58c054 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -281,7 +281,7 @@ class CachedProperties(type):
"""
def __new__(
- mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass
+ mcs,
name: str,
bases: tuple[type, ...],
namespace: dict[Any, Any],
@@ -1085,9 +1085,9 @@ class Entity(
state = self._stringify_state(available)
if available:
if state_attributes := self.state_attributes:
- attr.update(state_attributes)
+ attr |= state_attributes
if extra_state_attributes := self.extra_state_attributes:
- attr.update(extra_state_attributes)
+ attr |= extra_state_attributes
if (unit_of_measurement := self.unit_of_measurement) is not None:
attr[ATTR_UNIT_OF_MEASUREMENT] = unit_of_measurement
@@ -1214,7 +1214,7 @@ class Entity(
else:
# Overwrite properties that have been set in the config file.
if custom := customize.get(entity_id):
- attr.update(custom)
+ attr |= custom
if (
self._context_set is not None
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index c8cc6979226..2ca331a185b 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -80,6 +80,22 @@ class AddEntitiesCallback(Protocol):
"""Define add_entities type."""
+class AddConfigEntryEntitiesCallback(Protocol):
+ """Protocol type for EntityPlatform.add_entities callback."""
+
+ def __call__(
+ self,
+ new_entities: Iterable[Entity],
+ update_before_add: bool = False,
+ *,
+ config_subentry_id: str | None = None,
+ ) -> None:
+ """Define add_entities type.
+
+ :param config_subentry_id: subentry which the entities should be added to
+ """
+
+
class EntityPlatformModule(Protocol):
"""Protocol type for entity platform modules."""
@@ -105,7 +121,7 @@ class EntityPlatformModule(Protocol):
self,
hass: HomeAssistant,
entry: config_entries.ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up an integration platform from a config entry."""
@@ -517,13 +533,21 @@ class EntityPlatform:
@callback
def _async_schedule_add_entities_for_entry(
- self, new_entities: Iterable[Entity], update_before_add: bool = False
+ self,
+ new_entities: Iterable[Entity],
+ update_before_add: bool = False,
+ *,
+ config_subentry_id: str | None = None,
) -> None:
"""Schedule adding entities for a single platform async and track the task."""
assert self.config_entry
task = self.config_entry.async_create_task(
self.hass,
- self.async_add_entities(new_entities, update_before_add=update_before_add),
+ self.async_add_entities(
+ new_entities,
+ update_before_add=update_before_add,
+ config_subentry_id=config_subentry_id,
+ ),
f"EntityPlatform async_add_entities_for_entry {self.domain}.{self.platform_name}",
eager_start=True,
)
@@ -549,9 +573,9 @@ class EntityPlatform:
async def _async_add_and_update_entities(
self,
- coros: list[Coroutine[Any, Any, None]],
entities: list[Entity],
timeout: float,
+ config_subentry_id: str | None,
) -> None:
"""Add entities for a single platform and update them.
@@ -561,10 +585,21 @@ class EntityPlatform:
event loop and will finish faster if we run them concurrently.
"""
results: list[BaseException | None] | None = None
- tasks = [create_eager_task(coro, loop=self.hass.loop) for coro in coros]
+ entity_registry = ent_reg.async_get(self.hass)
try:
async with self.hass.timeout.async_timeout(timeout, self.domain):
- results = await asyncio.gather(*tasks, return_exceptions=True)
+ results = await asyncio.gather(
+ *(
+ create_eager_task(
+ self._async_add_entity(
+ entity, True, entity_registry, config_subentry_id
+ ),
+ loop=self.hass.loop,
+ )
+ for entity in entities
+ ),
+ return_exceptions=True,
+ )
except TimeoutError:
self.logger.warning(
"Timed out adding entities for domain %s with platform %s after %ds",
@@ -591,9 +626,9 @@ class EntityPlatform:
async def _async_add_entities(
self,
- coros: list[Coroutine[Any, Any, None]],
entities: list[Entity],
timeout: float,
+ config_subentry_id: str | None,
) -> None:
"""Add entities for a single platform without updating.
@@ -602,13 +637,15 @@ class EntityPlatform:
to the event loop so we can await the coros directly without
scheduling them as tasks.
"""
+ entity_registry = ent_reg.async_get(self.hass)
try:
async with self.hass.timeout.async_timeout(timeout, self.domain):
- for idx, coro in enumerate(coros):
+ for entity in entities:
try:
- await coro
+ await self._async_add_entity(
+ entity, False, entity_registry, config_subentry_id
+ )
except Exception as ex:
- entity = entities[idx]
self.logger.exception(
"Error adding entity %s for domain %s with platform %s",
entity.entity_id,
@@ -625,37 +662,41 @@ class EntityPlatform:
)
async def async_add_entities(
- self, new_entities: Iterable[Entity], update_before_add: bool = False
+ self,
+ new_entities: Iterable[Entity],
+ update_before_add: bool = False,
+ *,
+ config_subentry_id: str | None = None,
) -> None:
"""Add entities for a single platform async.
This method must be run in the event loop.
+
+ :param config_subentry_id: subentry which the entities should be added to
"""
- # handle empty list from component/platform
- if not new_entities: # type: ignore[truthy-iterable]
- return
-
- hass = self.hass
- entity_registry = ent_reg.async_get(hass)
- coros: list[Coroutine[Any, Any, None]] = []
- entities: list[Entity] = []
- for entity in new_entities:
- coros.append(
- self._async_add_entity(entity, update_before_add, entity_registry)
+ if config_subentry_id and (
+ not self.config_entry
+ or config_subentry_id not in self.config_entry.subentries
+ ):
+ raise HomeAssistantError(
+ f"Can't add entities to unknown subentry {config_subentry_id} of config "
+ f"entry {self.config_entry.entry_id if self.config_entry else None}"
)
- entities.append(entity)
- # No entities for processing
- if not coros:
+ entities: list[Entity] = (
+ new_entities if type(new_entities) is list else list(new_entities)
+ )
+ # handle empty list from component/platform
+ if not entities:
return
- timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(coros), SLOW_ADD_MIN_TIMEOUT)
+ timeout = max(SLOW_ADD_ENTITY_MAX_WAIT * len(entities), SLOW_ADD_MIN_TIMEOUT)
if update_before_add:
- add_func = self._async_add_and_update_entities
+ await self._async_add_and_update_entities(
+ entities, timeout, config_subentry_id
+ )
else:
- add_func = self._async_add_entities
-
- await add_func(coros, entities, timeout)
+ await self._async_add_entities(entities, timeout, config_subentry_id)
if (
(self.config_entry and self.config_entry.pref_disable_polling)
@@ -720,6 +761,7 @@ class EntityPlatform:
entity: Entity,
update_before_add: bool,
entity_registry: EntityRegistry,
+ config_subentry_id: str | None,
) -> None:
"""Add an entity to the platform."""
if entity is None:
@@ -779,6 +821,7 @@ class EntityPlatform:
try:
device = dev_reg.async_get(self.hass).async_get_or_create(
config_entry_id=self.config_entry.entry_id,
+ config_subentry_id=config_subentry_id,
**device_info,
)
except dev_reg.DeviceInfoError as exc:
@@ -825,6 +868,7 @@ class EntityPlatform:
entity.unique_id,
capabilities=entity.capability_attributes,
config_entry=self.config_entry,
+ config_subentry_id=config_subentry_id,
device_id=device.id if device else None,
disabled_by=disabled_by,
entity_category=entity.entity_category,
diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py
index 95a32696228..684d00fe344 100644
--- a/homeassistant/helpers/entity_registry.py
+++ b/homeassistant/helpers/entity_registry.py
@@ -79,7 +79,7 @@ EVENT_ENTITY_REGISTRY_UPDATED: EventType[EventEntityRegistryUpdatedData] = Event
_LOGGER = logging.getLogger(__name__)
STORAGE_VERSION_MAJOR = 1
-STORAGE_VERSION_MINOR = 15
+STORAGE_VERSION_MINOR = 16
STORAGE_KEY = "core.entity_registry"
CLEANUP_INTERVAL = 3600 * 24
@@ -177,6 +177,7 @@ class RegistryEntry:
categories: dict[str, str] = attr.ib(factory=dict)
capabilities: Mapping[str, Any] | None = attr.ib(default=None)
config_entry_id: str | None = attr.ib(default=None)
+ config_subentry_id: str | None = attr.ib(default=None)
created_at: datetime = attr.ib(factory=utcnow)
device_class: str | None = attr.ib(default=None)
device_id: str | None = attr.ib(default=None)
@@ -280,6 +281,7 @@ class RegistryEntry:
"area_id": self.area_id,
"categories": self.categories,
"config_entry_id": self.config_entry_id,
+ "config_subentry_id": self.config_subentry_id,
"created_at": self.created_at.timestamp(),
"device_id": self.device_id,
"disabled_by": self.disabled_by,
@@ -341,6 +343,7 @@ class RegistryEntry:
"categories": self.categories,
"capabilities": self.capabilities,
"config_entry_id": self.config_entry_id,
+ "config_subentry_id": self.config_subentry_id,
"created_at": self.created_at,
"device_class": self.device_class,
"device_id": self.device_id,
@@ -405,6 +408,7 @@ class DeletedRegistryEntry:
unique_id: str = attr.ib()
platform: str = attr.ib()
config_entry_id: str | None = attr.ib()
+ config_subentry_id: str | None = attr.ib()
domain: str = attr.ib(init=False, repr=False)
id: str = attr.ib()
orphaned_timestamp: float | None = attr.ib()
@@ -424,6 +428,7 @@ class DeletedRegistryEntry:
json_bytes(
{
"config_entry_id": self.config_entry_id,
+ "config_subentry_id": self.config_subentry_id,
"created_at": self.created_at,
"entity_id": self.entity_id,
"id": self.id,
@@ -539,6 +544,13 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]):
for entity in data["deleted_entities"]:
entity["created_at"] = entity["modified_at"] = created_at
+ if old_minor_version < 16:
+ # Version 1.16 adds config_subentry_id
+ for entity in data["entities"]:
+ entity["config_subentry_id"] = None
+ for entity in data["deleted_entities"]:
+ entity["config_subentry_id"] = None
+
if old_major_version > 1:
raise NotImplementedError
return data
@@ -647,10 +659,12 @@ def _validate_item(
platform: str,
*,
config_entry_id: str | None | UndefinedType = None,
+ config_subentry_id: str | None | UndefinedType = None,
device_id: str | None | UndefinedType = None,
disabled_by: RegistryEntryDisabler | None | UndefinedType = None,
entity_category: EntityCategory | None | UndefinedType = None,
hidden_by: RegistryEntryHider | None | UndefinedType = None,
+ old_config_subentry_id: str | None = None,
report_non_string_unique_id: bool = True,
unique_id: str | Hashable | UndefinedType | Any,
) -> None:
@@ -676,6 +690,26 @@ def _validate_item(
raise ValueError(
f"Can't link entity to unknown config entry {config_entry_id}"
)
+ if (
+ config_entry_id
+ and config_entry_id is not UNDEFINED
+ and old_config_subentry_id
+ and config_subentry_id is UNDEFINED
+ ):
+ raise ValueError("Can't change config entry without changing subentry")
+ if (
+ config_entry_id
+ and config_entry_id is not UNDEFINED
+ and config_subentry_id
+ and config_subentry_id is not UNDEFINED
+ ):
+ if (
+ not (config_entry := hass.config_entries.async_get_entry(config_entry_id))
+ or config_subentry_id not in config_entry.subentries
+ ):
+ raise ValueError(
+ f"Config entry {config_entry_id} has no subentry {config_subentry_id}"
+ )
if device_id and device_id is not UNDEFINED:
device_registry = dr.async_get(hass)
if not device_registry.async_get(device_id):
@@ -826,6 +860,7 @@ class EntityRegistry(BaseRegistry):
# Data that we want entry to have
capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED,
config_entry: ConfigEntry | None | UndefinedType = UNDEFINED,
+ config_subentry_id: str | None | UndefinedType = UNDEFINED,
device_id: str | None | UndefinedType = UNDEFINED,
entity_category: EntityCategory | UndefinedType | None = UNDEFINED,
has_entity_name: bool | UndefinedType = UNDEFINED,
@@ -852,6 +887,7 @@ class EntityRegistry(BaseRegistry):
entity_id,
capabilities=capabilities,
config_entry_id=config_entry_id,
+ config_subentry_id=config_subentry_id,
device_id=device_id,
entity_category=entity_category,
has_entity_name=has_entity_name,
@@ -869,6 +905,7 @@ class EntityRegistry(BaseRegistry):
domain,
platform,
config_entry_id=config_entry_id,
+ config_subentry_id=config_subentry_id,
device_id=device_id,
disabled_by=disabled_by,
entity_category=entity_category,
@@ -907,6 +944,7 @@ class EntityRegistry(BaseRegistry):
entry = RegistryEntry(
capabilities=none_if_undefined(capabilities),
config_entry_id=none_if_undefined(config_entry_id),
+ config_subentry_id=none_if_undefined(config_subentry_id),
created_at=created_at,
device_id=none_if_undefined(device_id),
disabled_by=disabled_by,
@@ -949,6 +987,7 @@ class EntityRegistry(BaseRegistry):
orphaned_timestamp = None if config_entry_id else time.time()
self.deleted_entities[key] = DeletedRegistryEntry(
config_entry_id=config_entry_id,
+ config_subentry_id=entity.config_subentry_id,
created_at=entity.created_at,
entity_id=entity_id,
id=entity.id,
@@ -1008,6 +1047,20 @@ class EntityRegistry(BaseRegistry):
):
self.async_remove(entity.entity_id)
+ # Remove entities which belong to config subentries no longer associated with the
+ # device
+ entities = async_entries_for_device(
+ self, event.data["device_id"], include_disabled_entities=True
+ )
+ for entity in entities:
+ if (
+ (config_entry_id := entity.config_entry_id) is not None
+ and config_entry_id in device.config_entries
+ and entity.config_subentry_id
+ not in device.config_entries_subentries[config_entry_id]
+ ):
+ self.async_remove(entity.entity_id)
+
# Re-enable disabled entities if the device is no longer disabled
if not device.disabled:
entities = async_entries_for_device(
@@ -1041,6 +1094,7 @@ class EntityRegistry(BaseRegistry):
categories: dict[str, str] | UndefinedType = UNDEFINED,
capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED,
config_entry_id: str | None | UndefinedType = UNDEFINED,
+ config_subentry_id: str | None | UndefinedType = UNDEFINED,
device_class: str | None | UndefinedType = UNDEFINED,
device_id: str | None | UndefinedType = UNDEFINED,
disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED,
@@ -1073,6 +1127,7 @@ class EntityRegistry(BaseRegistry):
("categories", categories),
("capabilities", capabilities),
("config_entry_id", config_entry_id),
+ ("config_subentry_id", config_subentry_id),
("device_class", device_class),
("device_id", device_id),
("disabled_by", disabled_by),
@@ -1102,10 +1157,12 @@ class EntityRegistry(BaseRegistry):
old.domain,
old.platform,
config_entry_id=config_entry_id,
+ config_subentry_id=config_subentry_id,
device_id=device_id,
disabled_by=disabled_by,
entity_category=entity_category,
hidden_by=hidden_by,
+ old_config_subentry_id=old.config_subentry_id,
unique_id=new_unique_id,
)
@@ -1170,6 +1227,7 @@ class EntityRegistry(BaseRegistry):
categories: dict[str, str] | UndefinedType = UNDEFINED,
capabilities: Mapping[str, Any] | None | UndefinedType = UNDEFINED,
config_entry_id: str | None | UndefinedType = UNDEFINED,
+ config_subentry_id: str | None | UndefinedType = UNDEFINED,
device_class: str | None | UndefinedType = UNDEFINED,
device_id: str | None | UndefinedType = UNDEFINED,
disabled_by: RegistryEntryDisabler | None | UndefinedType = UNDEFINED,
@@ -1196,6 +1254,7 @@ class EntityRegistry(BaseRegistry):
categories=categories,
capabilities=capabilities,
config_entry_id=config_entry_id,
+ config_subentry_id=config_subentry_id,
device_class=device_class,
device_id=device_id,
disabled_by=disabled_by,
@@ -1222,6 +1281,7 @@ class EntityRegistry(BaseRegistry):
new_platform: str,
*,
new_config_entry_id: str | UndefinedType = UNDEFINED,
+ new_config_subentry_id: str | UndefinedType = UNDEFINED,
new_unique_id: str | UndefinedType = UNDEFINED,
new_device_id: str | None | UndefinedType = UNDEFINED,
) -> RegistryEntry:
@@ -1246,6 +1306,7 @@ class EntityRegistry(BaseRegistry):
entity_id,
new_unique_id=new_unique_id,
config_entry_id=new_config_entry_id,
+ config_subentry_id=new_config_subentry_id,
device_id=new_device_id,
platform=new_platform,
)
@@ -1308,6 +1369,7 @@ class EntityRegistry(BaseRegistry):
categories=entity["categories"],
capabilities=entity["capabilities"],
config_entry_id=entity["config_entry_id"],
+ config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]),
device_class=entity["device_class"],
device_id=entity["device_id"],
@@ -1357,6 +1419,7 @@ class EntityRegistry(BaseRegistry):
)
deleted_entities[key] = DeletedRegistryEntry(
config_entry_id=entity["config_entry_id"],
+ config_subentry_id=entity["config_subentry_id"],
created_at=datetime.fromisoformat(entity["created_at"]),
entity_id=entity["entity_id"],
id=entity["id"],
@@ -1415,6 +1478,30 @@ class EntityRegistry(BaseRegistry):
)
self.async_schedule_save()
+ @callback
+ def async_clear_config_subentry(
+ self, config_entry_id: str, config_subentry_id: str
+ ) -> None:
+ """Clear config subentry from registry entries."""
+ now_time = time.time()
+ for entity_id in [
+ entry.entity_id
+ for entry in self.entities.get_entries_for_config_entry_id(config_entry_id)
+ if entry.config_subentry_id == config_subentry_id
+ ]:
+ self.async_remove(entity_id)
+ for key, deleted_entity in list(self.deleted_entities.items()):
+ if config_subentry_id != deleted_entity.config_subentry_id:
+ continue
+ # Add a time stamp when the deleted entity became orphaned
+ self.deleted_entities[key] = attr.evolve(
+ deleted_entity,
+ orphaned_timestamp=now_time,
+ config_entry_id=None,
+ config_subentry_id=None,
+ )
+ self.async_schedule_save()
+
@callback
def async_purge_expired_orphaned_entities(self) -> None:
"""Purge expired orphaned entities from the registry.
diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py
index b363bc21e86..baf1f144a3f 100644
--- a/homeassistant/helpers/event.py
+++ b/homeassistant/helpers/event.py
@@ -551,6 +551,12 @@ def async_track_entity_registry_updated_event(
)
+@callback
+def async_has_entity_registry_updated_listeners(hass: HomeAssistant) -> bool:
+ """Check if async_track_entity_registry_updated_event has been called yet."""
+ return _KEYED_TRACK_ENTITY_REGISTRY_UPDATED.key in hass.data
+
+
@callback
def _async_device_registry_updated_filter(
hass: HomeAssistant,
diff --git a/homeassistant/helpers/floor_registry.py b/homeassistant/helpers/floor_registry.py
index fcfca8e3212..186ad2b31f7 100644
--- a/homeassistant/helpers/floor_registry.py
+++ b/homeassistant/helpers/floor_registry.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from collections import defaultdict
from collections.abc import Iterable
import dataclasses
from dataclasses import dataclass
@@ -16,8 +17,9 @@ from homeassistant.util.hass_dict import HassKey
from .normalized_name_base_registry import (
NormalizedNameBaseRegistryEntry,
NormalizedNameBaseRegistryItems,
+ normalize_name,
)
-from .registry import BaseRegistry
+from .registry import BaseRegistry, RegistryIndexType
from .singleton import singleton
from .storage import Store
from .typing import UNDEFINED, UndefinedType
@@ -92,10 +94,43 @@ class FloorRegistryStore(Store[FloorRegistryStoreData]):
return old_data # type: ignore[return-value]
+class FloorRegistryItems(NormalizedNameBaseRegistryItems[FloorEntry]):
+ """Class to hold floor registry items."""
+
+ def __init__(self) -> None:
+ """Initialize the floor registry items."""
+ super().__init__()
+ self._aliases_index: RegistryIndexType = defaultdict(dict)
+
+ def _index_entry(self, key: str, entry: FloorEntry) -> None:
+ """Index an entry."""
+ super()._index_entry(key, entry)
+ for alias in entry.aliases:
+ normalized_alias = normalize_name(alias)
+ self._aliases_index[normalized_alias][key] = True
+
+ def _unindex_entry(
+ self, key: str, replacement_entry: FloorEntry | None = None
+ ) -> None:
+ # always call base class before other indices
+ super()._unindex_entry(key, replacement_entry)
+ entry = self.data[key]
+ if aliases := entry.aliases:
+ for alias in aliases:
+ normalized_alias = normalize_name(alias)
+ self._unindex_entry_value(key, normalized_alias, self._aliases_index)
+
+ def get_floors_for_alias(self, alias: str) -> list[FloorEntry]:
+ """Get floors for alias."""
+ data = self.data
+ normalized_alias = normalize_name(alias)
+ return [data[key] for key in self._aliases_index.get(normalized_alias, ())]
+
+
class FloorRegistry(BaseRegistry[FloorRegistryStoreData]):
"""Class to hold a registry of floors."""
- floors: NormalizedNameBaseRegistryItems[FloorEntry]
+ floors: FloorRegistryItems
_floor_data: dict[str, FloorEntry]
def __init__(self, hass: HomeAssistant) -> None:
@@ -123,6 +158,11 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]):
"""Get floor by name."""
return self.floors.get_by_name(name)
+ @callback
+ def async_get_floors_by_alias(self, alias: str) -> list[FloorEntry]:
+ """Get floors by alias."""
+ return self.floors.get_floors_for_alias(alias)
+
@callback
def async_list_floors(self) -> Iterable[FloorEntry]:
"""Get all floors."""
@@ -226,7 +266,7 @@ class FloorRegistry(BaseRegistry[FloorRegistryStoreData]):
async def async_load(self) -> None:
"""Load the floor registry."""
data = await self._store.async_load()
- floors = NormalizedNameBaseRegistryItems[FloorEntry]()
+ floors = FloorRegistryItems()
if data is not None:
for floor in data["floors"]:
diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py
index f33f8407e47..ca7b097d90d 100644
--- a/homeassistant/helpers/frame.py
+++ b/homeassistant/helpers/frame.py
@@ -10,18 +10,20 @@ import functools
import linecache
import logging
import sys
+import threading
from types import FrameType
from typing import Any, cast
from propcache.api import cached_property
-from homeassistant.core import HomeAssistant, async_get_hass_or_none
+from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.loader import (
Integration,
async_get_issue_integration,
async_suggest_report_issue,
)
+from homeassistant.util.async_ import run_callback_threadsafe
_LOGGER = logging.getLogger(__name__)
@@ -29,6 +31,21 @@ _LOGGER = logging.getLogger(__name__)
_REPORTED_INTEGRATIONS: set[str] = set()
+class _Hass:
+ """Container which makes a HomeAssistant instance available to frame helper."""
+
+ hass: HomeAssistant | None = None
+
+
+_hass = _Hass()
+
+
+@callback
+def async_setup(hass: HomeAssistant) -> None:
+ """Set up the frame helper."""
+ _hass.hass = hass
+
+
@dataclass(kw_only=True)
class IntegrationFrame:
"""Integration frame container."""
@@ -133,44 +150,6 @@ class MissingIntegrationFrame(HomeAssistantError):
"""Raised when no integration is found in the frame."""
-def report(
- what: str,
- *,
- exclude_integrations: set[str] | None = None,
- error_if_core: bool = True,
- error_if_integration: bool = False,
- level: int = logging.WARNING,
- log_custom_component_only: bool = False,
-) -> None:
- """Report incorrect usage.
-
- If error_if_core is True, raise instead of log if an integration is not found
- when unwinding the stack frame.
- If error_if_integration is True, raise instead of log if an integration is found
- when unwinding the stack frame.
- """
- core_behavior = ReportBehavior.ERROR if error_if_core else ReportBehavior.LOG
- core_integration_behavior = (
- ReportBehavior.ERROR if error_if_integration else ReportBehavior.LOG
- )
- custom_integration_behavior = core_integration_behavior
-
- if log_custom_component_only:
- if core_behavior is ReportBehavior.LOG:
- core_behavior = ReportBehavior.IGNORE
- if core_integration_behavior is ReportBehavior.LOG:
- core_integration_behavior = ReportBehavior.IGNORE
-
- report_usage(
- what,
- core_behavior=core_behavior,
- core_integration_behavior=core_integration_behavior,
- custom_integration_behavior=custom_integration_behavior,
- exclude_integrations=exclude_integrations,
- level=level,
- )
-
-
class ReportBehavior(enum.Enum):
"""Enum for behavior on code usage."""
@@ -201,18 +180,49 @@ def report_usage(
breaking version
:param exclude_integrations: skip specified integration when reviewing the stack.
If no integration is found, the core behavior will be applied
- :param integration_domain: fallback for identifying the integration if the
- frame is not found
+ :param integration_domain: domain of the integration causing the issue. If None, the
+ stack frame will be searched to identify the integration causing the issue.
"""
- try:
- integration_frame = get_integration_frame(
- exclude_integrations=exclude_integrations
- )
- except MissingIntegrationFrame as err:
- if integration := async_get_issue_integration(
- hass := async_get_hass_or_none(), integration_domain
- ):
- _report_integration_domain(
+ if (hass := _hass.hass) is None:
+ raise RuntimeError("Frame helper not set up")
+ _report_usage_partial = functools.partial(
+ _report_usage,
+ hass,
+ what,
+ breaks_in_ha_version=breaks_in_ha_version,
+ core_behavior=core_behavior,
+ core_integration_behavior=core_integration_behavior,
+ custom_integration_behavior=custom_integration_behavior,
+ exclude_integrations=exclude_integrations,
+ integration_domain=integration_domain,
+ level=level,
+ )
+ if hass.loop_thread_id != threading.get_ident():
+ future = run_callback_threadsafe(hass.loop, _report_usage_partial)
+ future.result()
+ return
+ _report_usage_partial()
+
+
+def _report_usage(
+ hass: HomeAssistant,
+ what: str,
+ *,
+ breaks_in_ha_version: str | None,
+ core_behavior: ReportBehavior,
+ core_integration_behavior: ReportBehavior,
+ custom_integration_behavior: ReportBehavior,
+ exclude_integrations: set[str] | None,
+ integration_domain: str | None,
+ level: int,
+) -> None:
+ """Report incorrect code usage.
+
+ Must be called from the event loop.
+ """
+ if integration_domain:
+ if integration := async_get_issue_integration(hass, integration_domain):
+ _report_usage_integration_domain(
hass,
what,
breaks_in_ha_version,
@@ -222,16 +232,15 @@ def report_usage(
level,
)
return
- msg = f"Detected code that {what}. Please report this issue"
- if core_behavior is ReportBehavior.ERROR:
- raise RuntimeError(msg) from err
- if core_behavior is ReportBehavior.LOG:
- if breaks_in_ha_version:
- msg = (
- f"Detected code that {what}. This will stop working in Home "
- f"Assistant {breaks_in_ha_version}, please report this issue"
- )
- _LOGGER.warning(msg, stack_info=True)
+ _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, None)
+ return
+
+ try:
+ integration_frame = get_integration_frame(
+ exclude_integrations=exclude_integrations
+ )
+ except MissingIntegrationFrame as err:
+ _report_usage_no_integration(what, core_behavior, breaks_in_ha_version, err)
return
integration_behavior = core_integration_behavior
@@ -239,7 +248,8 @@ def report_usage(
integration_behavior = custom_integration_behavior
if integration_behavior is not ReportBehavior.IGNORE:
- _report_integration_frame(
+ _report_usage_integration_frame(
+ hass,
what,
breaks_in_ha_version,
integration_frame,
@@ -248,7 +258,7 @@ def report_usage(
)
-def _report_integration_domain(
+def _report_usage_integration_domain(
hass: HomeAssistant | None,
what: str,
breaks_in_ha_version: str | None,
@@ -298,7 +308,8 @@ def _report_integration_domain(
)
-def _report_integration_frame(
+def _report_usage_integration_frame(
+ hass: HomeAssistant,
what: str,
breaks_in_ha_version: str | None,
integration_frame: IntegrationFrame,
@@ -316,7 +327,7 @@ def _report_integration_frame(
_REPORTED_INTEGRATIONS.add(key)
report_issue = async_suggest_report_issue(
- async_get_hass_or_none(),
+ hass,
integration_domain=integration_frame.integration,
module=integration_frame.module,
)
@@ -346,31 +357,55 @@ def _report_integration_frame(
)
+def _report_usage_no_integration(
+ what: str,
+ core_behavior: ReportBehavior,
+ breaks_in_ha_version: str | None,
+ err: MissingIntegrationFrame | None,
+) -> None:
+ """Report incorrect usage without an integration.
+
+ This could happen because the offending call happened outside of an integration,
+ or because the integration could not be identified.
+ """
+ msg = f"Detected code that {what}. Please report this issue"
+ if core_behavior is ReportBehavior.ERROR:
+ raise RuntimeError(msg) from err
+ if core_behavior is ReportBehavior.LOG:
+ if breaks_in_ha_version:
+ msg = (
+ f"Detected code that {what}. This will stop working in Home "
+ f"Assistant {breaks_in_ha_version}, please report this issue"
+ )
+ _LOGGER.warning(msg, stack_info=True)
+
+
def warn_use[_CallableT: Callable](func: _CallableT, what: str) -> _CallableT:
"""Mock a function to warn when it was about to be used."""
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def report_use(*args: Any, **kwargs: Any) -> None:
- report(what)
+ report_usage(what)
else:
@functools.wraps(func)
def report_use(*args: Any, **kwargs: Any) -> None:
- report(what)
+ report_usage(what)
return cast(_CallableT, report_use)
def report_non_thread_safe_operation(what: str) -> None:
"""Report a non-thread safe operation."""
- report(
+ report_usage(
f"calls {what} from a thread other than the event loop, "
"which may cause Home Assistant to crash or data to corrupt. "
"For more information, see "
"https://developers.home-assistant.io/docs/asyncio_thread_safety/"
f"#{what.replace('.', '')}",
- error_if_core=True,
- error_if_integration=True,
+ core_behavior=ReportBehavior.ERROR,
+ core_integration_behavior=ReportBehavior.ERROR,
+ custom_integration_behavior=ReportBehavior.ERROR,
)
diff --git a/homeassistant/helpers/httpx_client.py b/homeassistant/helpers/httpx_client.py
index ade2ce747d5..49b12e0aa60 100644
--- a/homeassistant/helpers/httpx_client.py
+++ b/homeassistant/helpers/httpx_client.py
@@ -7,6 +7,9 @@ import sys
from types import TracebackType
from typing import Any, Self
+# httpx dynamically imports httpcore, so we need to import it
+# to avoid it being imported later when the event loop is running
+import httpcore # noqa: F401
import httpx
from homeassistant.const import APPLICATION_NAME, EVENT_HOMEASSISTANT_CLOSE, __version__
diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py
index c93545ed414..75572194bb8 100644
--- a/homeassistant/helpers/intent.py
+++ b/homeassistant/helpers/intent.py
@@ -38,7 +38,7 @@ from .typing import VolSchemaType
_LOGGER = logging.getLogger(__name__)
type _SlotsType = dict[str, Any]
type _IntentSlotsType = dict[
- str | tuple[str, str], VolSchemaType | Callable[[Any], Any]
+ str | tuple[str, str], IntentSlotInfo | VolSchemaType | Callable[[Any], Any]
]
INTENT_TURN_OFF = "HassTurnOff"
@@ -59,6 +59,7 @@ INTENT_GET_CURRENT_DATE = "HassGetCurrentDate"
INTENT_GET_CURRENT_TIME = "HassGetCurrentTime"
INTENT_RESPOND = "HassRespond"
INTENT_BROADCAST = "HassBroadcast"
+INTENT_GET_TEMPERATURE = "HassClimateGetTemperature"
SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA)
@@ -506,12 +507,22 @@ def _add_areas(
candidate.area = areas.async_get_area(candidate.device.area_id)
+def _default_area_candidate_filter(
+ candidate: MatchTargetsCandidate, possible_area_ids: Collection[str]
+) -> bool:
+ """Keep candidates in the possible areas."""
+ return (candidate.area is not None) and (candidate.area.id in possible_area_ids)
+
+
@callback
def async_match_targets( # noqa: C901
hass: HomeAssistant,
constraints: MatchTargetsConstraints,
preferences: MatchTargetsPreferences | None = None,
states: list[State] | None = None,
+ area_candidate_filter: Callable[
+ [MatchTargetsCandidate, Collection[str]], bool
+ ] = _default_area_candidate_filter,
) -> MatchTargetsResult:
"""Match entities based on constraints in order to handle an intent."""
preferences = preferences or MatchTargetsPreferences()
@@ -622,9 +633,7 @@ def async_match_targets( # noqa: C901
}
candidates = [
- c
- for c in candidates
- if (c.area is not None) and (c.area.id in possible_area_ids)
+ c for c in candidates if area_candidate_filter(c, possible_area_ids)
]
if not candidates:
return MatchTargetsResult(
@@ -648,9 +657,7 @@ def async_match_targets( # noqa: C901
# May be constrained by floors above
possible_area_ids.intersection_update(matching_area_ids)
candidates = [
- c
- for c in candidates
- if (c.area is not None) and (c.area.id in possible_area_ids)
+ c for c in candidates if area_candidate_filter(c, possible_area_ids)
]
if not candidates:
return MatchTargetsResult(
@@ -700,7 +707,7 @@ def async_match_targets( # noqa: C901
group_candidates = [
c
for c in group_candidates
- if (c.area is not None) and (c.area.id == preferences.area_id)
+ if area_candidate_filter(c, {preferences.area_id})
]
if len(group_candidates) < 2:
# Disambiguated by area
@@ -746,7 +753,7 @@ def async_match_targets( # noqa: C901
if preferences.area_id:
# Filter by area
filtered_candidates = [
- c for c in candidates if c.area and (c.area.id == preferences.area_id)
+ c for c in candidates if area_candidate_filter(c, {preferences.area_id})
]
if (len(filtered_candidates) > 1) and preferences.floor_id:
@@ -867,6 +874,34 @@ def non_empty_string(value: Any) -> str:
return value_str
+@dataclass(kw_only=True)
+class IntentSlotInfo:
+ """Details about how intent slots are processed and validated."""
+
+ service_data_name: str | None = None
+ """Optional name of the service data input to map to this slot."""
+
+ description: str | None = None
+ """Human readable description of the slot."""
+
+ value_schema: VolSchemaType | Callable[[Any], Any] = vol.Any
+ """Validator for the slot."""
+
+
+def _convert_slot_info(
+ key: str | tuple[str, str],
+ value: IntentSlotInfo | VolSchemaType | Callable[[Any], Any],
+) -> tuple[str, IntentSlotInfo]:
+ """Create an IntentSlotInfo from the various supported input arguments."""
+ if isinstance(value, IntentSlotInfo):
+ if not isinstance(key, str):
+ raise TypeError("Tuple key and IntentSlotDescription value not supported")
+ return key, value
+ if isinstance(key, tuple):
+ return key[0], IntentSlotInfo(service_data_name=key[1], value_schema=value)
+ return key, IntentSlotInfo(value_schema=value)
+
+
class DynamicServiceIntentHandler(IntentHandler):
"""Service Intent handler registration (dynamic).
@@ -900,23 +935,14 @@ class DynamicServiceIntentHandler(IntentHandler):
self.platforms = platforms
self.device_classes = device_classes
- self.required_slots: _IntentSlotsType = {}
- if required_slots:
- for key, value_schema in required_slots.items():
- if isinstance(key, str):
- # Slot name/service data key
- key = (key, key)
-
- self.required_slots[key] = value_schema
-
- self.optional_slots: _IntentSlotsType = {}
- if optional_slots:
- for key, value_schema in optional_slots.items():
- if isinstance(key, str):
- # Slot name/service data key
- key = (key, key)
-
- self.optional_slots[key] = value_schema
+ self.required_slots: dict[str, IntentSlotInfo] = dict(
+ _convert_slot_info(key, value)
+ for key, value in (required_slots or {}).items()
+ )
+ self.optional_slots: dict[str, IntentSlotInfo] = dict(
+ _convert_slot_info(key, value)
+ for key, value in (optional_slots or {}).items()
+ )
@cached_property
def slot_schema(self) -> dict:
@@ -957,16 +983,20 @@ class DynamicServiceIntentHandler(IntentHandler):
if self.required_slots:
slot_schema.update(
{
- vol.Required(key[0]): validator
- for key, validator in self.required_slots.items()
+ vol.Required(
+ key, description=slot_info.description
+ ): slot_info.value_schema
+ for key, slot_info in self.required_slots.items()
}
)
if self.optional_slots:
slot_schema.update(
{
- vol.Optional(key[0]): validator
- for key, validator in self.optional_slots.items()
+ vol.Optional(
+ key, description=slot_info.description
+ ): slot_info.value_schema
+ for key, slot_info in self.optional_slots.items()
}
)
@@ -1149,18 +1179,15 @@ class DynamicServiceIntentHandler(IntentHandler):
service_data: dict[str, Any] = {ATTR_ENTITY_ID: state.entity_id}
if self.required_slots:
- service_data.update(
- {
- key[1]: intent_obj.slots[key[0]]["value"]
- for key in self.required_slots
- }
- )
+ for key, slot_info in self.required_slots.items():
+ service_data[slot_info.service_data_name or key] = intent_obj.slots[
+ key
+ ]["value"]
if self.optional_slots:
- for key in self.optional_slots:
- value = intent_obj.slots.get(key[0])
- if value:
- service_data[key[1]] = value["value"]
+ for key, slot_info in self.optional_slots.items():
+ if value := intent_obj.slots.get(key):
+ service_data[slot_info.service_data_name or key] = value["value"]
await self._run_then_background(
hass.async_create_task_internal(
diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py
index 2ef785e7f71..3e521aa7ef1 100644
--- a/homeassistant/helpers/llm.py
+++ b/homeassistant/helpers/llm.py
@@ -9,6 +9,7 @@ from datetime import timedelta
from decimal import Decimal
from enum import Enum
from functools import cache, partial
+from operator import attrgetter
from typing import Any, cast
import slugify as unicode_slug
@@ -19,7 +20,6 @@ from homeassistant.components.calendar import (
DOMAIN as CALENDAR_DOMAIN,
SERVICE_GET_EVENTS,
)
-from homeassistant.components.climate import INTENT_GET_TEMPERATURE
from homeassistant.components.cover import INTENT_CLOSE_COVER, INTENT_OPEN_COVER
from homeassistant.components.homeassistant import async_should_expose
from homeassistant.components.intent import async_device_supports_timers
@@ -67,6 +67,24 @@ Answer questions about the world truthfully.
Answer in plain text. Keep it simple and to the point.
"""
+NO_ENTITIES_PROMPT = (
+ "Only if the user wants to control a device, tell them to expose entities "
+ "to their voice assistant in Home Assistant."
+)
+
+DYNAMIC_CONTEXT_PROMPT = """You ARE equipped to answer questions about the current state of
+the home using the `GetLiveContext` tool. This is a primary function. Do not state you lack the
+functionality if the question requires live data.
+If the user asks about device existence/type (e.g., "Do I have lights in the bedroom?"): Answer
+from the static context below.
+If the user asks about the CURRENT state, value, or mode (e.g., "Is the lock locked?",
+"Is the fan on?", "What mode is the thermostat in?", "What is the temperature outside?"):
+ 1. Recognize this requires live data.
+ 2. You MUST call `GetLiveContext`. This tool will provide the needed real-time information (like temperature from the local weather, lock status, etc.).
+ 3. Use the tool's response** to answer the user accurately (e.g., "The temperature outside is [value from tool].").
+For general knowledge questions not about the home: Answer truthfully from internal knowledge.
+"""
+
@callback
def async_render_no_api_prompt(hass: HomeAssistant) -> str:
@@ -105,15 +123,29 @@ def async_register_api(hass: HomeAssistant, api: API) -> Callable[[], None]:
async def async_get_api(
- hass: HomeAssistant, api_id: str, llm_context: LLMContext
+ hass: HomeAssistant, api_id: str | list[str], llm_context: LLMContext
) -> APIInstance:
- """Get an API."""
+ """Get an API.
+
+ This returns a single APIInstance for one or more API ids, merging into
+ a single instance of necessary.
+ """
apis = _async_get_apis(hass)
- if api_id not in apis:
- raise HomeAssistantError(f"API {api_id} not found")
+ if isinstance(api_id, str):
+ api_id = [api_id]
- return await apis[api_id].async_get_api_instance(llm_context)
+ for key in api_id:
+ if key not in apis:
+ raise HomeAssistantError(f"API {key} not found")
+
+ api: API
+ if len(api_id) == 1:
+ api = apis[api_id[0]]
+ else:
+ api = MergedAPI([apis[key] for key in api_id])
+
+ return await api.async_get_api_instance(llm_context)
@callback
@@ -281,11 +313,107 @@ class IntentTool(Tool):
return response
+class NamespacedTool(Tool):
+ """A tool that wraps another tool, prepending a namespace.
+
+ This is used to support tools from multiple API. This tool dispatches
+ the original tool with the original non-namespaced name.
+ """
+
+ def __init__(self, namespace: str, tool: Tool) -> None:
+ """Init the class."""
+ self.namespace = namespace
+ self.name = f"{namespace}.{tool.name}"
+ self.description = tool.description
+ self.parameters = tool.parameters
+ self.tool = tool
+
+ async def async_call(
+ self, hass: HomeAssistant, tool_input: ToolInput, llm_context: LLMContext
+ ) -> JsonObjectType:
+ """Handle the intent."""
+ return await self.tool.async_call(
+ hass,
+ ToolInput(
+ tool_name=self.tool.name,
+ tool_args=tool_input.tool_args,
+ id=tool_input.id,
+ ),
+ llm_context,
+ )
+
+
+class MergedAPI(API):
+ """An API that represents a merged view of multiple APIs."""
+
+ def __init__(self, llm_apis: list[API]) -> None:
+ """Init the class."""
+ if not llm_apis:
+ raise ValueError("No APIs provided")
+ hass = llm_apis[0].hass
+ api_ids = [unicode_slug.slugify(api.id) for api in llm_apis]
+ if len(set(api_ids)) != len(api_ids):
+ raise ValueError("API IDs must be unique")
+ super().__init__(
+ hass=hass,
+ id="|".join(unicode_slug.slugify(api.id) for api in llm_apis),
+ name="Merged LLM API",
+ )
+ self.llm_apis = llm_apis
+
+ async def async_get_api_instance(self, llm_context: LLMContext) -> APIInstance:
+ """Return the instance of the API."""
+ # These usually don't do I/O and execute right away
+ llm_apis = [
+ await llm_api.async_get_api_instance(llm_context)
+ for llm_api in self.llm_apis
+ ]
+ prompt_parts = []
+ tools: list[Tool] = []
+ for api_instance in llm_apis:
+ namespace = unicode_slug.slugify(api_instance.api.name)
+ prompt_parts.append(
+ f'Follow these instructions for tools from "{namespace}":\n'
+ )
+ prompt_parts.append(api_instance.api_prompt)
+ prompt_parts.append("\n\n")
+ tools.extend(
+ [NamespacedTool(namespace, tool) for tool in api_instance.tools]
+ )
+
+ return APIInstance(
+ api=self,
+ api_prompt="".join(prompt_parts),
+ llm_context=llm_context,
+ tools=tools,
+ custom_serializer=self._custom_serializer(llm_apis),
+ )
+
+ def _custom_serializer(
+ self, llm_apis: list[APIInstance]
+ ) -> Callable[[Any], Any] | None:
+ serializers = [
+ api_instance.custom_serializer
+ for api_instance in llm_apis
+ if api_instance.custom_serializer is not None
+ ]
+ if not serializers:
+ return None
+
+ def merged(x: Any) -> Any:
+ for serializer in serializers:
+ if (result := serializer(x)) is not None:
+ return result
+ return x
+
+ return merged
+
+
class AssistAPI(API):
"""API exposing Assist API to LLMs."""
IGNORE_INTENTS = {
- INTENT_GET_TEMPERATURE,
+ intent.INTENT_GET_TEMPERATURE,
INTENT_GET_WEATHER,
INTENT_OPEN_COVER, # deprecated
INTENT_CLOSE_COVER, # deprecated
@@ -312,7 +440,7 @@ class AssistAPI(API):
"""Return the instance of the API."""
if llm_context.assistant:
exposed_entities: dict | None = _get_exposed_entities(
- self.hass, llm_context.assistant
+ self.hass, llm_context.assistant, include_state=False
)
else:
exposed_entities = None
@@ -330,10 +458,7 @@ class AssistAPI(API):
self, llm_context: LLMContext, exposed_entities: dict | None
) -> str:
if not exposed_entities or not exposed_entities["entities"]:
- return (
- "Only if the user wants to control a device, tell them to expose entities "
- "to their voice assistant in Home Assistant."
- )
+ return NO_ENTITIES_PROMPT
return "\n".join(
[
*self._async_get_preable(llm_context),
@@ -383,6 +508,8 @@ class AssistAPI(API):
):
prompt.append("This device is not able to start timers.")
+ prompt.append(DYNAMIC_CONTEXT_PROMPT)
+
return prompt
@callback
@@ -394,7 +521,7 @@ class AssistAPI(API):
if exposed_entities and exposed_entities["entities"]:
prompt.append(
- "An overview of the areas and the devices in this smart home:"
+ "Static Context: An overview of the areas and the devices in this smart home:"
)
prompt.append(yaml_util.dump(list(exposed_entities["entities"].values())))
@@ -455,11 +582,16 @@ class AssistAPI(API):
for script_entity_id in exposed_entities[SCRIPT_DOMAIN]
)
+ if exposed_domains:
+ tools.append(GetLiveContextTool())
+
return tools
def _get_exposed_entities(
- hass: HomeAssistant, assistant: str
+ hass: HomeAssistant,
+ assistant: str,
+ include_state: bool = True,
) -> dict[str, dict[str, dict[str, Any]]]:
"""Get exposed entities.
@@ -490,7 +622,7 @@ def _get_exposed_entities(
CALENDAR_DOMAIN: {},
}
- for state in hass.states.async_all():
+ for state in sorted(hass.states.async_all(), key=attrgetter("name")):
if not async_should_expose(hass, assistant, state.entity_id):
continue
@@ -520,22 +652,28 @@ def _get_exposed_entities(
info: dict[str, Any] = {
"names": ", ".join(names),
"domain": state.domain,
- "state": state.state,
}
+ if include_state:
+ info["state"] = state.state
+
if description:
info["description"] = description
if area_names:
info["areas"] = ", ".join(area_names)
- if attributes := {
- attr_name: str(attr_value)
- if isinstance(attr_value, (Enum, Decimal, int))
- else attr_value
- for attr_name, attr_value in state.attributes.items()
- if attr_name in interesting_attributes
- }:
+ if include_state and (
+ attributes := {
+ attr_name: (
+ str(attr_value)
+ if isinstance(attr_value, (Enum, Decimal, int))
+ else attr_value
+ )
+ for attr_name, attr_value in state.attributes.items()
+ if attr_name in interesting_attributes
+ }
+ ):
info["attributes"] = attributes
if state.domain in data:
@@ -884,3 +1022,44 @@ class CalendarGetEventsTool(Tool):
]
return {"success": True, "result": events}
+
+
+class GetLiveContextTool(Tool):
+ """Tool for getting the current state of exposed entities.
+
+ This returns state for all entities that have been exposed to
+ the assistant. This is different than the GetState intent, which
+ returns state for entities based on intent parameters.
+ """
+
+ name = "GetLiveContext"
+ description = (
+ "Use this tool when the user asks a question about the CURRENT state, "
+ "value, or mode of a specific device, sensor, entity, or area in the "
+ "smart home, and the answer can be improved with real-time data not "
+ "available in the static device overview list. "
+ )
+
+ async def async_call(
+ self,
+ hass: HomeAssistant,
+ tool_input: ToolInput,
+ llm_context: LLMContext,
+ ) -> JsonObjectType:
+ """Get the current state of exposed entities."""
+ if llm_context.assistant is None:
+ # Note this doesn't happen in practice since this tool won't be
+ # exposed if no assistant is configured.
+ return {"success": False, "error": "No assistant configured"}
+
+ exposed_entities = _get_exposed_entities(hass, llm_context.assistant)
+ if not exposed_entities["entities"]:
+ return {"success": False, "error": NO_ENTITIES_PROMPT}
+ prompt = [
+ "Live Context: An overview of the areas and the devices in this smart home:",
+ yaml_util.dump(list(exposed_entities["entities"].values())),
+ ]
+ return {
+ "success": True,
+ "result": "\n".join(prompt),
+ }
diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py
index e39cc2de547..67c4448724e 100644
--- a/homeassistant/helpers/network.py
+++ b/homeassistant/helpers/network.py
@@ -10,12 +10,12 @@ from aiohttp import hdrs
from hass_nabucasa import remote
import yarl
-from homeassistant.components import http
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.loader import bind_hass
from homeassistant.util.network import is_ip_address, is_loopback, normalize_url
+from . import http
from .hassio import is_hassio
TYPE_URL_INTERNAL = "internal_url"
diff --git a/homeassistant/helpers/recorder.py b/homeassistant/helpers/recorder.py
index 59604944eeb..7ad319419c1 100644
--- a/homeassistant/helpers/recorder.py
+++ b/homeassistant/helpers/recorder.py
@@ -20,7 +20,7 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
-DOMAIN: HassKey[RecorderData] = HassKey("recorder")
+DATA_RECORDER: HassKey[RecorderData] = HassKey("recorder")
DATA_INSTANCE: HassKey[Recorder] = HassKey("recorder_instance")
@@ -52,24 +52,19 @@ def async_migration_is_live(hass: HomeAssistant) -> bool:
@callback
def async_initialize_recorder(hass: HomeAssistant) -> None:
- """Initialize recorder data."""
+ """Initialize recorder data.
+
+ This creates the RecorderData instance stored in hass.data[DATA_RECORDER] and
+ registers the basic recorder websocket API which is used by frontend to determine
+ if the recorder is migrating the database.
+ """
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.recorder.basic_websocket_api import async_setup
- hass.data[DOMAIN] = RecorderData()
+ hass.data[DATA_RECORDER] = RecorderData()
async_setup(hass)
-async def async_wait_recorder(hass: HomeAssistant) -> bool:
- """Wait for recorder to initialize and return connection status.
-
- Returns False immediately if the recorder is not enabled.
- """
- if DOMAIN not in hass.data:
- return False
- return await hass.data[DOMAIN].db_connected
-
-
@functools.lru_cache(maxsize=1)
def get_instance(hass: HomeAssistant) -> Recorder:
"""Get the recorder instance."""
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index bd3babc8793..2b4da38b15e 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -12,7 +12,6 @@ from datetime import datetime, timedelta
from functools import partial
import itertools
import logging
-from types import MappingProxyType
from typing import Any, Literal, TypedDict, cast, overload
import async_interrupt
@@ -90,7 +89,7 @@ from . import condition, config_validation as cv, service, template
from .condition import ConditionCheckerType, trace_condition_function
from .dispatcher import async_dispatcher_connect, async_dispatcher_send_internal
from .event import async_call_later, async_track_template
-from .script_variables import ScriptVariables
+from .script_variables import ScriptRunVariables, ScriptVariables
from .template import Template
from .trace import (
TraceElement,
@@ -177,7 +176,7 @@ def _set_result_unless_done(future: asyncio.Future[None]) -> None:
future.set_result(None)
-def action_trace_append(variables: dict[str, Any], path: str) -> TraceElement:
+def action_trace_append(variables: TemplateVarsType, path: str) -> TraceElement:
"""Append a TraceElement to trace[path]."""
trace_element = TraceElement(variables, path)
trace_append_element(trace_element, ACTION_TRACE_NODE_MAX_LEN)
@@ -189,7 +188,7 @@ async def trace_action(
hass: HomeAssistant,
script_run: _ScriptRun,
stop: asyncio.Future[None],
- variables: dict[str, Any],
+ variables: TemplateVarsType,
) -> AsyncGenerator[TraceElement]:
"""Trace action execution."""
path = trace_path_get()
@@ -411,7 +410,7 @@ class _ScriptRun:
self,
hass: HomeAssistant,
script: Script,
- variables: dict[str, Any],
+ variables: ScriptRunVariables,
context: Context | None,
log_exceptions: bool,
) -> None:
@@ -430,9 +429,6 @@ class _ScriptRun:
if not self._stop.done():
self._script._changed() # noqa: SLF001
- async def _async_get_condition(self, config: ConfigType) -> ConditionCheckerType:
- return await self._script._async_get_condition(config) # noqa: SLF001
-
def _log(
self, msg: str, *args: Any, level: int = logging.INFO, **kwargs: Any
) -> None:
@@ -488,14 +484,16 @@ class _ScriptRun:
script_stack.pop()
self._finish()
- return ScriptRunResult(self._conversation_response, response, self._variables)
+ return ScriptRunResult(
+ self._conversation_response, response, self._variables.local_scope
+ )
async def _async_step(self, log_exceptions: bool) -> None:
continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False)
with trace_path(str(self._step)):
async with trace_action(
- self._hass, self, self._stop, self._variables
+ self._hass, self, self._stop, self._variables.non_parallel_scope
) as trace_element:
if self._stop.done():
return
@@ -521,7 +519,7 @@ class _ScriptRun:
trace_set_result(enabled=False)
return
- handler = f"_async_{action}_step"
+ handler = f"_async_step_{action}"
try:
await getattr(self, handler)()
except Exception as ex: # noqa: BLE001
@@ -529,7 +527,7 @@ class _ScriptRun:
ex, continue_on_error, self._log_exceptions or log_exceptions
)
finally:
- trace_element.update_variables(self._variables)
+ trace_element.update_variables(self._variables.non_parallel_scope)
def _finish(self) -> None:
self._script._runs.remove(self) # noqa: SLF001
@@ -614,107 +612,6 @@ class _ScriptRun:
level=level,
)
- def _get_pos_time_period_template(self, key: str) -> timedelta:
- try:
- return cv.positive_time_period( # type: ignore[no-any-return]
- template.render_complex(self._action[key], self._variables)
- )
- except (exceptions.TemplateError, vol.Invalid) as ex:
- self._log(
- "Error rendering %s %s template: %s",
- self._script.name,
- key,
- ex,
- level=logging.ERROR,
- )
- raise _AbortScript from ex
-
- async def _async_delay_step(self) -> None:
- """Handle delay."""
- delay_delta = self._get_pos_time_period_template(CONF_DELAY)
-
- self._step_log(f"delay {delay_delta}")
-
- delay = delay_delta.total_seconds()
- self._changed()
- if not delay:
- # Handle an empty delay
- trace_set_result(delay=delay, done=True)
- return
-
- trace_set_result(delay=delay, done=False)
- futures, timeout_handle, timeout_future = self._async_futures_with_timeout(
- delay
- )
-
- try:
- await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED)
- finally:
- if timeout_future.done():
- trace_set_result(delay=delay, done=True)
- else:
- timeout_handle.cancel()
-
- def _get_timeout_seconds_from_action(self) -> float | None:
- """Get the timeout from the action."""
- if CONF_TIMEOUT in self._action:
- return self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds()
- return None
-
- async def _async_wait_template_step(self) -> None:
- """Handle a wait template."""
- timeout = self._get_timeout_seconds_from_action()
- self._step_log("wait template", timeout)
-
- self._variables["wait"] = {"remaining": timeout, "completed": False}
- trace_set_result(wait=self._variables["wait"])
-
- wait_template = self._action[CONF_WAIT_TEMPLATE]
-
- # check if condition already okay
- if condition.async_template(self._hass, wait_template, self._variables, False):
- self._variables["wait"]["completed"] = True
- self._changed()
- return
-
- if timeout == 0:
- self._changed()
- self._async_handle_timeout()
- return
-
- futures, timeout_handle, timeout_future = self._async_futures_with_timeout(
- timeout
- )
- done = self._hass.loop.create_future()
- futures.append(done)
-
- @callback
- def async_script_wait(
- entity_id: str, from_s: State | None, to_s: State | None
- ) -> None:
- """Handle script after template condition is true."""
- self._async_set_remaining_time_var(timeout_handle)
- self._variables["wait"]["completed"] = True
- _set_result_unless_done(done)
-
- unsub = async_track_template(
- self._hass, wait_template, async_script_wait, self._variables
- )
- self._changed()
- await self._async_wait_with_optional_timeout(
- futures, timeout_handle, timeout_future, unsub
- )
-
- def _async_set_remaining_time_var(
- self, timeout_handle: asyncio.TimerHandle | None
- ) -> None:
- """Set the remaining time variable for a wait step."""
- wait_var = self._variables["wait"]
- if timeout_handle:
- wait_var["remaining"] = timeout_handle.when() - self._hass.loop.time()
- else:
- wait_var["remaining"] = None
-
async def _async_run_long_action[_T](
self, long_task: asyncio.Task[_T]
) -> _T | None:
@@ -728,111 +625,58 @@ class _ScriptRun:
except ScriptStoppedError as ex:
raise asyncio.CancelledError from ex
- async def _async_call_service_step(self) -> None:
- """Call the service specified in the action."""
- self._step_log("call service")
-
- params = service.async_prepare_call_from_config(
- self._hass, self._action, self._variables
- )
-
- # Validate response data parameters. This check ignores services that do
- # not exist which will raise an appropriate error in the service call below.
- response_variable = self._action.get(CONF_RESPONSE_VARIABLE)
- return_response = response_variable is not None
- if self._hass.services.has_service(params[CONF_DOMAIN], params[CONF_SERVICE]):
- supports_response = self._hass.services.supports_response(
- params[CONF_DOMAIN], params[CONF_SERVICE]
- )
- if supports_response == SupportsResponse.ONLY and not return_response:
- raise vol.Invalid(
- f"Script requires '{CONF_RESPONSE_VARIABLE}' for response data "
- f"for service call {params[CONF_DOMAIN]}.{params[CONF_SERVICE]}"
- )
- if supports_response == SupportsResponse.NONE and return_response:
- raise vol.Invalid(
- f"Script does not support '{CONF_RESPONSE_VARIABLE}' for service "
- f"'{CONF_RESPONSE_VARIABLE}' which does not support response data."
- )
-
- running_script = (
- params[CONF_DOMAIN] == "automation" and params[CONF_SERVICE] == "trigger"
- ) or params[CONF_DOMAIN] in ("python_script", "script")
- trace_set_result(params=params, running_script=running_script)
- response_data = await self._async_run_long_action(
+ async def _async_run_script(
+ self, script: Script, *, parallel: bool = False
+ ) -> None:
+ """Execute a script."""
+ if not script.enabled:
+ self._log("Skipping disabled script: %s", script.name)
+ trace_set_result(enabled=False)
+ return
+ result = await self._async_run_long_action(
self._hass.async_create_task_internal(
- self._hass.services.async_call(
- **params,
- blocking=True,
- context=self._context,
- return_response=return_response,
+ script.async_run(
+ self._variables.enter_scope(parallel=parallel), self._context
),
eager_start=True,
)
)
- if response_variable:
- self._variables[response_variable] = response_data
+ if result and result.conversation_response is not UNDEFINED:
+ self._conversation_response = result.conversation_response
- async def _async_device_step(self) -> None:
- """Perform the device automation specified in the action."""
- self._step_log("device automation")
- await device_action.async_call_action_from_config(
- self._hass, self._action, self._variables, self._context
+ ## Flow control actions ##
+
+ ### Sequence actions ###
+
+ @async_trace_path("parallel")
+ async def _async_step_parallel(self) -> None:
+ """Run a sequence in parallel."""
+ scripts = await self._script._async_get_parallel_scripts(self._step) # noqa: SLF001
+
+ async def async_run_with_trace(idx: int, script: Script) -> None:
+ """Run a script with a trace path."""
+ trace_path_stack_cv.set(copy(trace_path_stack_cv.get()))
+ with trace_path([str(idx), "sequence"]):
+ await self._async_run_script(script, parallel=True)
+
+ results = await asyncio.gather(
+ *(async_run_with_trace(idx, script) for idx, script in enumerate(scripts)),
+ return_exceptions=True,
)
+ for result in results:
+ if isinstance(result, Exception):
+ raise result
- async def _async_scene_step(self) -> None:
- """Activate the scene specified in the action."""
- self._step_log("activate scene")
- trace_set_result(scene=self._action[CONF_SCENE])
- await self._hass.services.async_call(
- scene.DOMAIN,
- SERVICE_TURN_ON,
- {ATTR_ENTITY_ID: self._action[CONF_SCENE]},
- blocking=True,
- context=self._context,
- )
+ @async_trace_path("sequence")
+ async def _async_step_sequence(self) -> None:
+ """Run a sequence."""
+ sequence = await self._script._async_get_sequence_script(self._step) # noqa: SLF001
+ await self._async_run_script(sequence)
- async def _async_event_step(self) -> None:
- """Fire an event."""
- self._step_log(self._action.get(CONF_ALIAS, self._action[CONF_EVENT]))
- event_data = {}
- for conf in (CONF_EVENT_DATA, CONF_EVENT_DATA_TEMPLATE):
- if conf not in self._action:
- continue
+ ### Condition actions ###
- try:
- event_data.update(
- template.render_complex(self._action[conf], self._variables)
- )
- except exceptions.TemplateError as ex:
- self._log(
- "Error rendering event data template: %s", ex, level=logging.ERROR
- )
-
- trace_set_result(event=self._action[CONF_EVENT], event_data=event_data)
- self._hass.bus.async_fire_internal(
- self._action[CONF_EVENT], event_data, context=self._context
- )
-
- async def _async_condition_step(self) -> None:
- """Test if condition is matching."""
- self._script.last_action = self._action.get(
- CONF_ALIAS, self._action[CONF_CONDITION]
- )
- cond = await self._async_get_condition(self._action)
- try:
- trace_element = trace_stack_top(trace_stack_cv)
- if trace_element:
- trace_element.reuse_by_child = True
- check = cond(self._hass, self._variables)
- except exceptions.ConditionError as ex:
- _LOGGER.warning("Error in 'condition' evaluation:\n%s", ex)
- check = False
-
- self._log("Test condition %s: %s", self._script.last_action, check)
- trace_update_result(result=check)
- if not check:
- raise _ConditionFail
+ async def _async_get_condition(self, config: ConfigType) -> ConditionCheckerType:
+ return await self._script._async_get_condition(config) # noqa: SLF001
def _test_conditions(
self,
@@ -861,14 +705,76 @@ class _ScriptRun:
return traced_test_conditions(self._hass, self._variables)
- @async_trace_path("repeat")
- async def _async_repeat_step(self) -> None: # noqa: C901
- """Repeat a sequence."""
+ async def _async_step_choose(self) -> None:
+ """Choose a sequence."""
+ choose_data = await self._script._async_get_choose_data(self._step) # noqa: SLF001
+
+ with trace_path("choose"):
+ for idx, (conditions, script) in enumerate(choose_data["choices"]):
+ with trace_path(str(idx)):
+ try:
+ if self._test_conditions(conditions, "choose", "conditions"):
+ trace_set_result(choice=idx)
+ with trace_path("sequence"):
+ await self._async_run_script(script)
+ return
+ except exceptions.ConditionError as ex:
+ _LOGGER.warning("Error in 'choose' evaluation:\n%s", ex)
+
+ if choose_data["default"] is not None:
+ trace_set_result(choice="default")
+ with trace_path(["default"]):
+ await self._async_run_script(choose_data["default"])
+
+ async def _async_step_condition(self) -> None:
+ """Test if condition is matching."""
+ self._script.last_action = self._action.get(
+ CONF_ALIAS, self._action[CONF_CONDITION]
+ )
+ cond = await self._async_get_condition(self._action)
+ try:
+ trace_element = trace_stack_top(trace_stack_cv)
+ if trace_element:
+ trace_element.reuse_by_child = True
+ check = cond(self._hass, self._variables)
+ except exceptions.ConditionError as ex:
+ _LOGGER.warning("Error in 'condition' evaluation:\n%s", ex)
+ check = False
+
+ self._log("Test condition %s: %s", self._script.last_action, check)
+ trace_update_result(result=check)
+ if not check:
+ raise _ConditionFail
+
+ async def _async_step_if(self) -> None:
+ """If sequence."""
+ if_data = await self._script._async_get_if_data(self._step) # noqa: SLF001
+
+ test_conditions: bool | None = False
+ try:
+ with trace_path("if"):
+ test_conditions = self._test_conditions(
+ if_data["if_conditions"], "if", "condition"
+ )
+ except exceptions.ConditionError as ex:
+ _LOGGER.warning("Error in 'if' evaluation:\n%s", ex)
+
+ if test_conditions:
+ trace_set_result(choice="then")
+ with trace_path("then"):
+ await self._async_run_script(if_data["if_then"])
+ return
+
+ if if_data["if_else"] is not None:
+ trace_set_result(choice="else")
+ with trace_path("else"):
+ await self._async_run_script(if_data["if_else"])
+
+ async def _async_do_step_repeat(self) -> None: # noqa: C901
+ """Repeat a sequence helper."""
description = self._action.get(CONF_ALIAS, "sequence")
repeat = self._action[CONF_REPEAT]
- saved_repeat_vars = self._variables.get("repeat")
-
def set_repeat_var(
iteration: int, count: int | None = None, item: Any = None
) -> None:
@@ -877,7 +783,7 @@ class _ScriptRun:
repeat_vars["last"] = iteration == count
if item is not None:
repeat_vars["item"] = item
- self._variables["repeat"] = repeat_vars
+ self._variables.define_local("repeat", repeat_vars)
script = self._script._get_repeat_script(self._step) # noqa: SLF001
warned_too_many_loops = False
@@ -1028,55 +934,137 @@ class _ScriptRun:
# while all the cpu time is consumed.
await asyncio.sleep(0)
- if saved_repeat_vars:
- self._variables["repeat"] = saved_repeat_vars
- else:
- self._variables.pop("repeat", None) # Not set if count = 0
-
- async def _async_choose_step(self) -> None:
- """Choose a sequence."""
- choose_data = await self._script._async_get_choose_data(self._step) # noqa: SLF001
-
- with trace_path("choose"):
- for idx, (conditions, script) in enumerate(choose_data["choices"]):
- with trace_path(str(idx)):
- try:
- if self._test_conditions(conditions, "choose", "conditions"):
- trace_set_result(choice=idx)
- with trace_path("sequence"):
- await self._async_run_script(script)
- return
- except exceptions.ConditionError as ex:
- _LOGGER.warning("Error in 'choose' evaluation:\n%s", ex)
-
- if choose_data["default"] is not None:
- trace_set_result(choice="default")
- with trace_path(["default"]):
- await self._async_run_script(choose_data["default"])
-
- async def _async_if_step(self) -> None:
- """If sequence."""
- if_data = await self._script._async_get_if_data(self._step) # noqa: SLF001
-
- test_conditions: bool | None = False
+ @async_trace_path("repeat")
+ async def _async_step_repeat(self) -> None:
+ """Repeat a sequence."""
+ self._variables = self._variables.enter_scope()
try:
- with trace_path("if"):
- test_conditions = self._test_conditions(
- if_data["if_conditions"], "if", "condition"
+ await self._async_do_step_repeat()
+ finally:
+ self._variables = self._variables.exit_scope()
+
+ ### Stop actions ###
+
+ async def _async_step_stop(self) -> None:
+ """Stop script execution."""
+ stop = self._action[CONF_STOP]
+ error = self._action.get(CONF_ERROR, False)
+ trace_set_result(stop=stop, error=error)
+ if error:
+ self._log("Error script sequence: %s", stop)
+ raise _AbortScript(stop)
+
+ self._log("Stop script sequence: %s", stop)
+ if CONF_RESPONSE_VARIABLE in self._action:
+ try:
+ response = self._variables[self._action[CONF_RESPONSE_VARIABLE]]
+ except KeyError as ex:
+ raise _AbortScript(
+ f"Response variable '{self._action[CONF_RESPONSE_VARIABLE]}' "
+ "is not defined"
+ ) from ex
+ else:
+ response = None
+ raise _StopScript(stop, response)
+
+ ## Variable actions ##
+
+ async def _async_step_variables(self) -> None:
+ """Assign values to variables."""
+ self._step_log("assigning variables")
+ self._variables.update(
+ self._action[CONF_VARIABLES].async_simple_render(self._variables)
+ )
+
+ ## External actions ##
+
+ async def _async_step_call_service(self) -> None:
+ """Call the service specified in the action."""
+ self._step_log("call service")
+
+ params = service.async_prepare_call_from_config(
+ self._hass, self._action, self._variables
+ )
+
+ # Validate response data parameters. This check ignores services that do
+ # not exist which will raise an appropriate error in the service call below.
+ response_variable = self._action.get(CONF_RESPONSE_VARIABLE)
+ return_response = response_variable is not None
+ if self._hass.services.has_service(params[CONF_DOMAIN], params[CONF_SERVICE]):
+ supports_response = self._hass.services.supports_response(
+ params[CONF_DOMAIN], params[CONF_SERVICE]
+ )
+ if supports_response == SupportsResponse.ONLY and not return_response:
+ raise vol.Invalid(
+ f"Script requires '{CONF_RESPONSE_VARIABLE}' for response data "
+ f"for service call {params[CONF_DOMAIN]}.{params[CONF_SERVICE]}"
+ )
+ if supports_response == SupportsResponse.NONE and return_response:
+ raise vol.Invalid(
+ f"Script does not support '{CONF_RESPONSE_VARIABLE}' for service "
+ f"'{CONF_RESPONSE_VARIABLE}' which does not support response data."
)
- except exceptions.ConditionError as ex:
- _LOGGER.warning("Error in 'if' evaluation:\n%s", ex)
- if test_conditions:
- trace_set_result(choice="then")
- with trace_path("then"):
- await self._async_run_script(if_data["if_then"])
- return
+ running_script = (
+ params[CONF_DOMAIN] == "automation" and params[CONF_SERVICE] == "trigger"
+ ) or params[CONF_DOMAIN] in ("python_script", "script")
+ trace_set_result(params=params, running_script=running_script)
+ response_data = await self._async_run_long_action(
+ self._hass.async_create_task_internal(
+ self._hass.services.async_call(
+ **params,
+ blocking=True,
+ context=self._context,
+ return_response=return_response,
+ ),
+ eager_start=True,
+ )
+ )
+ if response_variable:
+ self._variables[response_variable] = response_data
- if if_data["if_else"] is not None:
- trace_set_result(choice="else")
- with trace_path("else"):
- await self._async_run_script(if_data["if_else"])
+ async def _async_step_device(self) -> None:
+ """Perform the device automation specified in the action."""
+ self._step_log("device automation")
+ await device_action.async_call_action_from_config(
+ self._hass, self._action, dict(self._variables), self._context
+ )
+
+ async def _async_step_event(self) -> None:
+ """Fire an event."""
+ self._step_log(self._action.get(CONF_ALIAS, self._action[CONF_EVENT]))
+ event_data = {}
+ for conf in (CONF_EVENT_DATA, CONF_EVENT_DATA_TEMPLATE):
+ if conf not in self._action:
+ continue
+
+ try:
+ event_data.update(
+ template.render_complex(self._action[conf], self._variables)
+ )
+ except exceptions.TemplateError as ex:
+ self._log(
+ "Error rendering event data template: %s", ex, level=logging.ERROR
+ )
+
+ trace_set_result(event=self._action[CONF_EVENT], event_data=event_data)
+ self._hass.bus.async_fire_internal(
+ self._action[CONF_EVENT], event_data, context=self._context
+ )
+
+ async def _async_step_scene(self) -> None:
+ """Activate the scene specified in the action."""
+ self._step_log("activate scene")
+ trace_set_result(scene=self._action[CONF_SCENE])
+ await self._hass.services.async_call(
+ scene.DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: self._action[CONF_SCENE]},
+ blocking=True,
+ context=self._context,
+ )
+
+ ## Time-based actions ##
@overload
def _async_futures_with_timeout(
@@ -1124,18 +1112,103 @@ class _ScriptRun:
futures.append(timeout_future)
return futures, timeout_handle, timeout_future
- async def _async_wait_for_trigger_step(self) -> None:
+ def _get_pos_time_period_template(self, key: str) -> timedelta:
+ try:
+ return cv.positive_time_period( # type: ignore[no-any-return]
+ template.render_complex(self._action[key], self._variables)
+ )
+ except (exceptions.TemplateError, vol.Invalid) as ex:
+ self._log(
+ "Error rendering %s %s template: %s",
+ self._script.name,
+ key,
+ ex,
+ level=logging.ERROR,
+ )
+ raise _AbortScript from ex
+
+ async def _async_step_delay(self) -> None:
+ """Handle delay."""
+ delay_delta = self._get_pos_time_period_template(CONF_DELAY)
+
+ self._step_log(f"delay {delay_delta}")
+
+ delay = delay_delta.total_seconds()
+ self._changed()
+ if not delay:
+ # Handle an empty delay
+ trace_set_result(delay=delay, done=True)
+ return
+
+ trace_set_result(delay=delay, done=False)
+ futures, timeout_handle, timeout_future = self._async_futures_with_timeout(
+ delay
+ )
+
+ try:
+ await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED)
+ finally:
+ if timeout_future.done():
+ trace_set_result(delay=delay, done=True)
+ else:
+ timeout_handle.cancel()
+
+ def _get_timeout_seconds_from_action(self) -> float | None:
+ """Get the timeout from the action."""
+ if CONF_TIMEOUT in self._action:
+ return self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds()
+ return None
+
+ def _async_handle_timeout(self) -> None:
+ """Handle timeout."""
+ self._variables["wait"]["remaining"] = 0.0
+ if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True):
+ self._log(_TIMEOUT_MSG)
+ trace_set_result(wait=self._variables["wait"], timeout=True)
+ raise _AbortScript from TimeoutError()
+
+ async def _async_wait_with_optional_timeout(
+ self,
+ futures: list[asyncio.Future[None]],
+ timeout_handle: asyncio.TimerHandle | None,
+ timeout_future: asyncio.Future[None] | None,
+ unsub: Callable[[], None],
+ ) -> None:
+ try:
+ await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED)
+ if timeout_future and timeout_future.done():
+ self._async_handle_timeout()
+ finally:
+ if timeout_future and not timeout_future.done() and timeout_handle:
+ timeout_handle.cancel()
+
+ unsub()
+
+ def _async_set_remaining_time_var(
+ self, timeout_handle: asyncio.TimerHandle | None
+ ) -> None:
+ """Set the remaining time variable for a wait step."""
+ wait_var = self._variables["wait"]
+ if timeout_handle:
+ wait_var["remaining"] = timeout_handle.when() - self._hass.loop.time()
+ else:
+ wait_var["remaining"] = None
+
+ async def _async_step_wait_for_trigger(self) -> None:
"""Wait for a trigger event."""
timeout = self._get_timeout_seconds_from_action()
self._step_log("wait for trigger", timeout)
- variables = {**self._variables}
- self._variables["wait"] = {
- "remaining": timeout,
- "completed": False,
- "trigger": None,
- }
+ variables = dict(self._variables)
+ self._variables.assign_parallel_protected(
+ "wait",
+ {
+ "remaining": timeout,
+ "completed": False,
+ "trigger": None,
+ },
+ )
trace_set_result(wait=self._variables["wait"])
if timeout == 0:
@@ -1176,39 +1249,55 @@ class _ScriptRun:
futures, timeout_handle, timeout_future, remove_triggers
)
- def _async_handle_timeout(self) -> None:
- """Handle timeout."""
- self._variables["wait"]["remaining"] = 0.0
- if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True):
- self._log(_TIMEOUT_MSG)
- trace_set_result(wait=self._variables["wait"], timeout=True)
- raise _AbortScript from TimeoutError()
+ async def _async_step_wait_template(self) -> None:
+ """Handle a wait template."""
+ timeout = self._get_timeout_seconds_from_action()
+ self._step_log("wait template", timeout)
- async def _async_wait_with_optional_timeout(
- self,
- futures: list[asyncio.Future[None]],
- timeout_handle: asyncio.TimerHandle | None,
- timeout_future: asyncio.Future[None] | None,
- unsub: Callable[[], None],
- ) -> None:
- try:
- await asyncio.wait(futures, return_when=asyncio.FIRST_COMPLETED)
- if timeout_future and timeout_future.done():
- self._async_handle_timeout()
- finally:
- if timeout_future and not timeout_future.done() and timeout_handle:
- timeout_handle.cancel()
+ self._variables.assign_parallel_protected(
+ "wait", {"remaining": timeout, "completed": False}
+ )
+ trace_set_result(wait=self._variables["wait"])
- unsub()
+ wait_template = self._action[CONF_WAIT_TEMPLATE]
- async def _async_variables_step(self) -> None:
- """Set a variable value."""
- self._step_log("setting variables")
- self._variables = self._action[CONF_VARIABLES].async_render(
- self._hass, self._variables, render_as_defaults=False
+ # check if condition already okay
+ if condition.async_template(self._hass, wait_template, self._variables, False):
+ self._variables["wait"]["completed"] = True
+ self._changed()
+ return
+
+ if timeout == 0:
+ self._changed()
+ self._async_handle_timeout()
+ return
+
+ futures, timeout_handle, timeout_future = self._async_futures_with_timeout(
+ timeout
+ )
+ done = self._hass.loop.create_future()
+ futures.append(done)
+
+ @callback
+ def async_script_wait(
+ entity_id: str, from_s: State | None, to_s: State | None
+ ) -> None:
+ """Handle script after template condition is true."""
+ self._async_set_remaining_time_var(timeout_handle)
+ self._variables["wait"]["completed"] = True
+ _set_result_unless_done(done)
+
+ unsub = async_track_template(
+ self._hass, wait_template, async_script_wait, self._variables
+ )
+ self._changed()
+ await self._async_wait_with_optional_timeout(
+ futures, timeout_handle, timeout_future, unsub
)
- async def _async_set_conversation_response_step(self) -> None:
+ ## Conversation actions ##
+
+ async def _async_step_set_conversation_response(self) -> None:
"""Set conversation response."""
self._step_log("setting conversation response")
resp: template.Template | None = self._action[CONF_SET_CONVERSATION_RESPONSE]
@@ -1220,70 +1309,13 @@ class _ScriptRun:
)
trace_set_result(conversation_response=self._conversation_response)
- async def _async_stop_step(self) -> None:
- """Stop script execution."""
- stop = self._action[CONF_STOP]
- error = self._action.get(CONF_ERROR, False)
- trace_set_result(stop=stop, error=error)
- if error:
- self._log("Error script sequence: %s", stop)
- raise _AbortScript(stop)
-
- self._log("Stop script sequence: %s", stop)
- if CONF_RESPONSE_VARIABLE in self._action:
- try:
- response = self._variables[self._action[CONF_RESPONSE_VARIABLE]]
- except KeyError as ex:
- raise _AbortScript(
- f"Response variable '{self._action[CONF_RESPONSE_VARIABLE]}' "
- "is not defined"
- ) from ex
- else:
- response = None
- raise _StopScript(stop, response)
-
- @async_trace_path("sequence")
- async def _async_sequence_step(self) -> None:
- """Run a sequence."""
- sequence = await self._script._async_get_sequence_script(self._step) # noqa: SLF001
- await self._async_run_script(sequence)
-
- @async_trace_path("parallel")
- async def _async_parallel_step(self) -> None:
- """Run a sequence in parallel."""
- scripts = await self._script._async_get_parallel_scripts(self._step) # noqa: SLF001
-
- async def async_run_with_trace(idx: int, script: Script) -> None:
- """Run a script with a trace path."""
- trace_path_stack_cv.set(copy(trace_path_stack_cv.get()))
- with trace_path([str(idx), "sequence"]):
- await self._async_run_script(script)
-
- results = await asyncio.gather(
- *(async_run_with_trace(idx, script) for idx, script in enumerate(scripts)),
- return_exceptions=True,
- )
- for result in results:
- if isinstance(result, Exception):
- raise result
-
- async def _async_run_script(self, script: Script) -> None:
- """Execute a script."""
- result = await self._async_run_long_action(
- self._hass.async_create_task_internal(
- script.async_run(self._variables, self._context), eager_start=True
- )
- )
- if result and result.conversation_response is not UNDEFINED:
- self._conversation_response = result.conversation_response
-
class _QueuedScriptRun(_ScriptRun):
"""Manage queued Script sequence run."""
lock_acquired = False
- async def async_run(self) -> None:
+ async def async_run(self) -> ScriptRunResult | None:
"""Run script."""
# Wait for previous run, if any, to finish by attempting to acquire the script's
# shared lock. At the same time monitor if we've been told to stop.
@@ -1297,7 +1329,7 @@ class _QueuedScriptRun(_ScriptRun):
self.lock_acquired = True
# We've acquired the lock so we can go ahead and start the run.
- await super().async_run()
+ return await super().async_run()
def _finish(self) -> None:
if self.lock_acquired:
@@ -1353,7 +1385,7 @@ async def _async_stop_scripts_at_shutdown(hass: HomeAssistant, event: Event) ->
)
-type _VarsType = dict[str, Any] | Mapping[str, Any] | MappingProxyType[str, Any]
+type _VarsType = dict[str, Any] | Mapping[str, Any] | ScriptRunVariables
def _referenced_extract_ids(data: Any, key: str, found: set[str]) -> None:
@@ -1391,7 +1423,7 @@ class ScriptRunResult:
conversation_response: str | None | UndefinedType
service_response: ServiceResponse
- variables: dict[str, Any]
+ variables: Mapping[str, Any]
class Script:
@@ -1406,7 +1438,6 @@ class Script:
*,
# Used in "Running " log message
change_listener: Callable[[], Any] | None = None,
- copy_variables: bool = False,
log_exceptions: bool = True,
logger: logging.Logger | None = None,
max_exceeded: str = DEFAULT_MAX_EXCEEDED,
@@ -1415,8 +1446,12 @@ class Script:
script_mode: str = DEFAULT_SCRIPT_MODE,
top_level: bool = True,
variables: ScriptVariables | None = None,
+ enabled: bool = True,
) -> None:
- """Initialize the script."""
+ """Initialize the script.
+
+ enabled attribute is only used for non-top-level scripts.
+ """
if not (all_scripts := hass.data.get(DATA_SCRIPTS)):
all_scripts = hass.data[DATA_SCRIPTS] = []
hass.bus.async_listen_once(
@@ -1435,6 +1470,7 @@ class Script:
self.name = name
self.unique_id = f"{domain}.{name}-{id(self)}"
self.domain = domain
+ self.enabled = enabled
self.running_description = running_description or f"{domain} script"
self._change_listener = change_listener
self._change_listener_job = (
@@ -1460,8 +1496,6 @@ class Script:
self._parallel_scripts: dict[int, list[Script]] = {}
self._sequence_scripts: dict[int, Script] = {}
self.variables = variables
- self._variables_dynamic = template.is_complex(variables)
- self._copy_variables_on_run = copy_variables
@property
def change_listener(self) -> Callable[..., Any] | None:
@@ -1739,25 +1773,19 @@ class Script:
if self.top_level:
if self.variables:
try:
- variables = self.variables.async_render(
+ run_variables = self.variables.async_render(
self._hass,
run_variables,
)
except exceptions.TemplateError as err:
self._log("Error rendering variables: %s", err, level=logging.ERROR)
raise
- elif run_variables:
- variables = dict(run_variables)
- else:
- variables = {}
+ variables = ScriptRunVariables.create_top_level(run_variables)
variables["context"] = context
- elif self._copy_variables_on_run:
- # This is not the top level script, variables have been turned to a dict
- variables = cast(dict[str, Any], copy(run_variables))
else:
- # This is not the top level script, variables have been turned to a dict
- variables = cast(dict[str, Any], run_variables)
+ # This is not the top level script, run_variables is an instance of ScriptRunVariables
+ variables = cast(ScriptRunVariables, run_variables)
# Prevent non-allowed recursive calls which will cause deadlocks when we try to
# stop (restart) or wait for (queued) our own script run.
@@ -1983,7 +2011,7 @@ class Script:
max_runs=self.max_runs,
logger=self._logger,
top_level=False,
- copy_variables=True,
+ enabled=parallel_script.get(CONF_ENABLED, True),
)
parallel_script.change_listener = partial(
self._chain_change_listener, parallel_script
diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py
index 2b4507abd64..54200e094e6 100644
--- a/homeassistant/helpers/script_variables.py
+++ b/homeassistant/helpers/script_variables.py
@@ -2,8 +2,10 @@
from __future__ import annotations
+from collections import ChainMap, UserDict
from collections.abc import Mapping
-from typing import Any
+from dataclasses import dataclass, field
+from typing import Any, cast
from homeassistant.core import HomeAssistant, callback
@@ -24,30 +26,23 @@ class ScriptVariables:
hass: HomeAssistant,
run_variables: Mapping[str, Any] | None,
*,
- render_as_defaults: bool = True,
limited: bool = False,
) -> dict[str, Any]:
"""Render script variables.
- The run variables are used to compute the static variables.
-
- If `render_as_defaults` is True, the run variables will not be overridden.
-
+ The run variables are included in the result.
+ The run variables are used to compute the rendered variable values.
+ The run variables will not be overridden.
+ The rendering happens one at a time, with previous results influencing the next.
"""
if self._has_template is None:
self._has_template = template.is_complex(self.variables)
if not self._has_template:
- if render_as_defaults:
- rendered_variables = dict(self.variables)
+ rendered_variables = dict(self.variables)
- if run_variables is not None:
- rendered_variables.update(run_variables)
- else:
- rendered_variables = (
- {} if run_variables is None else dict(run_variables)
- )
- rendered_variables.update(self.variables)
+ if run_variables is not None:
+ rendered_variables.update(run_variables)
return rendered_variables
@@ -56,7 +51,7 @@ class ScriptVariables:
for key, value in self.variables.items():
# We can skip if we're going to override this key with
# run variables anyway
- if render_as_defaults and key in rendered_variables:
+ if key in rendered_variables:
continue
rendered_variables[key] = template.render_complex(
@@ -65,6 +60,197 @@ class ScriptVariables:
return rendered_variables
+ @callback
+ def async_simple_render(self, run_variables: Mapping[str, Any]) -> dict[str, Any]:
+ """Render script variables.
+
+ Simply renders the variables, the run variables are not included in the result.
+ The run variables are used to compute the rendered variable values.
+ The rendering happens one at a time, with previous results influencing the next.
+ """
+ if self._has_template is None:
+ self._has_template = template.is_complex(self.variables)
+
+ if not self._has_template:
+ return self.variables
+
+ run_variables = dict(run_variables)
+ rendered_variables = {}
+
+ for key, value in self.variables.items():
+ rendered_variable = template.render_complex(value, run_variables)
+ rendered_variables[key] = rendered_variable
+ run_variables[key] = rendered_variable
+
+ return rendered_variables
+
def as_dict(self) -> dict[str, Any]:
"""Return dict version of this class."""
return self.variables
+
+
+@dataclass
+class _ParallelData:
+ """Data used in each parallel sequence."""
+
+ # `protected` is for variables that need special protection in parallel sequences.
+ # What this means is that such a variable defined in one parallel sequence will not be
+ # clobbered by the variable with the same name assigned in another parallel sequence.
+ # It also means that such a variable will not be visible in the outer scope.
+ # Currently the only such variable is `wait`.
+ protected: dict[str, Any] = field(default_factory=dict)
+ # `outer_scope_writes` is for variables that are written to the outer scope from
+ # a parallel sequence. This is used for generating correct traces of changed variables
+ # for each of the parallel sequences, isolating them from one another.
+ outer_scope_writes: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(kw_only=True)
+class ScriptRunVariables(UserDict[str, Any]):
+ """Class to hold script run variables.
+
+ The purpose of this class is to provide proper variable scoping semantics for scripts.
+ Each instance institutes a new local scope, in which variables can be defined.
+ Each instance has a reference to the previous instance, except for the top-level instance.
+ The instances therefore form a chain, in which variable lookup and assignment is performed.
+ The variables defined lower in the chain naturally override those defined higher up.
+ """
+
+ # _previous is the previous ScriptRunVariables in the chain
+ _previous: ScriptRunVariables | None = None
+ # _parent is the previous non-empty ScriptRunVariables in the chain
+ _parent: ScriptRunVariables | None = None
+
+ # _local_data is the store for local variables
+ _local_data: dict[str, Any] | None = None
+ # _parallel_data is used for each parallel sequence
+ _parallel_data: _ParallelData | None = None
+
+ # _non_parallel_scope includes all scopes all the way to the most recent parallel split
+ _non_parallel_scope: ChainMap[str, Any]
+ # _full_scope includes all scopes (all the way to the top-level)
+ _full_scope: ChainMap[str, Any]
+
+ @classmethod
+ def create_top_level(
+ cls,
+ initial_data: Mapping[str, Any] | None = None,
+ ) -> ScriptRunVariables:
+ """Create a new top-level ScriptRunVariables."""
+ local_data: dict[str, Any] = {}
+ non_parallel_scope = full_scope = ChainMap(local_data)
+ self = cls(
+ _local_data=local_data,
+ _non_parallel_scope=non_parallel_scope,
+ _full_scope=full_scope,
+ )
+ if initial_data is not None:
+ self.update(initial_data)
+ return self
+
+ def enter_scope(self, *, parallel: bool = False) -> ScriptRunVariables:
+ """Return a new child scope.
+
+ :param parallel: Whether the new scope starts a parallel sequence.
+ """
+ if self._local_data is not None or self._parallel_data is not None:
+ parent = self
+ else:
+ parent = cast( # top level always has local data, so we can cast safely
+ ScriptRunVariables, self._parent
+ )
+
+ parallel_data: _ParallelData | None
+ if not parallel:
+ parallel_data = None
+ non_parallel_scope = self._non_parallel_scope
+ full_scope = self._full_scope
+ else:
+ parallel_data = _ParallelData()
+ non_parallel_scope = ChainMap(
+ parallel_data.protected, parallel_data.outer_scope_writes
+ )
+ full_scope = self._full_scope.new_child(parallel_data.protected)
+
+ return ScriptRunVariables(
+ _previous=self,
+ _parent=parent,
+ _parallel_data=parallel_data,
+ _non_parallel_scope=non_parallel_scope,
+ _full_scope=full_scope,
+ )
+
+ def exit_scope(self) -> ScriptRunVariables:
+ """Exit the current scope.
+
+ Does no clean-up, but simply returns the previous scope.
+ """
+ if self._previous is None:
+ raise ValueError("Cannot exit top-level scope")
+ return self._previous
+
+ def __delitem__(self, key: str) -> None:
+ """Delete a variable (disallowed)."""
+ raise TypeError("Deleting items is not allowed in ScriptRunVariables.")
+
+ def __setitem__(self, key: str, value: Any) -> None:
+ """Assign value to a variable."""
+ self._assign(key, value, parallel_protected=False)
+
+ def assign_parallel_protected(self, key: str, value: Any) -> None:
+ """Assign value to a variable which is to be protected in parallel sequences."""
+ self._assign(key, value, parallel_protected=True)
+
+ def _assign(self, key: str, value: Any, *, parallel_protected: bool) -> None:
+ """Assign value to a variable.
+
+ Value is always assigned to the variable in the nearest scope, in which it is defined.
+ If the variable is not defined at all, it is created in the top-level scope.
+
+ :param parallel_protected: Whether variable is to be protected in parallel sequences.
+ """
+ if self._local_data is not None and key in self._local_data:
+ self._local_data[key] = value
+ return
+
+ if self._parent is None:
+ assert self._local_data is not None # top level always has local data
+ self._local_data[key] = value
+ return
+
+ if self._parallel_data is not None:
+ if parallel_protected:
+ self._parallel_data.protected[key] = value
+ return
+ self._parallel_data.protected.pop(key, None)
+ self._parallel_data.outer_scope_writes[key] = value
+
+ self._parent._assign(key, value, parallel_protected=parallel_protected) # noqa: SLF001
+
+ def define_local(self, key: str, value: Any) -> None:
+ """Define a local variable and assign value to it."""
+ if self._local_data is None:
+ self._local_data = {}
+ self._non_parallel_scope = self._non_parallel_scope.new_child(
+ self._local_data
+ )
+ self._full_scope = self._full_scope.new_child(self._local_data)
+ self._local_data[key] = value
+
+ @property
+ def data(self) -> Mapping[str, Any]: # type: ignore[override]
+ """Return variables in full scope.
+
+ Defined here for UserDict compatibility.
+ """
+ return self._full_scope
+
+ @property
+ def non_parallel_scope(self) -> Mapping[str, Any]:
+ """Return variables in non-parallel scope."""
+ return self._non_parallel_scope
+
+ @property
+ def local_scope(self) -> Mapping[str, Any]:
+ """Return variables in local scope."""
+ return self._local_data if self._local_data is not None else {}
diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py
index 025b8de8896..f2c76d1d019 100644
--- a/homeassistant/helpers/selector.py
+++ b/homeassistant/helpers/selector.py
@@ -164,6 +164,8 @@ DEVICE_FILTER_SELECTOR_CONFIG_SCHEMA = vol.Schema(
vol.Optional("manufacturer"): str,
# Model of device
vol.Optional("model"): str,
+ # Model ID of device
+ vol.Optional("model_id"): str,
# Device has to contain entities matching this selector
vol.Optional("entity"): vol.All(
cv.ensure_list, [ENTITY_FILTER_SELECTOR_CONFIG_SCHEMA]
@@ -178,6 +180,7 @@ class DeviceFilterSelectorConfig(TypedDict, total=False):
integration: str
manufacturer: str
model: str
+ model_id: str
class ActionSelectorConfig(TypedDict):
@@ -1133,7 +1136,7 @@ class SelectOptionDict(TypedDict):
class SelectSelectorMode(StrEnum):
- """Possible modes for a number selector."""
+ """Possible modes for a select selector."""
LIST = "list"
DROPDOWN = "dropdown"
diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py
index 7866250d658..cb6d8fe81b8 100644
--- a/homeassistant/helpers/template.py
+++ b/homeassistant/helpers/template.py
@@ -6,12 +6,13 @@ from ast import literal_eval
import asyncio
import base64
import collections.abc
-from collections.abc import Callable, Generator, Iterable
+from collections.abc import Callable, Generator, Iterable, MutableSequence
from contextlib import AbstractContextManager
from contextvars import ContextVar
from copy import deepcopy
from datetime import date, datetime, time, timedelta
from functools import cache, lru_cache, partial, wraps
+import hashlib
import json
import logging
import math
@@ -1071,7 +1072,7 @@ class TemplateStateBase(State):
raise KeyError
@under_cached_property
- def entity_id(self) -> str: # type: ignore[override]
+ def entity_id(self) -> str:
"""Wrap State.entity_id.
Intentionally does not collect state
@@ -1127,7 +1128,7 @@ class TemplateStateBase(State):
return self._state.object_id
@property
- def name(self) -> str: # type: ignore[override]
+ def name(self) -> str:
"""Wrap State.name."""
self._collect_state()
return self._state.name
@@ -1412,6 +1413,28 @@ def device_id(hass: HomeAssistant, entity_id_or_device_name: str) -> str | None:
)
+def device_name(hass: HomeAssistant, lookup_value: str) -> str | None:
+ """Get the device name from an device id, or entity id."""
+ device_reg = device_registry.async_get(hass)
+ if device := device_reg.async_get(lookup_value):
+ return device.name_by_user or device.name
+
+ ent_reg = entity_registry.async_get(hass)
+ # Import here, not at top-level to avoid circular import
+ from . import config_validation as cv # pylint: disable=import-outside-toplevel
+
+ try:
+ cv.entity_id(lookup_value)
+ except vol.Invalid:
+ pass
+ else:
+ if entity := ent_reg.async_get(lookup_value):
+ if entity.device_id and (device := device_reg.async_get(entity.device_id)):
+ return device.name_by_user or device.name
+
+ return None
+
+
def device_attr(hass: HomeAssistant, device_or_entity_id: str, attr_name: str) -> Any:
"""Get the device specific attribute."""
device_reg = device_registry.async_get(hass)
@@ -1477,10 +1500,14 @@ def floors(hass: HomeAssistant) -> Iterable[str | None]:
def floor_id(hass: HomeAssistant, lookup_value: Any) -> str | None:
- """Get the floor ID from a floor name."""
+ """Get the floor ID from a floor or area name, alias, device id, or entity id."""
floor_registry = fr.async_get(hass)
- if floor := floor_registry.async_get_floor_by_name(str(lookup_value)):
+ lookup_str = str(lookup_value)
+ if floor := floor_registry.async_get_floor_by_name(lookup_str):
return floor.floor_id
+ floors_list = floor_registry.async_get_floors_by_alias(lookup_str)
+ if floors_list:
+ return floors_list[0].floor_id
if aid := area_id(hass, lookup_value):
area_reg = area_registry.async_get(hass)
@@ -1525,16 +1552,29 @@ def floor_areas(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]:
return [entry.id for entry in entries if entry.id]
+def floor_entities(hass: HomeAssistant, floor_id_or_name: str) -> Iterable[str]:
+ """Return entity_ids for a given floor ID or name."""
+ return [
+ entity_id
+ for area_id in floor_areas(hass, floor_id_or_name)
+ for entity_id in area_entities(hass, area_id)
+ ]
+
+
def areas(hass: HomeAssistant) -> Iterable[str | None]:
"""Return all areas."""
return list(area_registry.async_get(hass).areas)
def area_id(hass: HomeAssistant, lookup_value: str) -> str | None:
- """Get the area ID from an area name, device id, or entity id."""
+ """Get the area ID from an area name, alias, device id, or entity id."""
area_reg = area_registry.async_get(hass)
- if area := area_reg.async_get_area_by_name(str(lookup_value)):
+ lookup_str = str(lookup_value)
+ if area := area_reg.async_get_area_by_name(lookup_str):
return area.id
+ areas_list = area_reg.async_get_areas_by_alias(lookup_str)
+ if areas_list:
+ return areas_list[0].id
ent_reg = entity_registry.async_get(hass)
dev_reg = device_registry.async_get(hass)
@@ -2727,6 +2767,144 @@ def iif(
return if_false
+def shuffle(*args: Any, seed: Any = None) -> MutableSequence[Any]:
+ """Shuffle a list, either with a seed or without."""
+ if not args:
+ raise TypeError("shuffle expected at least 1 argument, got 0")
+
+ # If first argument is iterable and more than 1 argument provided
+ # but not a named seed, then use 2nd argument as seed.
+ if isinstance(args[0], Iterable):
+ items = list(args[0])
+ if len(args) > 1 and seed is None:
+ seed = args[1]
+ elif len(args) == 1:
+ raise TypeError(f"'{type(args[0]).__name__}' object is not iterable")
+ else:
+ items = list(args)
+
+ if seed:
+ r = random.Random(seed)
+ r.shuffle(items)
+ else:
+ random.shuffle(items)
+ return items
+
+
+def typeof(value: Any) -> Any:
+ """Return the type of value passed to debug types."""
+ return value.__class__.__name__
+
+
+def flatten(value: Iterable[Any], levels: int | None = None) -> list[Any]:
+ """Flattens list of lists."""
+ if not isinstance(value, Iterable) or isinstance(value, str):
+ raise TypeError(f"flatten expected a list, got {type(value).__name__}")
+
+ flattened: list[Any] = []
+ for item in value:
+ if isinstance(item, Iterable) and not isinstance(item, str):
+ if levels is None:
+ flattened.extend(flatten(item))
+ elif levels >= 1:
+ flattened.extend(flatten(item, levels=(levels - 1)))
+ else:
+ flattened.append(item)
+ else:
+ flattened.append(item)
+ return flattened
+
+
+def intersect(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
+ """Return the common elements between two lists."""
+ if not isinstance(value, Iterable) or isinstance(value, str):
+ raise TypeError(f"intersect expected a list, got {type(value).__name__}")
+ if not isinstance(other, Iterable) or isinstance(other, str):
+ raise TypeError(f"intersect expected a list, got {type(other).__name__}")
+
+ return list(set(value) & set(other))
+
+
+def difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
+ """Return elements in first list that are not in second list."""
+ if not isinstance(value, Iterable) or isinstance(value, str):
+ raise TypeError(f"difference expected a list, got {type(value).__name__}")
+ if not isinstance(other, Iterable) or isinstance(other, str):
+ raise TypeError(f"difference expected a list, got {type(other).__name__}")
+
+ return list(set(value) - set(other))
+
+
+def union(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
+ """Return all unique elements from both lists combined."""
+ if not isinstance(value, Iterable) or isinstance(value, str):
+ raise TypeError(f"union expected a list, got {type(value).__name__}")
+ if not isinstance(other, Iterable) or isinstance(other, str):
+ raise TypeError(f"union expected a list, got {type(other).__name__}")
+
+ return list(set(value) | set(other))
+
+
+def symmetric_difference(value: Iterable[Any], other: Iterable[Any]) -> list[Any]:
+ """Return elements that are in either list but not in both."""
+ if not isinstance(value, Iterable) or isinstance(value, str):
+ raise TypeError(
+ f"symmetric_difference expected a list, got {type(value).__name__}"
+ )
+ if not isinstance(other, Iterable) or isinstance(other, str):
+ raise TypeError(
+ f"symmetric_difference expected a list, got {type(other).__name__}"
+ )
+
+ return list(set(value) ^ set(other))
+
+
+def combine(*args: Any, recursive: bool = False) -> dict[Any, Any]:
+ """Combine multiple dictionaries into one."""
+ if not args:
+ raise TypeError("combine expected at least 1 argument, got 0")
+
+ result: dict[Any, Any] = {}
+ for arg in args:
+ if not isinstance(arg, dict):
+ raise TypeError(f"combine expected a dict, got {type(arg).__name__}")
+
+ if recursive:
+ for key, value in arg.items():
+ if (
+ key in result
+ and isinstance(result[key], dict)
+ and isinstance(value, dict)
+ ):
+ result[key] = combine(result[key], value, recursive=True)
+ else:
+ result[key] = value
+ else:
+ result |= arg
+
+ return result
+
+
+def md5(value: str) -> str:
+ """Generate md5 hash from a string."""
+ return hashlib.md5(value.encode()).hexdigest()
+
+
+def sha1(value: str) -> str:
+ """Generate sha1 hash from a string."""
+ return hashlib.sha1(value.encode()).hexdigest()
+
+
+def sha256(value: str) -> str:
+ """Generate sha256 hash from a string."""
+ return hashlib.sha256(value.encode()).hexdigest()
+
+
+def sha512(value: str) -> str:
+ """Generate sha512 hash from a string."""
+ return hashlib.sha512(value.encode()).hexdigest()
+
+
class TemplateContextManager(AbstractContextManager):
"""Context manager to store template being parsed or rendered in a ContextVar."""
@@ -2879,100 +3057,127 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
str | jinja2.nodes.Template, CodeType | None
] = weakref.WeakValueDictionary()
self.add_extension("jinja2.ext.loopcontrols")
- self.filters["round"] = forgiving_round
- self.filters["multiply"] = multiply
- self.filters["add"] = add
- self.filters["log"] = logarithm
- self.filters["sin"] = sine
- self.filters["cos"] = cosine
- self.filters["tan"] = tangent
- self.filters["asin"] = arc_sine
- self.filters["acos"] = arc_cosine
- self.filters["atan"] = arc_tangent
- self.filters["atan2"] = arc_tangent2
- self.filters["sqrt"] = square_root
- self.filters["as_datetime"] = as_datetime
- self.filters["as_timedelta"] = as_timedelta
- self.filters["as_timestamp"] = forgiving_as_timestamp
- self.filters["as_local"] = dt_util.as_local
- self.filters["timestamp_custom"] = timestamp_custom
- self.filters["timestamp_local"] = timestamp_local
- self.filters["timestamp_utc"] = timestamp_utc
- self.filters["to_json"] = to_json
- self.filters["from_json"] = from_json
- self.filters["is_defined"] = fail_when_undefined
- self.filters["average"] = average
- self.filters["median"] = median
- self.filters["statistical_mode"] = statistical_mode
- self.filters["random"] = random_every_time
- self.filters["base64_encode"] = base64_encode
- self.filters["base64_decode"] = base64_decode
- self.filters["ordinal"] = ordinal
- self.filters["regex_match"] = regex_match
- self.filters["regex_replace"] = regex_replace
- self.filters["regex_search"] = regex_search
- self.filters["regex_findall"] = regex_findall
- self.filters["regex_findall_index"] = regex_findall_index
- self.filters["bitwise_and"] = bitwise_and
- self.filters["bitwise_or"] = bitwise_or
- self.filters["bitwise_xor"] = bitwise_xor
- self.filters["pack"] = struct_pack
- self.filters["unpack"] = struct_unpack
- self.filters["ord"] = ord
- self.filters["is_number"] = is_number
- self.filters["float"] = forgiving_float_filter
- self.filters["int"] = forgiving_int_filter
- self.filters["slugify"] = slugify
- self.filters["iif"] = iif
- self.filters["bool"] = forgiving_boolean
- self.filters["version"] = version
- self.filters["contains"] = contains
- self.globals["log"] = logarithm
- self.globals["sin"] = sine
- self.globals["cos"] = cosine
- self.globals["tan"] = tangent
- self.globals["sqrt"] = square_root
- self.globals["pi"] = math.pi
- self.globals["tau"] = math.pi * 2
- self.globals["e"] = math.e
- self.globals["asin"] = arc_sine
+
self.globals["acos"] = arc_cosine
- self.globals["atan"] = arc_tangent
- self.globals["atan2"] = arc_tangent2
- self.globals["float"] = forgiving_float
self.globals["as_datetime"] = as_datetime
self.globals["as_local"] = dt_util.as_local
self.globals["as_timedelta"] = as_timedelta
self.globals["as_timestamp"] = forgiving_as_timestamp
- self.globals["timedelta"] = timedelta
- self.globals["merge_response"] = merge_response
- self.globals["strptime"] = strptime
- self.globals["urlencode"] = urlencode
+ self.globals["asin"] = arc_sine
+ self.globals["atan"] = arc_tangent
+ self.globals["atan2"] = arc_tangent2
self.globals["average"] = average
- self.globals["median"] = median
- self.globals["statistical_mode"] = statistical_mode
- self.globals["max"] = min_max_from_filter(self.filters["max"], "max")
- self.globals["min"] = min_max_from_filter(self.filters["min"], "min")
- self.globals["is_number"] = is_number
- self.globals["set"] = _to_set
- self.globals["tuple"] = _to_tuple
- self.globals["int"] = forgiving_int
- self.globals["pack"] = struct_pack
- self.globals["unpack"] = struct_unpack
- self.globals["slugify"] = slugify
- self.globals["iif"] = iif
self.globals["bool"] = forgiving_boolean
+ self.globals["combine"] = combine
+ self.globals["cos"] = cosine
+ self.globals["difference"] = difference
+ self.globals["e"] = math.e
+ self.globals["flatten"] = flatten
+ self.globals["float"] = forgiving_float
+ self.globals["iif"] = iif
+ self.globals["int"] = forgiving_int
+ self.globals["intersect"] = intersect
+ self.globals["is_number"] = is_number
+ self.globals["log"] = logarithm
+ self.globals["max"] = min_max_from_filter(self.filters["max"], "max")
+ self.globals["md5"] = md5
+ self.globals["median"] = median
+ self.globals["merge_response"] = merge_response
+ self.globals["min"] = min_max_from_filter(self.filters["min"], "min")
+ self.globals["pack"] = struct_pack
+ self.globals["pi"] = math.pi
+ self.globals["set"] = _to_set
+ self.globals["sha1"] = sha1
+ self.globals["sha256"] = sha256
+ self.globals["sha512"] = sha512
+ self.globals["shuffle"] = shuffle
+ self.globals["sin"] = sine
+ self.globals["slugify"] = slugify
+ self.globals["sqrt"] = square_root
+ self.globals["statistical_mode"] = statistical_mode
+ self.globals["strptime"] = strptime
+ self.globals["symmetric_difference"] = symmetric_difference
+ self.globals["tan"] = tangent
+ self.globals["tau"] = math.pi * 2
+ self.globals["timedelta"] = timedelta
+ self.globals["tuple"] = _to_tuple
+ self.globals["typeof"] = typeof
+ self.globals["union"] = union
+ self.globals["unpack"] = struct_unpack
+ self.globals["urlencode"] = urlencode
self.globals["version"] = version
self.globals["zip"] = zip
+
+ self.filters["acos"] = arc_cosine
+ self.filters["add"] = add
+ self.filters["as_datetime"] = as_datetime
+ self.filters["as_local"] = dt_util.as_local
+ self.filters["as_timedelta"] = as_timedelta
+ self.filters["as_timestamp"] = forgiving_as_timestamp
+ self.filters["asin"] = arc_sine
+ self.filters["atan"] = arc_tangent
+ self.filters["atan2"] = arc_tangent2
+ self.filters["average"] = average
+ self.filters["base64_decode"] = base64_decode
+ self.filters["base64_encode"] = base64_encode
+ self.filters["bitwise_and"] = bitwise_and
+ self.filters["bitwise_or"] = bitwise_or
+ self.filters["bitwise_xor"] = bitwise_xor
+ self.filters["bool"] = forgiving_boolean
+ self.filters["combine"] = combine
+ self.filters["contains"] = contains
+ self.filters["cos"] = cosine
+ self.filters["difference"] = difference
+ self.filters["flatten"] = flatten
+ self.filters["float"] = forgiving_float_filter
+ self.filters["from_json"] = from_json
+ self.filters["iif"] = iif
+ self.filters["int"] = forgiving_int_filter
+ self.filters["intersect"] = intersect
+ self.filters["is_defined"] = fail_when_undefined
+ self.filters["is_number"] = is_number
+ self.filters["log"] = logarithm
+ self.filters["md5"] = md5
+ self.filters["median"] = median
+ self.filters["multiply"] = multiply
+ self.filters["ord"] = ord
+ self.filters["ordinal"] = ordinal
+ self.filters["pack"] = struct_pack
+ self.filters["random"] = random_every_time
+ self.filters["regex_findall_index"] = regex_findall_index
+ self.filters["regex_findall"] = regex_findall
+ self.filters["regex_match"] = regex_match
+ self.filters["regex_replace"] = regex_replace
+ self.filters["regex_search"] = regex_search
+ self.filters["round"] = forgiving_round
+ self.filters["sha1"] = sha1
+ self.filters["sha256"] = sha256
+ self.filters["sha512"] = sha512
+ self.filters["shuffle"] = shuffle
+ self.filters["sin"] = sine
+ self.filters["slugify"] = slugify
+ self.filters["sqrt"] = square_root
+ self.filters["statistical_mode"] = statistical_mode
+ self.filters["symmetric_difference"] = symmetric_difference
+ self.filters["tan"] = tangent
+ self.filters["timestamp_custom"] = timestamp_custom
+ self.filters["timestamp_local"] = timestamp_local
+ self.filters["timestamp_utc"] = timestamp_utc
+ self.filters["to_json"] = to_json
+ self.filters["typeof"] = typeof
+ self.filters["union"] = union
+ self.filters["unpack"] = struct_unpack
+ self.filters["version"] = version
+
+ self.tests["contains"] = contains
+ self.tests["datetime"] = _is_datetime
self.tests["is_number"] = is_number
self.tests["list"] = _is_list
- self.tests["set"] = _is_set
- self.tests["tuple"] = _is_tuple
- self.tests["datetime"] = _is_datetime
- self.tests["string_like"] = _is_string_like
self.tests["match"] = regex_match
self.tests["search"] = regex_search
- self.tests["contains"] = contains
+ self.tests["set"] = _is_set
+ self.tests["string_like"] = _is_string_like
+ self.tests["tuple"] = _is_tuple
if hass is None:
return
@@ -2999,28 +3204,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
return jinja_context(wrapper)
- self.globals["device_entities"] = hassfunction(device_entities)
- self.filters["device_entities"] = self.globals["device_entities"]
-
- self.globals["device_attr"] = hassfunction(device_attr)
- self.filters["device_attr"] = self.globals["device_attr"]
-
- self.globals["config_entry_attr"] = hassfunction(config_entry_attr)
- self.filters["config_entry_attr"] = self.globals["config_entry_attr"]
-
- self.globals["is_device_attr"] = hassfunction(is_device_attr)
- self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context)
-
- self.globals["config_entry_id"] = hassfunction(config_entry_id)
- self.filters["config_entry_id"] = self.globals["config_entry_id"]
-
- self.globals["device_id"] = hassfunction(device_id)
- self.filters["device_id"] = self.globals["device_id"]
-
- self.globals["issues"] = hassfunction(issues)
-
- self.globals["issue"] = hassfunction(issue)
- self.filters["issue"] = self.globals["issue"]
+ # Area extensions
self.globals["areas"] = hassfunction(areas)
@@ -3036,6 +3220,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["area_devices"] = hassfunction(area_devices)
self.filters["area_devices"] = self.globals["area_devices"]
+ # Floor extensions
+
self.globals["floors"] = hassfunction(floors)
self.filters["floors"] = self.globals["floors"]
@@ -3048,9 +3234,41 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["floor_areas"] = hassfunction(floor_areas)
self.filters["floor_areas"] = self.globals["floor_areas"]
+ self.globals["floor_entities"] = hassfunction(floor_entities)
+ self.filters["floor_entities"] = self.globals["floor_entities"]
+
+ # Integration extensions
+
self.globals["integration_entities"] = hassfunction(integration_entities)
self.filters["integration_entities"] = self.globals["integration_entities"]
+ # Config entry extensions
+
+ self.globals["config_entry_attr"] = hassfunction(config_entry_attr)
+ self.filters["config_entry_attr"] = self.globals["config_entry_attr"]
+
+ self.globals["config_entry_id"] = hassfunction(config_entry_id)
+ self.filters["config_entry_id"] = self.globals["config_entry_id"]
+
+ # Device extensions
+
+ self.globals["device_name"] = hassfunction(device_name)
+ self.filters["device_name"] = self.globals["device_name"]
+
+ self.globals["device_attr"] = hassfunction(device_attr)
+ self.filters["device_attr"] = self.globals["device_attr"]
+
+ self.globals["device_entities"] = hassfunction(device_entities)
+ self.filters["device_entities"] = self.globals["device_entities"]
+
+ self.globals["is_device_attr"] = hassfunction(is_device_attr)
+ self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context)
+
+ self.globals["device_id"] = hassfunction(device_id)
+ self.filters["device_id"] = self.globals["device_id"]
+
+ # Label extensions
+
self.globals["labels"] = hassfunction(labels)
self.filters["labels"] = self.globals["labels"]
@@ -3069,6 +3287,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["label_entities"] = hassfunction(label_entities)
self.filters["label_entities"] = self.globals["label_entities"]
+ # Issue extensions
+
+ self.globals["issues"] = hassfunction(issues)
+ self.globals["issue"] = hassfunction(issue)
+ self.filters["issue"] = self.globals["issue"]
+
if limited:
# Only device_entities is available to limited templates, mark other
# functions and filters as unsupported.
@@ -3081,38 +3305,38 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
return warn_unsupported
hass_globals = [
- "closest",
- "distance",
- "expand",
- "is_hidden_entity",
- "is_state",
- "is_state_attr",
- "state_attr",
- "states",
- "state_translated",
- "has_value",
- "utcnow",
- "now",
- "device_attr",
- "is_device_attr",
- "device_id",
"area_id",
"area_name",
+ "closest",
+ "device_attr",
+ "device_id",
+ "distance",
+ "expand",
"floor_id",
"floor_name",
+ "has_value",
+ "is_device_attr",
+ "is_hidden_entity",
+ "is_state_attr",
+ "is_state",
+ "label_id",
+ "label_name",
+ "now",
"relative_time",
+ "state_attr",
+ "state_translated",
+ "states",
"time_since",
"time_until",
"today_at",
- "label_id",
- "label_name",
+ "utcnow",
]
hass_filters = [
- "closest",
- "expand",
- "device_id",
"area_id",
"area_name",
+ "closest",
+ "device_id",
+ "expand",
"floor_id",
"floor_name",
"has_value",
@@ -3122,8 +3346,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
hass_tests = [
"has_value",
"is_hidden_entity",
- "is_state",
"is_state_attr",
+ "is_state",
]
for glob in hass_globals:
self.globals[glob] = unsupported(glob)
@@ -3133,38 +3357,46 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.filters[test] = unsupported(test)
return
- self.globals["expand"] = hassfunction(expand)
- self.filters["expand"] = self.globals["expand"]
self.globals["closest"] = hassfunction(closest)
- self.filters["closest"] = hassfunction(closest_filter)
self.globals["distance"] = hassfunction(distance)
+ self.globals["expand"] = hassfunction(expand)
+ self.globals["has_value"] = hassfunction(has_value)
+ self.globals["now"] = hassfunction(now)
+ self.globals["relative_time"] = hassfunction(relative_time)
+ self.globals["time_since"] = hassfunction(time_since)
+ self.globals["time_until"] = hassfunction(time_until)
+ self.globals["today_at"] = hassfunction(today_at)
+ self.globals["utcnow"] = hassfunction(utcnow)
+
+ self.filters["closest"] = hassfunction(closest_filter)
+ self.filters["expand"] = self.globals["expand"]
+ self.filters["has_value"] = self.globals["has_value"]
+ self.filters["relative_time"] = self.globals["relative_time"]
+ self.filters["time_since"] = self.globals["time_since"]
+ self.filters["time_until"] = self.globals["time_until"]
+ self.filters["today_at"] = self.globals["today_at"]
+
+ self.tests["has_value"] = hassfunction(has_value, pass_eval_context)
+
+ # Entity extensions
+
self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity)
self.tests["is_hidden_entity"] = hassfunction(
is_hidden_entity, pass_eval_context
)
- self.globals["is_state"] = hassfunction(is_state)
- self.tests["is_state"] = hassfunction(is_state, pass_eval_context)
+
+ # State extensions
+
self.globals["is_state_attr"] = hassfunction(is_state_attr)
- self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context)
+ self.globals["is_state"] = hassfunction(is_state)
self.globals["state_attr"] = hassfunction(state_attr)
- self.filters["state_attr"] = self.globals["state_attr"]
- self.globals["states"] = AllStates(hass)
- self.filters["states"] = self.globals["states"]
self.globals["state_translated"] = StateTranslated(hass)
+ self.globals["states"] = AllStates(hass)
+ self.filters["state_attr"] = self.globals["state_attr"]
self.filters["state_translated"] = self.globals["state_translated"]
- self.globals["has_value"] = hassfunction(has_value)
- self.filters["has_value"] = self.globals["has_value"]
- self.tests["has_value"] = hassfunction(has_value, pass_eval_context)
- self.globals["utcnow"] = hassfunction(utcnow)
- self.globals["now"] = hassfunction(now)
- self.globals["relative_time"] = hassfunction(relative_time)
- self.filters["relative_time"] = self.globals["relative_time"]
- self.globals["time_since"] = hassfunction(time_since)
- self.filters["time_since"] = self.globals["time_since"]
- self.globals["time_until"] = hassfunction(time_until)
- self.filters["time_until"] = self.globals["time_until"]
- self.globals["today_at"] = hassfunction(today_at)
- self.filters["today_at"] = self.globals["today_at"]
+ self.filters["states"] = self.globals["states"]
+ self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context)
+ self.tests["is_state"] = hassfunction(is_state, pass_eval_context)
def is_safe_callable(self, obj):
"""Test if callback is safe."""
diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py
index 67e9010df79..a27c85a5c58 100644
--- a/homeassistant/helpers/trigger.py
+++ b/homeassistant/helpers/trigger.py
@@ -265,18 +265,18 @@ def _trigger_action_wrapper(
while isinstance(check_func, functools.partial):
check_func = check_func.func
- wrapper_func: Callable[..., None] | Callable[..., Coroutine[Any, Any, None]]
+ wrapper_func: Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]]
if asyncio.iscoroutinefunction(check_func):
- async_action = cast(Callable[..., Coroutine[Any, Any, None]], action)
+ async_action = cast(Callable[..., Coroutine[Any, Any, Any]], action)
@functools.wraps(async_action)
async def async_with_vars(
run_variables: dict[str, Any], context: Context | None = None
- ) -> None:
+ ) -> Any:
"""Wrap action with extra vars."""
trigger_variables = conf[CONF_VARIABLES]
run_variables.update(trigger_variables.async_render(hass, run_variables))
- await action(run_variables, context)
+ return await action(run_variables, context)
wrapper_func = async_with_vars
@@ -285,11 +285,11 @@ def _trigger_action_wrapper(
@functools.wraps(action)
async def with_vars(
run_variables: dict[str, Any], context: Context | None = None
- ) -> None:
+ ) -> Any:
"""Wrap action with extra vars."""
trigger_variables = conf[CONF_VARIABLES]
run_variables.update(trigger_variables.async_render(hass, run_variables))
- action(run_variables, context)
+ return action(run_variables, context)
if is_callback(check_func):
with_vars = callback(with_vars)
diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py
index 645581701af..3fe2d6648ab 100644
--- a/homeassistant/helpers/update_coordinator.py
+++ b/homeassistant/helpers/update_coordinator.py
@@ -358,8 +358,8 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_DataT]):
only once during the first refresh.
"""
if self.setup_method is None:
- return None
- return await self.setup_method()
+ return
+ await self.setup_method()
async def async_refresh(self) -> None:
"""Refresh data and log errors."""
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index 92b588dbe15..2498cf39ffe 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -18,7 +18,7 @@ import pathlib
import sys
import time
from types import ModuleType
-from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast
+from typing import TYPE_CHECKING, Any, Literal, Protocol, TypedDict, cast, final
from awesomeversion import (
AwesomeVersion,
@@ -40,7 +40,8 @@ from .generated.ssdp import SSDP
from .generated.usb import USB
from .generated.zeroconf import HOMEKIT, ZEROCONF
from .helpers.json import json_bytes, json_fragment
-from .helpers.typing import UNDEFINED
+from .helpers.typing import UNDEFINED, UndefinedType
+from .util.async_ import create_eager_task
from .util.hass_dict import HassKey
from .util.json import JSON_DECODE_EXCEPTIONS, json_loads
@@ -125,9 +126,9 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = {
DATA_COMPONENTS: HassKey[dict[str, ModuleType | ComponentProtocol]] = HassKey(
"components"
)
-DATA_INTEGRATIONS: HassKey[dict[str, Integration | asyncio.Future[None]]] = HassKey(
- "integrations"
-)
+DATA_INTEGRATIONS: HassKey[
+ dict[str, Integration | asyncio.Future[Integration | IntegrationNotFound]]
+] = HassKey("integrations")
DATA_MISSING_PLATFORMS: HassKey[dict[str, bool]] = HassKey("missing_platforms")
DATA_CUSTOM_COMPONENTS: HassKey[
dict[str, Integration] | asyncio.Future[dict[str, Integration]]
@@ -645,6 +646,7 @@ def async_register_preload_platform(hass: HomeAssistant, platform_name: str) ->
preload_platforms.append(platform_name)
+@final # Final to allow direct checking of the type instead of using isinstance
class Integration:
"""An integration in Home Assistant."""
@@ -759,10 +761,8 @@ class Integration:
manifest["overwrites_built_in"] = self.overwrites_built_in
if self.dependencies:
- self._all_dependencies_resolved: bool | None = None
- self._all_dependencies: set[str] | None = None
+ self._all_dependencies: set[str] | Exception | None = None
else:
- self._all_dependencies_resolved = True
self._all_dependencies = set()
self._platforms_to_preload = hass.data[DATA_PRELOAD_PLATFORMS]
@@ -934,47 +934,25 @@ class Integration:
"""Return all dependencies including sub-dependencies."""
if self._all_dependencies is None:
raise RuntimeError("Dependencies not resolved!")
+ if isinstance(self._all_dependencies, Exception):
+ raise self._all_dependencies
return self._all_dependencies
@property
def all_dependencies_resolved(self) -> bool:
"""Return if all dependencies have been resolved."""
- return self._all_dependencies_resolved is not None
+ return self._all_dependencies is not None
- async def resolve_dependencies(self) -> bool:
+ async def resolve_dependencies(self) -> set[str] | None:
"""Resolve all dependencies."""
- if self._all_dependencies_resolved is not None:
- return self._all_dependencies_resolved
+ if self._all_dependencies is not None:
+ if isinstance(self._all_dependencies, Exception):
+ return None
+ return self._all_dependencies
- self._all_dependencies_resolved = False
- try:
- dependencies = await _async_component_dependencies(self.hass, self)
- except IntegrationNotFound as err:
- _LOGGER.error(
- (
- "Unable to resolve dependencies for %s: unable to resolve"
- " (sub)dependency %s"
- ),
- self.domain,
- err.domain,
- )
- except CircularDependency as err:
- _LOGGER.error(
- (
- "Unable to resolve dependencies for %s: it contains a circular"
- " dependency: %s -> %s"
- ),
- self.domain,
- err.from_domain,
- err.to_domain,
- )
- else:
- dependencies.discard(self.domain)
- self._all_dependencies = dependencies
- self._all_dependencies_resolved = True
-
- return self._all_dependencies_resolved
+ result = await resolve_integrations_dependencies(self.hass, (self,))
+ return result.get(self.domain)
async def async_get_component(self) -> ComponentProtocol:
"""Return the component.
@@ -1345,7 +1323,7 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio
Raises IntegrationNotLoaded if the integration is not loaded.
"""
cache = hass.data[DATA_INTEGRATIONS]
- int_or_fut = cache.get(domain, UNDEFINED)
+ int_or_fut = cache.get(domain)
# Integration is never subclassed, so we can check for type
if type(int_or_fut) is Integration:
return int_or_fut
@@ -1355,7 +1333,7 @@ def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integratio
async def async_get_integration(hass: HomeAssistant, domain: str) -> Integration:
"""Get integration."""
cache = hass.data[DATA_INTEGRATIONS]
- if type(int_or_fut := cache.get(domain, UNDEFINED)) is Integration:
+ if type(int_or_fut := cache.get(domain)) is Integration:
return int_or_fut
integrations_or_excs = await async_get_integrations(hass, [domain])
int_or_exc = integrations_or_excs[domain]
@@ -1370,15 +1348,17 @@ async def async_get_integrations(
"""Get integrations."""
cache = hass.data[DATA_INTEGRATIONS]
results: dict[str, Integration | Exception] = {}
- needed: dict[str, asyncio.Future[None]] = {}
- in_progress: dict[str, asyncio.Future[None]] = {}
+ needed: dict[str, asyncio.Future[Integration | IntegrationNotFound]] = {}
+ in_progress: dict[str, asyncio.Future[Integration | IntegrationNotFound]] = {}
for domain in domains:
- int_or_fut = cache.get(domain, UNDEFINED)
+ int_or_fut = cache.get(domain)
# Integration is never subclassed, so we can check for type
if type(int_or_fut) is Integration:
results[domain] = int_or_fut
- elif int_or_fut is not UNDEFINED:
- in_progress[domain] = cast(asyncio.Future[None], int_or_fut)
+ elif int_or_fut:
+ if TYPE_CHECKING:
+ assert isinstance(int_or_fut, asyncio.Future)
+ in_progress[domain] = int_or_fut
elif "." in domain:
results[domain] = ValueError(f"Invalid domain {domain}")
else:
@@ -1386,14 +1366,13 @@ async def async_get_integrations(
if in_progress:
await asyncio.wait(in_progress.values())
- for domain in in_progress:
- # When we have waited and it's UNDEFINED, it doesn't exist
- # We don't cache that it doesn't exist, or else people can't fix it
- # and then restart, because their config will never be valid.
- if (int_or_fut := cache.get(domain, UNDEFINED)) is UNDEFINED:
- results[domain] = IntegrationNotFound(domain)
- else:
- results[domain] = cast(Integration, int_or_fut)
+ # Here we retrieve the results we waited for
+ # instead of reading them from the cache since
+ # reading from the cache will have a race if
+ # the integration gets removed from the cache
+ # because it was not found.
+ for domain, future in in_progress.items():
+ results[domain] = future.result()
if not needed:
return results
@@ -1405,7 +1384,7 @@ async def async_get_integrations(
for domain, future in needed.items():
if integration := custom.get(domain):
results[domain] = cache[domain] = integration
- future.set_result(None)
+ future.set_result(integration)
for domain in results:
if domain in needed:
@@ -1419,22 +1398,213 @@ async def async_get_integrations(
_resolve_integrations_from_root, hass, components, needed
)
for domain, future in needed.items():
- int_or_exc = integrations.get(domain)
- if not int_or_exc:
- cache.pop(domain)
- results[domain] = IntegrationNotFound(domain)
- elif isinstance(int_or_exc, Exception):
- cache.pop(domain)
- exc = IntegrationNotFound(domain)
- exc.__cause__ = int_or_exc
- results[domain] = exc
+ if integration := integrations.get(domain):
+ results[domain] = cache[domain] = integration
+ future.set_result(integration)
else:
- results[domain] = cache[domain] = int_or_exc
- future.set_result(None)
+ # We don't cache that it doesn't exist as configuration
+ # validation that relies on integrations being loaded
+ # would be unfixable. For example if a custom integration
+ # was temporarily removed.
+ # This allows restoring a missing integration to fix the
+ # validation error so the config validations checks do not
+ # block restarting.
+ del cache[domain]
+ exc = IntegrationNotFound(domain)
+ results[domain] = exc
+ # We don't use set_exception because
+ # we expect there will be cases where
+ # the future exception is never retrieved
+ future.set_result(exc)
return results
+class _ResolveDependenciesCacheProtocol(Protocol):
+ def get(self, itg: Integration) -> set[str] | Exception | None: ...
+
+ def __setitem__(
+ self, itg: Integration, all_dependencies: set[str] | Exception
+ ) -> None: ...
+
+
+class _ResolveDependenciesCache(_ResolveDependenciesCacheProtocol):
+ """Cache for resolve_integrations_dependencies."""
+
+ def get(self, itg: Integration) -> set[str] | Exception | None:
+ return itg._all_dependencies # noqa: SLF001
+
+ def __setitem__(
+ self, itg: Integration, all_dependencies: set[str] | Exception
+ ) -> None:
+ itg._all_dependencies = all_dependencies # noqa: SLF001
+
+
+async def resolve_integrations_dependencies(
+ hass: HomeAssistant, integrations: Iterable[Integration]
+) -> dict[str, set[str]]:
+ """Resolve all dependencies for integrations.
+
+ Detects circular dependencies and missing integrations.
+ """
+ return await _resolve_integrations_dependencies(
+ hass,
+ "resolve dependencies",
+ integrations,
+ cache=_ResolveDependenciesCache(),
+ ignore_exceptions=False,
+ )
+
+
+async def resolve_integrations_after_dependencies(
+ hass: HomeAssistant,
+ integrations: Iterable[Integration],
+ possible_after_dependencies: set[str] | None = None,
+ *,
+ ignore_exceptions: bool = False,
+) -> dict[str, set[str]]:
+ """Resolve all dependencies, including after_dependencies, for integrations.
+
+ Detects circular dependencies and missing integrations.
+ """
+ return await _resolve_integrations_dependencies(
+ hass,
+ "resolve (after) dependencies",
+ integrations,
+ cache={},
+ possible_after_dependencies=possible_after_dependencies,
+ ignore_exceptions=ignore_exceptions,
+ )
+
+
+async def _resolve_integrations_dependencies(
+ hass: HomeAssistant,
+ name: str,
+ integrations: Iterable[Integration],
+ *,
+ cache: _ResolveDependenciesCacheProtocol,
+ possible_after_dependencies: set[str] | None | UndefinedType = UNDEFINED,
+ ignore_exceptions: bool,
+) -> dict[str, set[str]]:
+ """Resolve all dependencies, possibly including after_dependencies, for integrations.
+
+ Detects circular dependencies and missing integrations.
+ """
+
+ async def _resolve_deps_catch_exceptions(itg: Integration) -> set[str] | None:
+ try:
+ return await _resolve_integration_dependencies(
+ itg,
+ cache=cache,
+ possible_after_dependencies=possible_after_dependencies,
+ ignore_exceptions=ignore_exceptions,
+ )
+ except Exception as exc: # noqa: BLE001
+ _LOGGER.error("Unable to %s for %s: %s", name, itg.domain, exc)
+ return None
+
+ resolve_dependencies_tasks = {
+ itg.domain: create_eager_task(
+ _resolve_deps_catch_exceptions(itg),
+ name=f"{name} {itg.domain}",
+ loop=hass.loop,
+ )
+ for itg in integrations
+ }
+
+ result = await asyncio.gather(*resolve_dependencies_tasks.values())
+
+ return {
+ domain: deps
+ for domain, deps in zip(resolve_dependencies_tasks, result, strict=True)
+ if deps is not None
+ }
+
+
+async def _resolve_integration_dependencies(
+ itg: Integration,
+ *,
+ cache: _ResolveDependenciesCacheProtocol,
+ possible_after_dependencies: set[str] | None | UndefinedType = UNDEFINED,
+ ignore_exceptions: bool = False,
+) -> set[str]:
+ """Recursively resolve all dependencies.
+
+ Uses `cache` to cache the results.
+
+ If `possible_after_dependencies` is not UNDEFINED,
+ listed after dependencies are also considered.
+ If `possible_after_dependencies` is None,
+ all the possible after dependencies are considered.
+
+ If `ignore_exceptions` is True, exceptions are caught and ignored
+ and the normal resolution algorithm continues.
+ Otherwise, exceptions are raised.
+ """
+ resolved = cache
+ resolving: set[str] = set()
+
+ async def resolve_dependencies_impl(itg: Integration) -> set[str]:
+ domain = itg.domain
+
+ # If it's already resolved, no point doing it again.
+ if (result := resolved.get(itg)) is not None:
+ if isinstance(result, Exception):
+ raise result
+ return result
+
+ # If we are already resolving it, we have a circular dependency.
+ if domain in resolving:
+ if ignore_exceptions:
+ resolved[itg] = set()
+ return set()
+ exc = CircularDependency([domain])
+ resolved[itg] = exc
+ raise exc
+
+ resolving.add(domain)
+
+ dependencies_domains = set(itg.dependencies)
+ if possible_after_dependencies is not UNDEFINED:
+ if possible_after_dependencies is None:
+ after_dependencies: Iterable[str] = itg.after_dependencies
+ else:
+ after_dependencies = (
+ set(itg.after_dependencies) & possible_after_dependencies
+ )
+ dependencies_domains.update(after_dependencies)
+ dependencies = await async_get_integrations(itg.hass, dependencies_domains)
+
+ all_dependencies: set[str] = set()
+ for dep_domain, dep_integration in dependencies.items():
+ if isinstance(dep_integration, Exception):
+ if ignore_exceptions:
+ continue
+ resolved[itg] = dep_integration
+ raise dep_integration
+
+ all_dependencies.add(dep_domain)
+
+ try:
+ dep_dependencies = await resolve_dependencies_impl(dep_integration)
+ except CircularDependency as exc:
+ exc.extend_cycle(domain)
+ resolved[itg] = exc
+ raise
+ except Exception as exc:
+ resolved[itg] = exc
+ raise
+
+ all_dependencies.update(dep_dependencies)
+
+ resolving.remove(domain)
+
+ resolved[itg] = all_dependencies
+ return all_dependencies
+
+ return await resolve_dependencies_impl(itg)
+
+
class LoaderError(Exception):
"""Loader base error."""
@@ -1460,11 +1630,13 @@ class IntegrationNotLoaded(LoaderError):
class CircularDependency(LoaderError):
"""Raised when a circular dependency is found when resolving components."""
- def __init__(self, from_domain: str | set[str], to_domain: str) -> None:
+ def __init__(self, domain_cycle: list[str]) -> None:
"""Initialize circular dependency error."""
- super().__init__(f"Circular dependency detected: {from_domain} -> {to_domain}.")
- self.from_domain = from_domain
- self.to_domain = to_domain
+ super().__init__("Circular dependency detected", domain_cycle)
+
+ def extend_cycle(self, domain: str) -> None:
+ """Extend the cycle with the domain."""
+ self.args[1].insert(0, domain)
def _load_file(
@@ -1618,50 +1790,6 @@ def bind_hass[_CallableT: Callable[..., Any]](func: _CallableT) -> _CallableT:
return func
-async def _async_component_dependencies(
- hass: HomeAssistant,
- integration: Integration,
-) -> set[str]:
- """Get component dependencies."""
- loading: set[str] = set()
- loaded: set[str] = set()
-
- async def component_dependencies_impl(integration: Integration) -> None:
- """Recursively get component dependencies."""
- domain = integration.domain
- if not (dependencies := integration.dependencies):
- loaded.add(domain)
- return
-
- loading.add(domain)
- dep_integrations = await async_get_integrations(hass, dependencies)
- for dependency_domain, dep_integration in dep_integrations.items():
- if isinstance(dep_integration, Exception):
- raise dep_integration
-
- # If we are already loading it, we have a circular dependency.
- # We have to check it here to make sure that every integration that
- # depends on us, does not appear in our own after_dependencies.
- if conflict := loading.intersection(dep_integration.after_dependencies):
- raise CircularDependency(conflict, dependency_domain)
-
- # If we have already loaded it, no point doing it again.
- if dependency_domain in loaded:
- continue
-
- # If we are already loading it, we have a circular dependency.
- if dependency_domain in loading:
- raise CircularDependency(dependency_domain, domain)
-
- await component_dependencies_impl(dep_integration)
- loading.remove(domain)
- loaded.add(domain)
-
- await component_dependencies_impl(integration)
-
- return loaded
-
-
def _async_mount_config_dir(hass: HomeAssistant) -> None:
"""Mount config dir in order to load custom_component.
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 0f53b732c13..3baebae8a6e 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -1,54 +1,56 @@
# Automatically generated by gen_requirements_all.py, do not edit
-aiodhcpwatcher==1.1.0
-aiodiscover==2.2.2
+aiodhcpwatcher==1.1.1
+aiodiscover==2.6.1
aiodns==3.2.0
-aiohasupervisor==0.3.0
-aiohttp-asyncmdnsresolver==0.1.0
-aiohttp-fast-zlib==0.2.2
-aiohttp==3.11.12
+aiohasupervisor==0.3.1b1
+aiohttp-asyncmdnsresolver==0.1.1
+aiohttp-fast-zlib==0.2.3
+aiohttp==3.11.16
aiohttp_cors==0.7.0
aiousbwatcher==1.1.1
aiozoneinfo==0.2.3
+annotatedyaml==0.4.5
astral==2.2
-async-interrupt==1.2.1
-async-upnp-client==0.43.0
+async-interrupt==1.2.2
+async-upnp-client==0.44.0
atomicwrites-homeassistant==1.4.1
attrs==25.1.0
audioop-lts==0.2.1
av==13.1.0
awesomeversion==24.6.0
bcrypt==4.2.0
-bleak-retry-connector==3.8.1
+bleak-retry-connector==3.9.0
bleak==0.22.3
bluetooth-adapters==0.21.4
-bluetooth-auto-recovery==1.4.2
-bluetooth-data-tools==1.23.4
-cached-ipaddress==0.8.0
+bluetooth-auto-recovery==1.4.5
+bluetooth-data-tools==1.27.0
+cached-ipaddress==0.10.0
certifi>=2021.5.30
ciso8601==2.3.2
cronsim==2.6
-cryptography==44.0.0
-dbus-fast==2.33.0
-fnv-hash-fast==1.2.2
+cryptography==44.0.1
+dbus-fast==2.43.0
+fnv-hash-fast==1.4.0
go2rtc-client==0.1.2
ha-ffmpeg==3.2.2
-habluetooth==3.21.1
-hass-nabucasa==0.89.0
+habluetooth==3.39.0
+hass-nabucasa==0.94.0
hassil==2.2.3
home-assistant-bluetooth==1.13.1
-home-assistant-frontend==20250205.0
-home-assistant-intents==2025.2.5
+home-assistant-frontend==20250411.0
+home-assistant-intents==2025.3.28
httpx==0.28.1
ifaddr==0.2.0
-Jinja2==3.1.5
+Jinja2==3.1.6
lru-dict==1.3.0
mutagen==1.47.0
-orjson==3.10.12
+numpy==2.2.2
+orjson==3.10.16
packaging>=23.1
paho-mqtt==2.1.0
-Pillow==11.1.0
-propcache==0.2.1
+Pillow==11.2.1
+propcache==0.3.1
psutil-home-assistant==0.0.1
PyJWT==2.10.1
pymicro-vad==1.0.1
@@ -60,20 +62,20 @@ python-slugify==8.0.4
PyTurboJPEG==1.7.5
PyYAML==6.0.2
requests==2.32.3
-securetar==2025.1.4
-SQLAlchemy==2.0.38
+securetar==2025.2.1
+SQLAlchemy==2.0.40
standard-aifc==3.13.0
standard-telnetlib==3.13.0
-typing-extensions>=4.12.2,<5.0
-ulid-transform==1.2.0
+typing-extensions>=4.13.0,<5.0
+ulid-transform==1.4.0
urllib3>=1.26.5,<2
-uv==0.5.27
+uv==0.6.10
voluptuous-openapi==0.0.6
voluptuous-serialize==2.6.0
voluptuous==0.15.2
webrtc-models==0.3.0
-yarl==1.18.3
-zeroconf==0.143.0
+yarl==1.20.0
+zeroconf==0.146.5
# Constrain pycryptodome to avoid vulnerability
# see https://github.com/home-assistant/core/pull/16238
@@ -86,9 +88,9 @@ httplib2>=0.19.0
# gRPC is an implicit dependency that we want to make explicit so we manage
# upgrades intentionally. It is a large package to build from source and we
# want to ensure we have wheels built.
-grpcio==1.67.1
-grpcio-status==1.67.1
-grpcio-reflection==1.67.1
+grpcio==1.71.0
+grpcio-status==1.71.0
+grpcio-reflection==1.71.0
# This is a old unmaintained library and is replaced with pycryptodome
pycrypto==1000000000.0.0
@@ -108,7 +110,7 @@ uuid==1000000000.0.0
# these requirements are quite loose. As the entire stack has some outstanding issues, and
# even newer versions seem to introduce new issues, it's useful for us to pin all these
# requirements so we can directly link HA versions to these library versions.
-anyio==4.8.0
+anyio==4.9.0
h11==0.14.0
httpcore==1.0.7
@@ -128,7 +130,7 @@ multidict>=6.0.2
backoff>=2.0
# ensure pydantic version does not float since it might have breaking changes
-pydantic==2.10.6
+pydantic==2.11.3
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1
@@ -210,3 +212,8 @@ async-timeout==4.0.3
# https://github.com/home-assistant/core/issues/122508
# https://github.com/home-assistant/core/issues/118004
aiofiles>=24.1.0
+
+# multidict < 6.4.0 has memory leaks
+# https://github.com/aio-libs/multidict/issues/1134
+# https://github.com/aio-libs/multidict/issues/1131
+multidict>=6.4.2
diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py
index a24568e9a6f..981f0a26926 100644
--- a/homeassistant/scripts/check_config.py
+++ b/homeassistant/scripts/check_config.py
@@ -12,6 +12,9 @@ import os
from typing import Any
from unittest.mock import patch
+from annotatedyaml import loader as yaml_loader
+from annotatedyaml.loader import Secrets
+
from homeassistant import core, loader
from homeassistant.config import get_default_config_dir
from homeassistant.config_entries import ConfigEntries
@@ -23,17 +26,16 @@ from homeassistant.helpers import (
issue_registry as ir,
)
from homeassistant.helpers.check_config import async_check_ha_config_file
-from homeassistant.util.yaml import Secrets, loader as yaml_loader
# mypy: allow-untyped-calls, allow-untyped-defs
-REQUIREMENTS = ("colorlog==6.8.2",)
+REQUIREMENTS = ("colorlog==6.9.0",)
_LOGGER = logging.getLogger(__name__)
MOCKS: dict[str, tuple[str, Callable]] = {
- "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml),
+ "load": ("annotatedyaml.loader.load_yaml", yaml_loader.load_yaml),
"load*": ("homeassistant.config.load_yaml_dict", yaml_loader.load_yaml_dict),
- "secrets": ("homeassistant.util.yaml.loader.secret_yaml", yaml_loader.secret_yaml),
+ "secrets": ("annotatedyaml.loader.secret_yaml", yaml_loader.secret_yaml),
}
PATCHES: dict[str, Any] = {}
diff --git a/homeassistant/setup.py b/homeassistant/setup.py
index dc4d0988b91..39f0a7656f3 100644
--- a/homeassistant/setup.py
+++ b/homeassistant/setup.py
@@ -45,36 +45,36 @@ _LOGGER = logging.getLogger(__name__)
ATTR_COMPONENT: Final = "component"
-# DATA_SETUP is a dict, indicating domains which are currently
+# _DATA_SETUP is a dict, indicating domains which are currently
# being setup or which failed to setup:
-# - Tasks are added to DATA_SETUP by `async_setup_component`, the key is the domain
+# - Tasks are added to _DATA_SETUP by `async_setup_component`, the key is the domain
# being setup and the Task is the `_async_setup_component` helper.
-# - Tasks are removed from DATA_SETUP if setup was successful, that is,
+# - Tasks are removed from _DATA_SETUP if setup was successful, that is,
# the task returned True.
-DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks")
+_DATA_SETUP: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_tasks")
-# DATA_SETUP_DONE is a dict, indicating components which will be setup:
-# - Events are added to DATA_SETUP_DONE during bootstrap by
+# _DATA_SETUP_DONE is a dict, indicating components which will be setup:
+# - Events are added to _DATA_SETUP_DONE during bootstrap by
# async_set_domains_to_be_loaded, the key is the domain which will be loaded.
-# - Events are set and removed from DATA_SETUP_DONE when async_setup_component
+# - Events are set and removed from _DATA_SETUP_DONE when async_setup_component
# is finished, regardless of if the setup was successful or not.
-DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done")
+_DATA_SETUP_DONE: HassKey[dict[str, asyncio.Future[bool]]] = HassKey("setup_done")
-# DATA_SETUP_STARTED is a dict, indicating when an attempt
+# _DATA_SETUP_STARTED is a dict, indicating when an attempt
# to setup a component started.
-DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey(
+_DATA_SETUP_STARTED: HassKey[dict[tuple[str, str | None], float]] = HassKey(
"setup_started"
)
-# DATA_SETUP_TIME is a defaultdict, indicating how time was spent
+# _DATA_SETUP_TIME is a defaultdict, indicating how time was spent
# setting up a component.
-DATA_SETUP_TIME: HassKey[
+_DATA_SETUP_TIME: HassKey[
defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]
] = HassKey("setup_time")
-DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed")
+_DATA_DEPS_REQS: HassKey[set[str]] = HassKey("deps_reqs_processed")
-DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey(
+_DATA_PERSISTENT_ERRORS: HassKey[dict[str, str | None]] = HassKey(
"bootstrap_persistent_errors"
)
@@ -104,8 +104,8 @@ def async_notify_setup_error(
# pylint: disable-next=import-outside-toplevel
from .components import persistent_notification
- if (errors := hass.data.get(DATA_PERSISTENT_ERRORS)) is None:
- errors = hass.data[DATA_PERSISTENT_ERRORS] = {}
+ if (errors := hass.data.get(_DATA_PERSISTENT_ERRORS)) is None:
+ errors = hass.data[_DATA_PERSISTENT_ERRORS] = {}
errors[component] = errors.get(component) or display_link
@@ -131,8 +131,8 @@ def async_set_domains_to_be_loaded(hass: core.HomeAssistant, domains: set[str])
- Properly handle after_dependencies.
- Keep track of domains which will load but have not yet finished loading
"""
- setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {})
- setup_futures = hass.data.setdefault(DATA_SETUP, {})
+ setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {})
+ setup_futures = hass.data.setdefault(_DATA_SETUP, {})
old_domains = set(setup_futures) | set(setup_done_futures) | hass.config.components
if overlap := old_domains & domains:
_LOGGER.debug("Domains to be loaded %s already loaded or pending", overlap)
@@ -158,8 +158,8 @@ async def async_setup_component(
if domain in hass.config.components:
return True
- setup_futures = hass.data.setdefault(DATA_SETUP, {})
- setup_done_futures = hass.data.setdefault(DATA_SETUP_DONE, {})
+ setup_futures = hass.data.setdefault(_DATA_SETUP, {})
+ setup_done_futures = hass.data.setdefault(_DATA_SETUP_DONE, {})
if existing_setup_future := setup_futures.get(domain):
return await existing_setup_future
@@ -200,30 +200,42 @@ async def _async_process_dependencies(
Returns a list of dependencies which failed to set up.
"""
- setup_futures = hass.data.setdefault(DATA_SETUP, {})
+ setup_futures = hass.data.setdefault(_DATA_SETUP, {})
- dependencies_tasks = {
- dep: setup_futures.get(dep)
- or create_eager_task(
- async_setup_component(hass, dep, config),
- name=f"setup {dep} as dependency of {integration.domain}",
- loop=hass.loop,
- )
- for dep in integration.dependencies
- if dep not in hass.config.components
- }
+ dependencies_tasks: dict[str, asyncio.Future[bool]] = {}
- after_dependencies_tasks: dict[str, asyncio.Future[bool]] = {}
- to_be_loaded = hass.data.get(DATA_SETUP_DONE, {})
+ for dep in integration.dependencies:
+ fut = setup_futures.get(dep)
+ if fut is None:
+ if dep in hass.config.components:
+ continue
+ fut = create_eager_task(
+ async_setup_component(hass, dep, config),
+ name=f"setup {dep} as dependency of {integration.domain}",
+ loop=hass.loop,
+ )
+ dependencies_tasks[dep] = fut
+
+ to_be_loaded = hass.data.get(_DATA_SETUP_DONE, {})
+ # We don't want to just wait for the futures from `to_be_loaded` here.
+ # We want to ensure that our after_dependencies are always actually
+ # scheduled to be set up, as if for whatever reason they had not been,
+ # we would deadlock waiting for them here.
for dep in integration.after_dependencies:
- if (
- dep not in dependencies_tasks
- and dep in to_be_loaded
- and dep not in hass.config.components
- ):
- after_dependencies_tasks[dep] = to_be_loaded[dep]
+ if dep not in to_be_loaded or dep in dependencies_tasks:
+ continue
+ fut = setup_futures.get(dep)
+ if fut is None:
+ if dep in hass.config.components:
+ continue
+ fut = create_eager_task(
+ async_setup_component(hass, dep, config),
+ name=f"setup {dep} as after dependency of {integration.domain}",
+ loop=hass.loop,
+ )
+ dependencies_tasks[dep] = fut
- if not dependencies_tasks and not after_dependencies_tasks:
+ if not dependencies_tasks:
return []
if dependencies_tasks:
@@ -232,17 +244,9 @@ async def _async_process_dependencies(
integration.domain,
dependencies_tasks.keys(),
)
- if after_dependencies_tasks:
- _LOGGER.debug(
- "Dependency %s will wait for after dependencies %s",
- integration.domain,
- after_dependencies_tasks.keys(),
- )
async with hass.timeout.async_freeze(integration.domain):
- results = await asyncio.gather(
- *dependencies_tasks.values(), *after_dependencies_tasks.values()
- )
+ results = await asyncio.gather(*dependencies_tasks.values())
failed = [
domain for idx, domain in enumerate(dependencies_tasks) if not results[idx]
@@ -323,7 +327,7 @@ async def _async_setup_component(
translation.async_load_integrations(hass, integration_set), loop=hass.loop
)
# Validate all dependencies exist and there are no circular dependencies
- if not await integration.resolve_dependencies():
+ if await integration.resolve_dependencies() is None:
return False
# Process requirements as soon as possible, so we can import the component
@@ -479,7 +483,7 @@ async def _async_setup_component(
)
# Cleanup
- hass.data[DATA_SETUP].pop(domain, None)
+ hass.data[_DATA_SETUP].pop(domain, None)
hass.bus.async_fire_internal(
EVENT_COMPONENT_LOADED, EventComponentLoaded(component=domain)
@@ -569,8 +573,8 @@ async def async_process_deps_reqs(
Module is a Python module of either a component or platform.
"""
- if (processed := hass.data.get(DATA_DEPS_REQS)) is None:
- processed = hass.data[DATA_DEPS_REQS] = set()
+ if (processed := hass.data.get(_DATA_DEPS_REQS)) is None:
+ processed = hass.data[_DATA_DEPS_REQS] = set()
elif integration.domain in processed:
return
@@ -685,7 +689,7 @@ class SetupPhases(StrEnum):
"""Wait time for the packages to import."""
-@singleton.singleton(DATA_SETUP_STARTED)
+@singleton.singleton(_DATA_SETUP_STARTED)
def _setup_started(
hass: core.HomeAssistant,
) -> dict[tuple[str, str | None], float]:
@@ -728,7 +732,7 @@ def async_pause_setup(hass: core.HomeAssistant, phase: SetupPhases) -> Generator
)
-@singleton.singleton(DATA_SETUP_TIME)
+@singleton.singleton(_DATA_SETUP_TIME)
def _setup_times(
hass: core.HomeAssistant,
) -> defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]:
@@ -828,3 +832,11 @@ def async_get_domain_setup_times(
) -> Mapping[str | None, dict[SetupPhases, float]]:
"""Return timing data for each integration."""
return _setup_times(hass).get(domain, {})
+
+
+async def async_wait_component(hass: HomeAssistant, domain: str) -> bool:
+ """Wait until a component is set up if pending, then return if it is set up."""
+ setup_done = hass.data.get(_DATA_SETUP_DONE, {})
+ if setup_future := setup_done.get(domain):
+ await setup_future
+ return domain in hass.config.components
diff --git a/homeassistant/strings.json b/homeassistant/strings.json
index fca55353aa0..51148108cd4 100644
--- a/homeassistant/strings.json
+++ b/homeassistant/strings.json
@@ -1,13 +1,102 @@
{
"common": {
- "generic": {
- "model": "Model",
- "ui_managed": "Managed via UI"
+ "action": {
+ "close": "Close",
+ "connect": "Connect",
+ "disable": "Disable",
+ "disconnect": "Disconnect",
+ "enable": "Enable",
+ "open": "Open",
+ "pause": "Pause",
+ "reload": "Reload",
+ "restart": "Restart",
+ "start": "Start",
+ "stop": "Stop",
+ "toggle": "Toggle",
+ "turn_off": "Turn off",
+ "turn_on": "Turn on"
+ },
+ "config_flow": {
+ "abort": {
+ "already_configured_account": "Account is already configured",
+ "already_configured_device": "Device is already configured",
+ "already_configured_location": "Location is already configured",
+ "already_configured_service": "Service is already configured",
+ "already_in_progress": "Configuration flow is already in progress",
+ "cloud_not_connected": "Not connected to Home Assistant Cloud.",
+ "no_devices_found": "No devices found on the network",
+ "oauth2_authorize_url_timeout": "Timeout generating authorize URL.",
+ "oauth2_error": "Received invalid token data.",
+ "oauth2_failed": "Error while obtaining access token.",
+ "oauth2_missing_configuration": "The component is not configured. Please follow the documentation.",
+ "oauth2_missing_credentials": "The integration requires application credentials.",
+ "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})",
+ "oauth2_timeout": "Timeout resolving OAuth token.",
+ "oauth2_unauthorized": "OAuth authorization error while obtaining access token.",
+ "oauth2_user_rejected_authorize": "Account linking rejected: {error}",
+ "reauth_successful": "Re-authentication was successful",
+ "reconfigure_successful": "Re-configuration was successful",
+ "single_instance_allowed": "Already configured. Only a single configuration possible.",
+ "unknown_authorize_url_generation": "Unknown error generating an authorize URL.",
+ "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages."
+ },
+ "create_entry": {
+ "authenticated": "Successfully authenticated"
+ },
+ "data": {
+ "access_token": "Access token",
+ "api_key": "API key",
+ "api_token": "API token",
+ "country": "Country",
+ "device": "Device",
+ "elevation": "Elevation",
+ "email": "Email",
+ "host": "Host",
+ "ip": "IP address",
+ "language": "Language",
+ "latitude": "Latitude",
+ "llm_hass_api": "Control Home Assistant",
+ "location": "Location",
+ "longitude": "Longitude",
+ "mode": "Mode",
+ "name": "Name",
+ "password": "Password",
+ "path": "Path",
+ "pin": "PIN code",
+ "port": "Port",
+ "ssl": "Uses an SSL certificate",
+ "url": "URL",
+ "usb_path": "USB device path",
+ "username": "Username",
+ "verify_ssl": "Verify SSL certificate"
+ },
+ "description": {
+ "confirm_setup": "Do you want to start setup?"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect",
+ "invalid_access_token": "Invalid access token",
+ "invalid_api_key": "Invalid API key",
+ "invalid_auth": "Invalid authentication",
+ "invalid_host": "Invalid hostname or IP address",
+ "timeout_connect": "Timeout establishing connection",
+ "unknown": "Unexpected error"
+ },
+ "title": {
+ "oauth2_pick_implementation": "Pick authentication method",
+ "reauth": "Authentication expired for {name}",
+ "via_hassio_addon": "{name} via Home Assistant add-on"
+ }
},
"device_automation": {
+ "action_type": {
+ "toggle": "Toggle {entity_name}",
+ "turn_off": "Turn off {entity_name}",
+ "turn_on": "Turn on {entity_name}"
+ },
"condition_type": {
- "is_on": "{entity_name} is on",
- "is_off": "{entity_name} is off"
+ "is_off": "{entity_name} is off",
+ "is_on": "{entity_name} is on"
},
"extra_fields": {
"above": "Above",
@@ -19,30 +108,47 @@
},
"trigger_type": {
"changed_states": "{entity_name} turned on or off",
- "turned_on": "{entity_name} turned on",
- "turned_off": "{entity_name} turned off"
- },
- "action_type": {
- "toggle": "Toggle {entity_name}",
- "turn_on": "Turn on {entity_name}",
- "turn_off": "Turn off {entity_name}"
+ "turned_off": "{entity_name} turned off",
+ "turned_on": "{entity_name} turned on"
}
},
- "action": {
- "connect": "Connect",
- "disconnect": "Disconnect",
- "enable": "Enable",
- "disable": "Disable",
+ "generic": {
+ "model": "Model",
+ "ui_managed": "Managed via UI"
+ },
+ "state": {
+ "active": "Active",
+ "auto": "Auto",
+ "charging": "Charging",
+ "closed": "Closed",
+ "closing": "Closing",
+ "connected": "Connected",
+ "disabled": "Disabled",
+ "discharging": "Discharging",
+ "disconnected": "Disconnected",
+ "enabled": "Enabled",
+ "error": "Error",
+ "high": "High",
+ "home": "Home",
+ "idle": "Idle",
+ "locked": "Locked",
+ "low": "Low",
+ "manual": "Manual",
+ "medium": "Medium",
+ "no": "No",
+ "normal": "Normal",
+ "not_home": "Away",
+ "off": "Off",
+ "on": "On",
"open": "Open",
- "close": "Close",
- "reload": "Reload",
- "restart": "Restart",
- "start": "Start",
- "stop": "Stop",
- "pause": "Pause",
- "turn_on": "Turn on",
- "turn_off": "Turn off",
- "toggle": "Toggle"
+ "opening": "Opening",
+ "paused": "Paused",
+ "standby": "Standby",
+ "stopped": "Stopped",
+ "unlocked": "Unlocked",
+ "very_high": "Very high",
+ "very_low": "Very low",
+ "yes": "Yes"
},
"time": {
"monday": "Monday",
@@ -52,97 +158,6 @@
"friday": "Friday",
"saturday": "Saturday",
"sunday": "Sunday"
- },
- "state": {
- "off": "Off",
- "on": "On",
- "yes": "Yes",
- "no": "No",
- "open": "Open",
- "closed": "Closed",
- "enabled": "Enabled",
- "disabled": "Disabled",
- "connected": "Connected",
- "disconnected": "Disconnected",
- "locked": "Locked",
- "unlocked": "Unlocked",
- "active": "Active",
- "idle": "Idle",
- "standby": "Standby",
- "paused": "Paused",
- "home": "Home",
- "not_home": "Away"
- },
- "config_flow": {
- "title": {
- "oauth2_pick_implementation": "Pick authentication method",
- "reauth": "Authentication expired for {name}",
- "via_hassio_addon": "{name} via Home Assistant add-on"
- },
- "description": {
- "confirm_setup": "Do you want to start setup?"
- },
- "data": {
- "device": "Device",
- "name": "Name",
- "email": "Email",
- "username": "Username",
- "password": "Password",
- "host": "Host",
- "ip": "IP address",
- "port": "Port",
- "url": "URL",
- "usb_path": "USB device path",
- "access_token": "Access token",
- "api_key": "API key",
- "api_token": "API token",
- "llm_hass_api": "Control Home Assistant",
- "ssl": "Uses an SSL certificate",
- "verify_ssl": "Verify SSL certificate",
- "elevation": "Elevation",
- "longitude": "Longitude",
- "latitude": "Latitude",
- "location": "Location",
- "pin": "PIN code",
- "mode": "Mode",
- "path": "Path",
- "language": "Language"
- },
- "create_entry": {
- "authenticated": "Successfully authenticated"
- },
- "error": {
- "cannot_connect": "Failed to connect",
- "invalid_access_token": "Invalid access token",
- "invalid_api_key": "Invalid API key",
- "invalid_auth": "Invalid authentication",
- "invalid_host": "Invalid hostname or IP address",
- "unknown": "Unexpected error",
- "timeout_connect": "Timeout establishing connection"
- },
- "abort": {
- "single_instance_allowed": "Already configured. Only a single configuration possible.",
- "already_configured_account": "Account is already configured",
- "already_configured_device": "Device is already configured",
- "already_configured_location": "Location is already configured",
- "already_configured_service": "Service is already configured",
- "already_in_progress": "Configuration flow is already in progress",
- "no_devices_found": "No devices found on the network",
- "webhook_not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive webhook messages.",
- "oauth2_error": "Received invalid token data.",
- "oauth2_timeout": "Timeout resolving OAuth token.",
- "oauth2_missing_configuration": "The component is not configured. Please follow the documentation.",
- "oauth2_missing_credentials": "The integration requires application credentials.",
- "oauth2_authorize_url_timeout": "Timeout generating authorize URL.",
- "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})",
- "oauth2_user_rejected_authorize": "Account linking rejected: {error}",
- "oauth2_unauthorized": "OAuth authorization error while obtaining access token.",
- "oauth2_failed": "Error while obtaining access token.",
- "reauth_successful": "Re-authentication was successful",
- "reconfigure_successful": "Re-configuration was successful",
- "unknown_authorize_url_generation": "Unknown error generating an authorize URL.",
- "cloud_not_connected": "Not connected to Home Assistant Cloud."
- }
}
}
}
diff --git a/homeassistant/util/frozen_dataclass_compat.py b/homeassistant/util/frozen_dataclass_compat.py
index 81ce9961a0b..518515d4f85 100644
--- a/homeassistant/util/frozen_dataclass_compat.py
+++ b/homeassistant/util/frozen_dataclass_compat.py
@@ -63,7 +63,7 @@ class FrozenOrThawed(type):
)
def __new__(
- mcs, # noqa: N804 ruff bug, ruff does not understand this is a metaclass
+ mcs,
name: str,
bases: tuple[type, ...],
namespace: dict[Any, Any],
diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py
index 2c4eb744614..d5dfab7da6c 100644
--- a/homeassistant/util/logging.py
+++ b/homeassistant/util/logging.py
@@ -2,14 +2,16 @@
from __future__ import annotations
+from collections import defaultdict
from collections.abc import Callable, Coroutine
from functools import partial, wraps
import inspect
import logging
import logging.handlers
-import queue
+from queue import SimpleQueue
+import time
import traceback
-from typing import Any, cast, overload
+from typing import Any, cast, overload, override
from homeassistant.core import (
HassJobType,
@@ -18,6 +20,76 @@ from homeassistant.core import (
get_hassjob_callable_job_type,
)
+_LOGGER = logging.getLogger(__name__)
+
+
+class HomeAssistantQueueListener(logging.handlers.QueueListener):
+ """Custom QueueListener to watch for noisy loggers."""
+
+ LOG_COUNTS_RESET_INTERVAL = 300
+ MAX_LOGS_COUNT = 200
+
+ EXCLUDED_LOG_COUNT_MODULES = [
+ "homeassistant.components.automation",
+ "homeassistant.components.script",
+ "homeassistant.setup",
+ "homeassistant.util.logging",
+ ]
+
+ _last_reset: float
+ _log_counts: dict[str, int]
+
+ def __init__(
+ self, queue: SimpleQueue[logging.Handler], *handlers: logging.Handler
+ ) -> None:
+ """Initialize the handler."""
+ super().__init__(queue, *handlers)
+ self._module_log_count_skip_flags: dict[str, bool] = {}
+ self._reset_counters(time.time())
+
+ @override
+ def handle(self, record: logging.LogRecord) -> None:
+ """Handle the record."""
+ super().handle(record)
+
+ if record.levelno < logging.INFO:
+ return
+
+ if (record.created - self._last_reset) > self.LOG_COUNTS_RESET_INTERVAL:
+ self._reset_counters(record.created)
+
+ module_name = record.name
+
+ if skip_flag := self._module_log_count_skip_flags.get(module_name):
+ return
+
+ if skip_flag is None and self._update_skip_flags(module_name):
+ return
+
+ self._log_counts[module_name] += 1
+ module_count = self._log_counts[module_name]
+ if module_count < self.MAX_LOGS_COUNT:
+ return
+
+ _LOGGER.warning(
+ "Module %s is logging too frequently. %d messages since last count",
+ module_name,
+ module_count,
+ )
+ self._module_log_count_skip_flags[module_name] = True
+
+ def _reset_counters(self, time_sec: float) -> None:
+ _LOGGER.debug("Resetting log counters")
+ self._last_reset = time_sec
+ self._log_counts = defaultdict(int)
+
+ def _update_skip_flags(self, module_name: str) -> bool:
+ excluded = any(
+ module_name.startswith(prefix) for prefix in self.EXCLUDED_LOG_COUNT_MODULES
+ )
+ self._module_log_count_skip_flags[module_name] = excluded
+ return excluded
+
class HomeAssistantQueueHandler(logging.handlers.QueueHandler):
"""Process the log in another thread."""
@@ -60,7 +132,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None:
This allows us to avoid blocking I/O and formatting messages
in the event loop as log messages are written in another thread.
"""
- simple_queue: queue.SimpleQueue[logging.Handler] = queue.SimpleQueue()
+ simple_queue: SimpleQueue[logging.Handler] = SimpleQueue()
queue_handler = HomeAssistantQueueHandler(simple_queue)
logging.root.addHandler(queue_handler)
@@ -71,7 +143,7 @@ def async_activate_log_queue_handler(hass: HomeAssistant) -> None:
logging.root.removeHandler(handler)
migrated_handlers.append(handler)
- listener = logging.handlers.QueueListener(simple_queue, *migrated_handlers)
+ listener = HomeAssistantQueueListener(simple_queue, *migrated_handlers)
queue_handler.listener = listener
listener.start()
diff --git a/homeassistant/util/read_only_dict.py b/homeassistant/util/read_only_dict.py
index 02befa78f60..3e4710cf220 100644
--- a/homeassistant/util/read_only_dict.py
+++ b/homeassistant/util/read_only_dict.py
@@ -1,7 +1,7 @@
"""Read only dictionary."""
from copy import deepcopy
-from typing import Any
+from typing import Any, final
def _readonly(*args: Any, **kwargs: Any) -> Any:
@@ -9,6 +9,7 @@ def _readonly(*args: Any, **kwargs: Any) -> Any:
raise RuntimeError("Cannot modify ReadOnlyDict")
+@final # Final to allow direct checking of the type instead of using isinstance
class ReadOnlyDict[_KT, _VT](dict[_KT, _VT]):
"""Read only version of dict that is compatible with dict types."""
diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py
index a22fd0c8fb4..4e26a126f39 100644
--- a/homeassistant/util/ssl.py
+++ b/homeassistant/util/ssl.py
@@ -82,10 +82,10 @@ def _client_context_no_verify(ssl_cipher_list: SSLCipherList) -> ssl.SSLContext:
return sslcontext
-@cache
-def _client_context(
+def _create_client_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
) -> ssl.SSLContext:
+ """Return an independent SSL context for making requests."""
# Reuse environment variable definition from requests, since it's already a
# requirement. If the environment variable has no value, fall back to using
# certs from certifi package.
@@ -100,6 +100,14 @@ def _client_context(
return sslcontext
+@cache
+def _client_context(
+ ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
+) -> ssl.SSLContext:
+ # Cached version of _create_client_context
+ return _create_client_context(ssl_cipher_list)
+
+
# Create this only once and reuse it
_DEFAULT_SSL_CONTEXT = _client_context(SSLCipherList.PYTHON_DEFAULT)
_DEFAULT_NO_VERIFY_SSL_CONTEXT = _client_context_no_verify(SSLCipherList.PYTHON_DEFAULT)
@@ -139,6 +147,14 @@ def client_context(
return _SSL_CONTEXTS.get(ssl_cipher_list, _DEFAULT_SSL_CONTEXT)
+def create_client_context(
+ ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
+) -> ssl.SSLContext:
+ """Return an independent SSL context for making requests."""
+ # This explicitly uses the non-cached version to create a client context
+ return _create_client_context(ssl_cipher_list)
+
+
def create_no_verify_ssl_context(
ssl_cipher_list: SSLCipherList = SSLCipherList.PYTHON_DEFAULT,
) -> ssl.SSLContext:
diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py
index 67258c9cd09..f2619c5dd61 100644
--- a/homeassistant/util/unit_conversion.py
+++ b/homeassistant/util/unit_conversion.py
@@ -4,6 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from functools import lru_cache
+from math import floor, log10
from homeassistant.const import (
CONCENTRATION_PARTS_PER_BILLION,
@@ -144,6 +145,15 @@ class BaseUnitConverter:
from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit)
return from_ratio / to_ratio
+ @classmethod
+ @lru_cache
+ def get_unit_floored_log_ratio(
+ cls, from_unit: str | None, to_unit: str | None
+ ) -> float:
+ """Get floored base10 log ratio between units of measurement."""
+ from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit)
+ return floor(max(0, log10(from_ratio / to_ratio)))
+
@classmethod
@lru_cache
def _are_unit_inverses(cls, from_unit: str | None, to_unit: str | None) -> bool:
diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py
index 15993cbae47..055f435503f 100644
--- a/homeassistant/util/unit_system.py
+++ b/homeassistant/util/unit_system.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+from dataclasses import dataclass
from numbers import Number
from typing import TYPE_CHECKING, Final
@@ -82,9 +83,21 @@ def _is_valid_unit(unit: str, unit_type: str) -> bool:
return False
+@dataclass(frozen=True, kw_only=True)
class UnitSystem:
"""A container for units of measure."""
+ _name: str
+ accumulated_precipitation_unit: UnitOfPrecipitationDepth
+ area_unit: UnitOfArea
+ length_unit: UnitOfLength
+ mass_unit: UnitOfMass
+ pressure_unit: UnitOfPressure
+ temperature_unit: UnitOfTemperature
+ volume_unit: UnitOfVolume
+ wind_speed_unit: UnitOfSpeed
+ _conversions: dict[tuple[SensorDeviceClass | str | None, str | None], str]
+
def __init__(
self,
name: str,
@@ -118,16 +131,16 @@ class UnitSystem:
if errors:
raise ValueError(errors)
- self._name = name
- self.accumulated_precipitation_unit = accumulated_precipitation
- self.area_unit = area
- self.length_unit = length
- self.mass_unit = mass
- self.pressure_unit = pressure
- self.temperature_unit = temperature
- self.volume_unit = volume
- self.wind_speed_unit = wind_speed
- self._conversions = conversions
+ super().__setattr__("_name", name)
+ super().__setattr__("accumulated_precipitation_unit", accumulated_precipitation)
+ super().__setattr__("area_unit", area)
+ super().__setattr__("length_unit", length)
+ super().__setattr__("mass_unit", mass)
+ super().__setattr__("pressure_unit", pressure)
+ super().__setattr__("temperature_unit", temperature)
+ super().__setattr__("volume_unit", volume)
+ super().__setattr__("wind_speed_unit", wind_speed)
+ super().__setattr__("_conversions", conversions)
def temperature(self, temperature: float, from_unit: str) -> float:
"""Convert the given temperature to this unit system."""
diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py
index 3b1f5c4cc0a..323383ef53f 100644
--- a/homeassistant/util/yaml/__init__.py
+++ b/homeassistant/util/yaml/__init__.py
@@ -1,17 +1,10 @@
"""YAML utility functions."""
-from .const import SECRET_YAML
+from annotatedyaml import SECRET_YAML, Input, YamlTypeError
+from annotatedyaml.input import UndefinedSubstitution, extract_inputs, substitute
+
from .dumper import dump, save_yaml
-from .input import UndefinedSubstitution, extract_inputs, substitute
-from .loader import (
- Secrets,
- YamlTypeError,
- load_yaml,
- load_yaml_dict,
- parse_yaml,
- secret_yaml,
-)
-from .objects import Input
+from .loader import Secrets, load_yaml, load_yaml_dict, parse_yaml, secret_yaml
__all__ = [
"SECRET_YAML",
diff --git a/homeassistant/util/yaml/const.py b/homeassistant/util/yaml/const.py
deleted file mode 100644
index 811c7d149f7..00000000000
--- a/homeassistant/util/yaml/const.py
+++ /dev/null
@@ -1,3 +0,0 @@
-"""Constants."""
-
-SECRET_YAML = "secrets.yaml"
diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py
index 61772b6989d..059be2c1c5b 100644
--- a/homeassistant/util/yaml/dumper.py
+++ b/homeassistant/util/yaml/dumper.py
@@ -1,96 +1,5 @@
"""Custom dumper and representers."""
-from collections import OrderedDict
-from typing import Any
+from annotatedyaml.dumper import add_representer, dump, represent_odict, save_yaml
-import yaml
-
-from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass
-
-# mypy: allow-untyped-calls, no-warn-return-any
-
-
-try:
- from yaml import CSafeDumper as FastestAvailableSafeDumper
-except ImportError:
- from yaml import ( # type: ignore[assignment]
- SafeDumper as FastestAvailableSafeDumper,
- )
-
-
-def dump(_dict: dict | list) -> str:
- """Dump YAML to a string and remove null."""
- return yaml.dump(
- _dict,
- default_flow_style=False,
- allow_unicode=True,
- sort_keys=False,
- Dumper=FastestAvailableSafeDumper,
- ).replace(": null\n", ":\n")
-
-
-def save_yaml(path: str, data: dict) -> None:
- """Save YAML to a file."""
- # Dump before writing to not truncate the file if dumping fails
- str_data = dump(data)
- with open(path, "w", encoding="utf-8") as outfile:
- outfile.write(str_data)
-
-
-# From: https://gist.github.com/miracle2k/3184458
-def represent_odict( # type: ignore[no-untyped-def]
- dumper, tag, mapping, flow_style=None
-) -> yaml.MappingNode:
- """Like BaseRepresenter.represent_mapping but does not issue the sort()."""
- value: list = []
- node = yaml.MappingNode(tag, value, flow_style=flow_style)
- if dumper.alias_key is not None:
- dumper.represented_objects[dumper.alias_key] = node
- best_style = True
- if hasattr(mapping, "items"):
- mapping = mapping.items()
- for item_key, item_value in mapping:
- node_key = dumper.represent_data(item_key)
- node_value = dumper.represent_data(item_value)
- if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style):
- best_style = False
- if not (isinstance(node_value, yaml.ScalarNode) and not node_value.style):
- best_style = False
- value.append((node_key, node_value))
- if flow_style is None:
- if dumper.default_flow_style is not None:
- node.flow_style = dumper.default_flow_style
- else:
- node.flow_style = best_style
- return node
-
-
-def add_representer(klass: Any, representer: Any) -> None:
- """Add to representer to the dumper."""
- FastestAvailableSafeDumper.add_representer(klass, representer)
-
-
-add_representer(
- OrderedDict,
- lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value),
-)
-
-add_representer(
- NodeDictClass,
- lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value),
-)
-
-add_representer(
- NodeListClass,
- lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value),
-)
-
-add_representer(
- NodeStrClass,
- lambda dumper, value: dumper.represent_scalar("tag:yaml.org,2002:str", str(value)),
-)
-
-add_representer(
- Input,
- lambda dumper, value: dumper.represent_scalar("!input", value.name),
-)
+__all__ = ["add_representer", "dump", "represent_odict", "save_yaml"]
diff --git a/homeassistant/util/yaml/input.py b/homeassistant/util/yaml/input.py
index ff9b37f18f1..5dad8a63ae5 100644
--- a/homeassistant/util/yaml/input.py
+++ b/homeassistant/util/yaml/input.py
@@ -2,55 +2,8 @@
from __future__ import annotations
-from typing import Any
+from annotatedyaml.input import UndefinedSubstitution, extract_inputs, substitute
from .objects import Input
-
-class UndefinedSubstitution(Exception):
- """Error raised when we find a substitution that is not defined."""
-
- def __init__(self, input_name: str) -> None:
- """Initialize the undefined substitution exception."""
- super().__init__(f"No substitution found for input {input_name}")
- self.input = input
-
-
-def extract_inputs(obj: Any) -> set[str]:
- """Extract input from a structure."""
- found: set[str] = set()
- _extract_inputs(obj, found)
- return found
-
-
-def _extract_inputs(obj: Any, found: set[str]) -> None:
- """Extract input from a structure."""
- if isinstance(obj, Input):
- found.add(obj.name)
- return
-
- if isinstance(obj, list):
- for val in obj:
- _extract_inputs(val, found)
- return
-
- if isinstance(obj, dict):
- for val in obj.values():
- _extract_inputs(val, found)
- return
-
-
-def substitute(obj: Any, substitutions: dict[str, Any]) -> Any:
- """Substitute values."""
- if isinstance(obj, Input):
- if obj.name not in substitutions:
- raise UndefinedSubstitution(obj.name)
- return substitutions[obj.name]
-
- if isinstance(obj, list):
- return [substitute(val, substitutions) for val in obj]
-
- if isinstance(obj, dict):
- return {key: substitute(val, substitutions) for key, val in obj.items()}
-
- return obj
+__all__ = ["Input", "UndefinedSubstitution", "extract_inputs", "substitute"]
diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py
index 3911d62040b..1f8338a1ff7 100644
--- a/homeassistant/util/yaml/loader.py
+++ b/homeassistant/util/yaml/loader.py
@@ -2,157 +2,37 @@
from __future__ import annotations
-from collections.abc import Callable, Iterator
-import fnmatch
-from io import StringIO, TextIOWrapper
-import logging
+from io import StringIO
import os
-from pathlib import Path
-from typing import Any, TextIO, overload
+from typing import TextIO
+from annotatedyaml import YAMLException, YamlTypeError
+from annotatedyaml.loader import (
+ HAS_C_LOADER,
+ JSON_TYPE,
+ LoaderType,
+ Secrets,
+ add_constructor,
+ load_yaml as load_annotated_yaml,
+ load_yaml_dict as load_annotated_yaml_dict,
+ parse_yaml as parse_annotated_yaml,
+ secret_yaml as annotated_secret_yaml,
+)
import yaml
-try:
- from yaml import CSafeLoader as FastestAvailableSafeLoader
-
- HAS_C_LOADER = True
-except ImportError:
- HAS_C_LOADER = False
- from yaml import ( # type: ignore[assignment]
- SafeLoader as FastestAvailableSafeLoader,
- )
-
-from propcache.api import cached_property
-
from homeassistant.exceptions import HomeAssistantError
-from .const import SECRET_YAML
-from .objects import Input, NodeDictClass, NodeListClass, NodeStrClass
-
-# mypy: allow-untyped-calls, no-warn-return-any
-
-JSON_TYPE = list | dict | str
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class YamlTypeError(HomeAssistantError):
- """Raised by load_yaml_dict if top level data is not a dict."""
-
-
-class Secrets:
- """Store secrets while loading YAML."""
-
- def __init__(self, config_dir: Path) -> None:
- """Initialize secrets."""
- self.config_dir = config_dir
- self._cache: dict[Path, dict[str, str]] = {}
-
- def get(self, requester_path: str, secret: str) -> str:
- """Return the value of a secret."""
- current_path = Path(requester_path)
-
- secret_dir = current_path
- while True:
- secret_dir = secret_dir.parent
-
- try:
- secret_dir.relative_to(self.config_dir)
- except ValueError:
- # We went above the config dir
- break
-
- secrets = self._load_secret_yaml(secret_dir)
-
- if secret in secrets:
- _LOGGER.debug(
- "Secret %s retrieved from secrets.yaml in folder %s",
- secret,
- secret_dir,
- )
- return secrets[secret]
-
- raise HomeAssistantError(f"Secret {secret} not defined")
-
- def _load_secret_yaml(self, secret_dir: Path) -> dict[str, str]:
- """Load the secrets yaml from path."""
- if (secret_path := secret_dir / SECRET_YAML) in self._cache:
- return self._cache[secret_path]
-
- _LOGGER.debug("Loading %s", secret_path)
- try:
- secrets = load_yaml(str(secret_path))
-
- if not isinstance(secrets, dict):
- raise HomeAssistantError("Secrets is not a dictionary")
-
- if "logger" in secrets:
- logger = str(secrets["logger"]).lower()
- if logger == "debug":
- _LOGGER.setLevel(logging.DEBUG)
- else:
- _LOGGER.error(
- (
- "Error in secrets.yaml: 'logger: debug' expected, but"
- " 'logger: %s' found"
- ),
- logger,
- )
- del secrets["logger"]
- except FileNotFoundError:
- secrets = {}
-
- self._cache[secret_path] = secrets
-
- return secrets
-
-
-class _LoaderMixin:
- """Mixin class with extensions for YAML loader."""
-
- name: str
- stream: Any
-
- @cached_property
- def get_name(self) -> str:
- """Get the name of the loader."""
- return self.name
-
- @cached_property
- def get_stream_name(self) -> str:
- """Get the name of the stream."""
- return getattr(self.stream, "name", "")
-
-
-class FastSafeLoader(FastestAvailableSafeLoader, _LoaderMixin):
- """The fastest available safe loader, either C or Python."""
-
- def __init__(self, stream: Any, secrets: Secrets | None = None) -> None:
- """Initialize a safe line loader."""
- self.stream = stream
-
- # Set name in same way as the Python loader does in yaml.reader.__init__
- if isinstance(stream, str):
- self.name = ""
- elif isinstance(stream, bytes):
- self.name = ""
- else:
- self.name = getattr(stream, "name", "")
-
- super().__init__(stream)
- self.secrets = secrets
-
-
-class PythonSafeLoader(yaml.SafeLoader, _LoaderMixin):
- """Python safe loader."""
-
- def __init__(self, stream: Any, secrets: Secrets | None = None) -> None:
- """Initialize a safe line loader."""
- super().__init__(stream)
- self.secrets = secrets
-
-
-type LoaderType = FastSafeLoader | PythonSafeLoader
+__all__ = [
+ "HAS_C_LOADER",
+ "JSON_TYPE",
+ "Secrets",
+ "YamlTypeError",
+ "add_constructor",
+ "load_yaml",
+ "load_yaml_dict",
+ "parse_yaml",
+ "secret_yaml",
+]
def load_yaml(
@@ -164,15 +44,9 @@ def load_yaml(
except for FileNotFoundError which will be re-raised.
"""
try:
- with open(fname, encoding="utf-8") as conf_file:
- return parse_yaml(conf_file, secrets)
- except UnicodeDecodeError as exc:
- _LOGGER.error("Unable to read file %s: %s", fname, exc)
- raise HomeAssistantError(exc) from exc
- except FileNotFoundError:
- raise
- except OSError as exc:
- raise HomeAssistantError(exc) from exc
+ return load_annotated_yaml(fname, secrets)
+ except YAMLException as exc:
+ raise HomeAssistantError(str(exc)) from exc
def load_yaml_dict(
@@ -183,320 +57,27 @@ def load_yaml_dict(
Raise if the top level is not a dict.
Return an empty dict if the file is empty.
"""
- loaded_yaml = load_yaml(fname, secrets)
- if loaded_yaml is None:
- loaded_yaml = {}
- if not isinstance(loaded_yaml, dict):
- raise YamlTypeError(f"YAML file {fname} does not contain a dict")
- return loaded_yaml
+ try:
+ return load_annotated_yaml_dict(fname, secrets)
+ except YamlTypeError:
+ raise
+ except YAMLException as exc:
+ raise HomeAssistantError(str(exc)) from exc
def parse_yaml(
content: str | TextIO | StringIO, secrets: Secrets | None = None
) -> JSON_TYPE:
"""Parse YAML with the fastest available loader."""
- if not HAS_C_LOADER:
- return _parse_yaml_python(content, secrets)
try:
- return _parse_yaml(FastSafeLoader, content, secrets)
- except yaml.YAMLError:
- # Loading failed, so we now load with the Python loader which has more
- # readable exceptions
- if isinstance(content, (StringIO, TextIO, TextIOWrapper)):
- # Rewind the stream so we can try again
- content.seek(0, 0)
- return _parse_yaml_python(content, secrets)
-
-
-def _parse_yaml_python(
- content: str | TextIO | StringIO, secrets: Secrets | None = None
-) -> JSON_TYPE:
- """Parse YAML with the python loader (this is very slow)."""
- try:
- return _parse_yaml(PythonSafeLoader, content, secrets)
- except yaml.YAMLError as exc:
- _LOGGER.error(str(exc))
- raise HomeAssistantError(exc) from exc
-
-
-def _parse_yaml(
- loader: type[FastSafeLoader | PythonSafeLoader],
- content: str | TextIO,
- secrets: Secrets | None = None,
-) -> JSON_TYPE:
- """Load a YAML file."""
- return yaml.load(content, Loader=lambda stream: loader(stream, secrets)) # type: ignore[arg-type]
-
-
-@overload
-def _add_reference(
- obj: list | NodeListClass, loader: LoaderType, node: yaml.nodes.Node
-) -> NodeListClass: ...
-
-
-@overload
-def _add_reference(
- obj: str | NodeStrClass, loader: LoaderType, node: yaml.nodes.Node
-) -> NodeStrClass: ...
-
-
-@overload
-def _add_reference(
- obj: dict | NodeDictClass, loader: LoaderType, node: yaml.nodes.Node
-) -> NodeDictClass: ...
-
-
-def _add_reference(
- obj: dict | list | str | NodeDictClass | NodeListClass | NodeStrClass,
- loader: LoaderType,
- node: yaml.nodes.Node,
-) -> NodeDictClass | NodeListClass | NodeStrClass:
- """Add file reference information to an object."""
- if isinstance(obj, list):
- obj = NodeListClass(obj)
- elif isinstance(obj, str):
- obj = NodeStrClass(obj)
- elif isinstance(obj, dict):
- obj = NodeDictClass(obj)
- return _add_reference_to_node_class(obj, loader, node)
-
-
-@overload
-def _add_reference_to_node_class(
- obj: NodeListClass, loader: LoaderType, node: yaml.nodes.Node
-) -> NodeListClass: ...
-
-
-@overload
-def _add_reference_to_node_class(
- obj: NodeStrClass, loader: LoaderType, node: yaml.nodes.Node
-) -> NodeStrClass: ...
-
-
-@overload
-def _add_reference_to_node_class(
- obj: NodeDictClass, loader: LoaderType, node: yaml.nodes.Node
-) -> NodeDictClass: ...
-
-
-def _add_reference_to_node_class(
- obj: NodeDictClass | NodeListClass | NodeStrClass,
- loader: LoaderType,
- node: yaml.nodes.Node,
-) -> NodeDictClass | NodeListClass | NodeStrClass:
- """Add file reference information to a node class object."""
- try: # suppress is much slower
- obj.__config_file__ = loader.get_name
- obj.__line__ = node.start_mark.line + 1
- except AttributeError:
- pass
- return obj
-
-
-def _raise_if_no_value[NodeT: yaml.nodes.Node, _R](
- func: Callable[[LoaderType, NodeT], _R],
-) -> Callable[[LoaderType, NodeT], _R]:
- def wrapper(loader: LoaderType, node: NodeT) -> _R:
- if not node.value:
- raise HomeAssistantError(
- f"{node.start_mark}: {node.tag} needs an argument."
- )
- return func(loader, node)
-
- return wrapper
-
-
-@_raise_if_no_value
-def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE:
- """Load another YAML file and embed it using the !include tag.
-
- Example:
- device_tracker: !include device_tracker.yaml
-
- """
- fname = os.path.join(os.path.dirname(loader.get_name), node.value)
- try:
- loaded_yaml = load_yaml(fname, loader.secrets)
- if loaded_yaml is None:
- loaded_yaml = NodeDictClass()
- return _add_reference(loaded_yaml, loader, node)
- except FileNotFoundError as exc:
- raise HomeAssistantError(
- f"{node.start_mark}: Unable to read file {fname}"
- ) from exc
-
-
-def _is_file_valid(name: str) -> bool:
- """Decide if a file is valid."""
- return not name.startswith(".")
-
-
-def _find_files(directory: str, pattern: str) -> Iterator[str]:
- """Recursively load files in a directory."""
- for root, dirs, files in os.walk(directory, topdown=True):
- dirs[:] = [d for d in dirs if _is_file_valid(d)]
- for basename in sorted(files):
- if _is_file_valid(basename) and fnmatch.fnmatch(basename, pattern):
- filename = os.path.join(root, basename)
- yield filename
-
-
-@_raise_if_no_value
-def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDictClass:
- """Load multiple files from directory as a dictionary."""
- mapping = NodeDictClass()
- loc = os.path.join(os.path.dirname(loader.get_name), node.value)
- for fname in _find_files(loc, "*.yaml"):
- filename = os.path.splitext(os.path.basename(fname))[0]
- if os.path.basename(fname) == SECRET_YAML:
- continue
- loaded_yaml = load_yaml(fname, loader.secrets)
- if loaded_yaml is None:
- # Special case, an empty file included by !include_dir_named is treated
- # as an empty dictionary
- loaded_yaml = NodeDictClass()
- mapping[filename] = loaded_yaml
- return _add_reference_to_node_class(mapping, loader, node)
-
-
-@_raise_if_no_value
-def _include_dir_merge_named_yaml(
- loader: LoaderType, node: yaml.nodes.Node
-) -> NodeDictClass:
- """Load multiple files from directory as a merged dictionary."""
- mapping = NodeDictClass()
- loc = os.path.join(os.path.dirname(loader.get_name), node.value)
- for fname in _find_files(loc, "*.yaml"):
- if os.path.basename(fname) == SECRET_YAML:
- continue
- loaded_yaml = load_yaml(fname, loader.secrets)
- if isinstance(loaded_yaml, dict):
- mapping.update(loaded_yaml)
- return _add_reference_to_node_class(mapping, loader, node)
-
-
-@_raise_if_no_value
-def _include_dir_list_yaml(
- loader: LoaderType, node: yaml.nodes.Node
-) -> list[JSON_TYPE]:
- """Load multiple files from directory as a list."""
- loc = os.path.join(os.path.dirname(loader.get_name), node.value)
- return [
- loaded_yaml
- for f in _find_files(loc, "*.yaml")
- if os.path.basename(f) != SECRET_YAML
- and (loaded_yaml := load_yaml(f, loader.secrets)) is not None
- ]
-
-
-@_raise_if_no_value
-def _include_dir_merge_list_yaml(
- loader: LoaderType, node: yaml.nodes.Node
-) -> JSON_TYPE:
- """Load multiple files from directory as a merged list."""
- loc: str = os.path.join(os.path.dirname(loader.get_name), node.value)
- merged_list: list[JSON_TYPE] = []
- for fname in _find_files(loc, "*.yaml"):
- if os.path.basename(fname) == SECRET_YAML:
- continue
- loaded_yaml = load_yaml(fname, loader.secrets)
- if isinstance(loaded_yaml, list):
- merged_list.extend(loaded_yaml)
- return _add_reference(merged_list, loader, node)
-
-
-def _handle_mapping_tag(
- loader: LoaderType, node: yaml.nodes.MappingNode
-) -> NodeDictClass:
- """Load YAML mappings into an ordered dictionary to preserve key order."""
- loader.flatten_mapping(node)
- nodes = loader.construct_pairs(node)
-
- seen: dict = {}
- for (key, _), (child_node, _) in zip(nodes, node.value, strict=False):
- line = child_node.start_mark.line
-
- try:
- hash(key)
- except TypeError as exc:
- fname = loader.get_stream_name
- raise yaml.MarkedYAMLError(
- context=f'invalid key: "{key}"',
- context_mark=yaml.Mark(
- fname,
- 0,
- line,
- -1,
- None,
- None, # type: ignore[arg-type]
- ),
- ) from exc
-
- if key in seen:
- fname = loader.get_stream_name
- _LOGGER.warning(
- 'YAML file %s contains duplicate key "%s". Check lines %d and %d',
- fname,
- key,
- seen[key],
- line,
- )
- seen[key] = line
-
- return _add_reference_to_node_class(NodeDictClass(nodes), loader, node)
-
-
-def _construct_seq(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE:
- """Add line number and file name to Load YAML sequence."""
- (obj,) = loader.construct_yaml_seq(node)
- return _add_reference(obj, loader, node)
-
-
-def _handle_scalar_tag(
- loader: LoaderType, node: yaml.nodes.ScalarNode
-) -> str | int | float | None:
- """Add line number and file name to Load YAML sequence."""
- obj = node.value
- if not isinstance(obj, str):
- return obj
- return _add_reference_to_node_class(NodeStrClass(obj), loader, node)
-
-
-def _env_var_yaml(loader: LoaderType, node: yaml.nodes.Node) -> str:
- """Load environment variables and embed it into the configuration YAML."""
- args = node.value.split()
-
- # Check for a default value
- if len(args) > 1:
- return os.getenv(args[0], " ".join(args[1:]))
- if args[0] in os.environ:
- return os.environ[args[0]]
- _LOGGER.error("Environment variable %s not defined", node.value)
- raise HomeAssistantError(node.value)
+ return parse_annotated_yaml(content, secrets)
+ except YAMLException as exc:
+ raise HomeAssistantError(str(exc)) from exc
def secret_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE:
"""Load secrets and embed it into the configuration YAML."""
- if loader.secrets is None:
- raise HomeAssistantError("Secrets not supported in this YAML file")
-
- return loader.secrets.get(loader.get_name, node.value)
-
-
-def add_constructor(tag: Any, constructor: Any) -> None:
- """Add to constructor to all loaders."""
- for yaml_loader in (FastSafeLoader, PythonSafeLoader):
- yaml_loader.add_constructor(tag, constructor)
-
-
-add_constructor("!include", _include_yaml)
-add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _handle_mapping_tag)
-add_constructor(yaml.resolver.BaseResolver.DEFAULT_SCALAR_TAG, _handle_scalar_tag)
-add_constructor(yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq)
-add_constructor("!env_var", _env_var_yaml)
-add_constructor("!secret", secret_yaml)
-add_constructor("!include_dir_list", _include_dir_list_yaml)
-add_constructor("!include_dir_merge_list", _include_dir_merge_list_yaml)
-add_constructor("!include_dir_named", _include_dir_named_yaml)
-add_constructor("!include_dir_merge_named", _include_dir_merge_named_yaml)
-add_constructor("!input", Input.from_node)
+ try:
+ return annotated_secret_yaml(loader, node)
+ except YAMLException as exc:
+ raise HomeAssistantError(str(exc)) from exc
diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py
index 7e4019331c6..26714b0fdd4 100644
--- a/homeassistant/util/yaml/objects.py
+++ b/homeassistant/util/yaml/objects.py
@@ -2,52 +2,6 @@
from __future__ import annotations
-from dataclasses import dataclass
-from typing import Any
+from annotatedyaml import Input, NodeDictClass, NodeListClass, NodeStrClass
-import voluptuous as vol
-from voluptuous.schema_builder import _compile_scalar
-import yaml
-
-
-class NodeListClass(list):
- """Wrapper class to be able to add attributes on a list."""
-
- __slots__ = ("__config_file__", "__line__")
-
- __config_file__: str
- __line__: int | str
-
-
-class NodeStrClass(str):
- """Wrapper class to be able to add attributes on a string."""
-
- __slots__ = ("__config_file__", "__line__")
-
- __config_file__: str
- __line__: int | str
-
- def __voluptuous_compile__(self, schema: vol.Schema) -> Any:
- """Needed because vol.Schema.compile does not handle str subclasses."""
- return _compile_scalar(self) # type: ignore[no-untyped-call]
-
-
-class NodeDictClass(dict):
- """Wrapper class to be able to add attributes on a dict."""
-
- __slots__ = ("__config_file__", "__line__")
-
- __config_file__: str
- __line__: int | str
-
-
-@dataclass(slots=True, frozen=True)
-class Input:
- """Input that should be substituted."""
-
- name: str
-
- @classmethod
- def from_node(cls, loader: yaml.Loader, node: yaml.nodes.Node) -> Input:
- """Create a new placeholder from a node."""
- return cls(node.value)
+__all__ = ["Input", "NodeDictClass", "NodeListClass", "NodeStrClass"]
diff --git a/mypy.ini b/mypy.ini
index 2d9821b1c64..0e42a6c3594 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -785,6 +785,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.azure_storage.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.backup.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -935,6 +945,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.bosch_alarm.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.braviatv.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -1105,6 +1125,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.comelit.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.command_line.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -2096,6 +2126,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.home_connect.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.homeassistant.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -2626,6 +2666,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.kulersky.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.lacrosse.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3356,6 +3406,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.ohme.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.onboarding.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3696,6 +3756,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.pyload.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.python_script.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3816,6 +3886,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.remember_the_milk.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.remote.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -3826,6 +3906,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.remote_calendar.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.renault.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -4126,6 +4216,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.sensorpush_cloud.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.sensoterra.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -4999,6 +5099,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
+[mypy-homeassistant.components.vodafone_station.*]
+check_untyped_defs = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+warn_return_any = true
+warn_unreachable = true
+
[mypy-homeassistant.components.wake_on_lan.*]
check_untyped_defs = true
disallow_incomplete_defs = true
diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py
index f76e0b43c10..ca7777da959 100644
--- a/pylint/plugins/hass_enforce_type_hints.py
+++ b/pylint/plugins/hass_enforce_type_hints.py
@@ -252,7 +252,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = {
arg_types={
0: "HomeAssistant",
1: "ConfigEntry",
- 2: "AddEntitiesCallback",
+ 2: "AddConfigEntryEntitiesCallback",
},
return_type=None,
),
@@ -1410,6 +1410,16 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
],
),
],
+ "entity": [
+ ClassTypeHintMatch(
+ base_class="Entity",
+ matches=_ENTITY_MATCH,
+ ),
+ ClassTypeHintMatch(
+ base_class="RestoreEntity",
+ matches=_RESTORE_ENTITY_MATCH,
+ ),
+ ],
"fan": [
ClassTypeHintMatch(
base_class="Entity",
@@ -2558,7 +2568,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
),
TypeHintMatch(
function_name="in_progress",
- return_type=["bool", "int", None],
+ return_type=["bool", None],
),
TypeHintMatch(
function_name="latest_version",
@@ -2580,6 +2590,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
function_name="title",
return_type=["str", None],
),
+ TypeHintMatch(
+ function_name="update_percentage",
+ return_type=["int", "float", None],
+ ),
TypeHintMatch(
function_name="install",
arg_types={1: "str | None", 2: "bool"},
diff --git a/pyproject.toml b/pyproject.toml
index 3936fdb3a1e..e100863510d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,97 +1,138 @@
[build-system]
-requires = ["setuptools==75.1.0"]
+requires = ["setuptools==77.0.3"]
build-backend = "setuptools.build_meta"
[project]
-name = "homeassistant"
-version = "2025.3.0.dev0"
-license = {text = "Apache-2.0"}
+name = "homeassistant"
+version = "2025.5.0.dev0"
+license = "Apache-2.0"
+license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
-readme = "README.rst"
-authors = [
- {name = "The Home Assistant Authors", email = "hello@home-assistant.io"}
+readme = "README.rst"
+authors = [
+ { name = "The Home Assistant Authors", email = "hello@home-assistant.io" },
]
-keywords = ["home", "automation"]
+keywords = ["home", "automation"]
classifiers = [
- "Development Status :: 5 - Production/Stable",
- "Intended Audience :: End Users/Desktop",
- "Intended Audience :: Developers",
- "License :: OSI Approved :: Apache Software License",
- "Operating System :: OS Independent",
- "Programming Language :: Python :: 3.13",
- "Topic :: Home Automation",
+ "Development Status :: 5 - Production/Stable",
+ "Intended Audience :: End Users/Desktop",
+ "Intended Audience :: Developers",
+ "Operating System :: OS Independent",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Home Automation",
]
-requires-python = ">=3.13.0"
-dependencies = [
- "aiodns==3.2.0",
- # Integrations may depend on hassio integration without listing it to
- # change behavior based on presence of supervisor. Deprecated with #127228
- # Lib can be removed with 2025.11
- "aiohasupervisor==0.3.0",
- "aiohttp==3.11.12",
- "aiohttp_cors==0.7.0",
- "aiohttp-fast-zlib==0.2.2",
- "aiohttp-asyncmdnsresolver==0.1.0",
- "aiozoneinfo==0.2.3",
- "astral==2.2",
- "async-interrupt==1.2.1",
- "attrs==25.1.0",
- "atomicwrites-homeassistant==1.4.1",
- "audioop-lts==0.2.1",
- "awesomeversion==24.6.0",
- "bcrypt==4.2.0",
- "certifi>=2021.5.30",
- "ciso8601==2.3.2",
- "cronsim==2.6",
- "fnv-hash-fast==1.2.2",
- # hass-nabucasa is imported by helpers which don't depend on the cloud
- # integration
- "hass-nabucasa==0.89.0",
- # When bumping httpx, please check the version pins of
- # httpcore, anyio, and h11 in gen_requirements_all
- "httpx==0.28.1",
- "home-assistant-bluetooth==1.13.1",
- "ifaddr==0.2.0",
- "Jinja2==3.1.5",
- "lru-dict==1.3.0",
- "PyJWT==2.10.1",
- # PyJWT has loose dependency. We want the latest one.
- "cryptography==44.0.0",
- "Pillow==11.1.0",
- "propcache==0.2.1",
- "pyOpenSSL==25.0.0",
- "orjson==3.10.12",
- "packaging>=23.1",
- "psutil-home-assistant==0.0.1",
- "python-slugify==8.0.4",
- "PyYAML==6.0.2",
- "requests==2.32.3",
- "securetar==2025.1.4",
- "SQLAlchemy==2.0.38",
- "standard-aifc==3.13.0",
- "standard-telnetlib==3.13.0",
- "typing-extensions>=4.12.2,<5.0",
- "ulid-transform==1.2.0",
- # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503
- # Temporary setting an upper bound, to prevent compat issues with urllib3>=2
- # https://github.com/home-assistant/core/issues/97248
- "urllib3>=1.26.5,<2",
- "uv==0.5.27",
- "voluptuous==0.15.2",
- "voluptuous-serialize==2.6.0",
- "voluptuous-openapi==0.0.6",
- "yarl==1.18.3",
- "webrtc-models==0.3.0",
- "zeroconf==0.143.0"
+requires-python = ">=3.13.2"
+dependencies = [
+ "aiodns==3.2.0",
+ # Integrations may depend on hassio integration without listing it to
+ # change behavior based on presence of supervisor. Deprecated with #127228
+ # Lib can be removed with 2025.11
+ "aiohasupervisor==0.3.1b1",
+ "aiohttp==3.11.16",
+ "aiohttp_cors==0.7.0",
+ "aiohttp-fast-zlib==0.2.3",
+ "aiohttp-asyncmdnsresolver==0.1.1",
+ "aiozoneinfo==0.2.3",
+ "annotatedyaml==0.4.5",
+ "astral==2.2",
+ "async-interrupt==1.2.2",
+ "attrs==25.1.0",
+ "atomicwrites-homeassistant==1.4.1",
+ "audioop-lts==0.2.1",
+ "awesomeversion==24.6.0",
+ "bcrypt==4.2.0",
+ "certifi>=2021.5.30",
+ "ciso8601==2.3.2",
+ "cronsim==2.6",
+ "fnv-hash-fast==1.4.0",
+ # ha-ffmpeg is indirectly imported from onboarding via the import chain
+ # onboarding->cloud->assist_pipeline->tts->ffmpeg. Onboarding needs
+ # to be setup in stage 0, but we don't want to also promote cloud with all its
+ # dependencies to stage 0.
+ "ha-ffmpeg==3.2.2",
+ # hass-nabucasa is imported by helpers which don't depend on the cloud
+ # integration
+ "hass-nabucasa==0.94.0",
+ # hassil is indirectly imported from onboarding via the import chain
+ # onboarding->cloud->assist_pipeline->conversation->hassil. Onboarding needs
+ # to be setup in stage 0, but we don't want to also promote cloud with all its
+ # dependencies to stage 0.
+ "hassil==2.2.3",
+ # When bumping httpx, please check the version pins of
+ # httpcore, anyio, and h11 in gen_requirements_all
+ "httpx==0.28.1",
+ "home-assistant-bluetooth==1.13.1",
+ # home_assistant_intents is indirectly imported from onboarding via the import chain
+ # onboarding->cloud->assist_pipeline->conversation->home_assistant_intents. Onboarding needs
+ # to be setup in stage 0, but we don't want to also promote cloud with all its
+ # dependencies to stage 0.
+ "home-assistant-intents==2025.3.28",
+ "ifaddr==0.2.0",
+ "Jinja2==3.1.6",
+ "lru-dict==1.3.0",
+ # mutagen is indirectly imported from onboarding via the import chain
+ # onboarding->cloud->assist_pipeline->tts->mutagen. Onboarding needs
+ # to be setup in stage 0, but we don't want to also promote cloud with all its
+ # dependencies to stage 0.
+ "mutagen==1.47.0",
+ # numpy is indirectly imported from onboarding via the import chain
+ # onboarding->cloud->alexa->camera->stream->numpy. Onboarding needs
+ # to be setup in stage 0, but we don't want to also promote cloud with all its
+ # dependencies to stage 0.
+ "numpy==2.2.2",
+ "PyJWT==2.10.1",
+ # PyJWT has loose dependency. We want the latest one.
+ "cryptography==44.0.1",
+ "Pillow==11.2.1",
+ "propcache==0.3.1",
+ "pyOpenSSL==25.0.0",
+ "orjson==3.10.16",
+ "packaging>=23.1",
+ "psutil-home-assistant==0.0.1",
+ # pymicro_vad is indirectly imported from onboarding via the import chain
+ # onboarding->cloud->assist_pipeline->pymicro_vad. Onboarding needs
+ # to be setup in stage 0, but we don't want to also promote cloud with all its
+ # dependencies to stage 0.
+ "pymicro-vad==1.0.1",
+ # pyspeex-noise is indirectly imported from onboarding via the import chain
+ # onboarding->cloud->assist_pipeline->pyspeex_noise. Onboarding needs
+ # to be setup in stage 0, but we don't want to also promote cloud with all its
+ # dependencies to stage 0.
+ "pyspeex-noise==1.0.2",
+ "python-slugify==8.0.4",
+ # PyTurboJPEG is indirectly imported from onboarding via the import chain
+ # onboarding->cloud->camera->pyturbojpeg. Onboarding needs
+ # to be setup in stage 0, but we don't want to also promote cloud with all its
+ # dependencies to stage 0.
+ "PyTurboJPEG==1.7.5",
+ "PyYAML==6.0.2",
+ "requests==2.32.3",
+ "securetar==2025.2.1",
+ "SQLAlchemy==2.0.40",
+ "standard-aifc==3.13.0",
+ "standard-telnetlib==3.13.0",
+ "typing-extensions>=4.13.0,<5.0",
+ "ulid-transform==1.4.0",
+ # Constrain urllib3 to ensure we deal with CVE-2020-26137 and CVE-2021-33503
+ # Temporary setting an upper bound, to prevent compat issues with urllib3>=2
+ # https://github.com/home-assistant/core/issues/97248
+ "urllib3>=1.26.5,<2",
+ "uv==0.6.10",
+ "voluptuous==0.15.2",
+ "voluptuous-serialize==2.6.0",
+ "voluptuous-openapi==0.0.6",
+ "yarl==1.20.0",
+ "webrtc-models==0.3.0",
+ "zeroconf==0.146.5",
]
[project.urls]
-"Homepage" = "https://www.home-assistant.io/"
+"Homepage" = "https://www.home-assistant.io/"
"Source Code" = "https://github.com/home-assistant/core"
"Bug Reports" = "https://github.com/home-assistant/core/issues"
-"Docs: Dev" = "https://developers.home-assistant.io/"
-"Discord" = "https://www.home-assistant.io/join-chat/"
-"Forum" = "https://community.home-assistant.io/"
+"Docs: Dev" = "https://developers.home-assistant.io/"
+"Discord" = "https://www.home-assistant.io/join-chat/"
+"Forum" = "https://community.home-assistant.io/"
[project.scripts]
hass = "homeassistant.__main__:main"
@@ -118,30 +159,28 @@ init-hook = """\
) \
"""
load-plugins = [
- "pylint.extensions.code_style",
- "pylint.extensions.typing",
- "hass_decorator",
- "hass_enforce_class_module",
- "hass_enforce_sorted_platforms",
- "hass_enforce_super_call",
- "hass_enforce_type_hints",
- "hass_inheritance",
- "hass_imports",
- "hass_logger",
- "pylint_per_file_ignores",
+ "pylint.extensions.code_style",
+ "pylint.extensions.typing",
+ "hass_decorator",
+ "hass_enforce_class_module",
+ "hass_enforce_sorted_platforms",
+ "hass_enforce_super_call",
+ "hass_enforce_type_hints",
+ "hass_inheritance",
+ "hass_imports",
+ "hass_logger",
+ "pylint_per_file_ignores",
]
persistent = false
extension-pkg-allow-list = [
- "av.audio.stream",
- "av.logging",
- "av.stream",
- "ciso8601",
- "orjson",
- "cv2",
-]
-fail-on = [
- "I",
+ "av.audio.stream",
+ "av.logging",
+ "av.stream",
+ "ciso8601",
+ "orjson",
+ "cv2",
]
+fail-on = ["I"]
[tool.pylint.BASIC]
class-const-naming-style = "any"
@@ -166,257 +205,257 @@ class-const-naming-style = "any"
# consider-using-namedtuple-or-dataclass - too opinionated
# consider-using-assignment-expr - decision to use := better left to devs
disable = [
- "format",
- "abstract-method",
- "cyclic-import",
- "duplicate-code",
- "inconsistent-return-statements",
- "locally-disabled",
- "not-context-manager",
- "too-few-public-methods",
- "too-many-ancestors",
- "too-many-arguments",
- "too-many-instance-attributes",
- "too-many-lines",
- "too-many-locals",
- "too-many-public-methods",
- "too-many-boolean-expressions",
- "too-many-positional-arguments",
- "wrong-import-order",
- "consider-using-namedtuple-or-dataclass",
- "consider-using-assignment-expr",
- "possibly-used-before-assignment",
+ "format",
+ "abstract-method",
+ "cyclic-import",
+ "duplicate-code",
+ "inconsistent-return-statements",
+ "locally-disabled",
+ "not-context-manager",
+ "too-few-public-methods",
+ "too-many-ancestors",
+ "too-many-arguments",
+ "too-many-instance-attributes",
+ "too-many-lines",
+ "too-many-locals",
+ "too-many-public-methods",
+ "too-many-boolean-expressions",
+ "too-many-positional-arguments",
+ "wrong-import-order",
+ "consider-using-namedtuple-or-dataclass",
+ "consider-using-assignment-expr",
+ "possibly-used-before-assignment",
- # Handled by ruff
- # Ref:
- "await-outside-async", # PLE1142
- "bad-str-strip-call", # PLE1310
- "bad-string-format-type", # PLE1307
- "bidirectional-unicode", # PLE2502
- "continue-in-finally", # PLE0116
- "duplicate-bases", # PLE0241
- "misplaced-bare-raise", # PLE0704
- "format-needs-mapping", # F502
- "function-redefined", # F811
- # Needed because ruff does not understand type of __all__ generated by a function
- # "invalid-all-format", # PLE0605
- "invalid-all-object", # PLE0604
- "invalid-character-backspace", # PLE2510
- "invalid-character-esc", # PLE2513
- "invalid-character-nul", # PLE2514
- "invalid-character-sub", # PLE2512
- "invalid-character-zero-width-space", # PLE2515
- "logging-too-few-args", # PLE1206
- "logging-too-many-args", # PLE1205
- "missing-format-string-key", # F524
- "mixed-format-string", # F506
- "no-method-argument", # N805
- "no-self-argument", # N805
- "nonexistent-operator", # B002
- "nonlocal-without-binding", # PLE0117
- "not-in-loop", # F701, F702
- "notimplemented-raised", # F901
- "return-in-init", # PLE0101
- "return-outside-function", # F706
- "syntax-error", # E999
- "too-few-format-args", # F524
- "too-many-format-args", # F522
- "too-many-star-expressions", # F622
- "truncated-format-string", # F501
- "undefined-all-variable", # F822
- "undefined-variable", # F821
- "used-prior-global-declaration", # PLE0118
- "yield-inside-async-function", # PLE1700
- "yield-outside-function", # F704
- "anomalous-backslash-in-string", # W605
- "assert-on-string-literal", # PLW0129
- "assert-on-tuple", # F631
- "bad-format-string", # W1302, F
- "bad-format-string-key", # W1300, F
- "bare-except", # E722
- "binary-op-exception", # PLW0711
- "cell-var-from-loop", # B023
- # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work
- "duplicate-except", # B014
- "duplicate-key", # F601
- "duplicate-string-formatting-argument", # F
- "duplicate-value", # F
- "eval-used", # S307
- "exec-used", # S102
- "expression-not-assigned", # B018
- "f-string-without-interpolation", # F541
- "forgotten-debug-statement", # T100
- "format-string-without-interpolation", # F
- # "global-statement", # PLW0603, ruff catches new occurrences, needs more work
- "global-variable-not-assigned", # PLW0602
- "implicit-str-concat", # ISC001
- "import-self", # PLW0406
- "inconsistent-quotes", # Q000
- "invalid-envvar-default", # PLW1508
- "keyword-arg-before-vararg", # B026
- "logging-format-interpolation", # G
- "logging-fstring-interpolation", # G
- "logging-not-lazy", # G
- "misplaced-future", # F404
- "named-expr-without-context", # PLW0131
- "nested-min-max", # PLW3301
- "pointless-statement", # B018
- "raise-missing-from", # B904
- "redefined-builtin", # A001
- "try-except-raise", # TRY302
- "unused-argument", # ARG001, we don't use it
- "unused-format-string-argument", #F507
- "unused-format-string-key", # F504
- "unused-import", # F401
- "unused-variable", # F841
- "useless-else-on-loop", # PLW0120
- "wildcard-import", # F403
- "bad-classmethod-argument", # N804
- "consider-iterating-dictionary", # SIM118
- "empty-docstring", # D419
- "invalid-name", # N815
- "line-too-long", # E501, disabled globally
- "missing-class-docstring", # D101
- "missing-final-newline", # W292
- "missing-function-docstring", # D103
- "missing-module-docstring", # D100
- "multiple-imports", #E401
- "singleton-comparison", # E711, E712
- "subprocess-run-check", # PLW1510
- "superfluous-parens", # UP034
- "ungrouped-imports", # I001
- "unidiomatic-typecheck", # E721
- "unnecessary-direct-lambda-call", # PLC3002
- "unnecessary-lambda-assignment", # PLC3001
- "unnecessary-pass", # PIE790
- "unneeded-not", # SIM208
- "useless-import-alias", # PLC0414
- "wrong-import-order", # I001
- "wrong-import-position", # E402
- "comparison-of-constants", # PLR0133
- "comparison-with-itself", # PLR0124
- "consider-alternative-union-syntax", # UP007
- "consider-merging-isinstance", # PLR1701
- "consider-using-alias", # UP006
- "consider-using-dict-comprehension", # C402
- "consider-using-generator", # C417
- "consider-using-get", # SIM401
- "consider-using-set-comprehension", # C401
- "consider-using-sys-exit", # PLR1722
- "consider-using-ternary", # SIM108
- "literal-comparison", # F632
- "property-with-parameters", # PLR0206
- "super-with-arguments", # UP008
- "too-many-branches", # PLR0912
- "too-many-return-statements", # PLR0911
- "too-many-statements", # PLR0915
- "trailing-comma-tuple", # COM818
- "unnecessary-comprehension", # C416
- "use-a-generator", # C417
- "use-dict-literal", # C406
- "use-list-literal", # C405
- "useless-object-inheritance", # UP004
- "useless-return", # PLR1711
- "no-else-break", # RET508
- "no-else-continue", # RET507
- "no-else-raise", # RET506
- "no-else-return", # RET505
- "broad-except", # BLE001
- "protected-access", # SLF001
- "broad-exception-raised", # TRY002
- "consider-using-f-string", # PLC0209
- # "no-self-use", # PLR6301 # Optional plugin, not enabled
+ # Handled by ruff
+ # Ref:
+ "await-outside-async", # PLE1142
+ "bad-str-strip-call", # PLE1310
+ "bad-string-format-type", # PLE1307
+ "bidirectional-unicode", # PLE2502
+ "continue-in-finally", # PLE0116
+ "duplicate-bases", # PLE0241
+ "misplaced-bare-raise", # PLE0704
+ "format-needs-mapping", # F502
+ "function-redefined", # F811
+ # Needed because ruff does not understand type of __all__ generated by a function
+ # "invalid-all-format", # PLE0605
+ "invalid-all-object", # PLE0604
+ "invalid-character-backspace", # PLE2510
+ "invalid-character-esc", # PLE2513
+ "invalid-character-nul", # PLE2514
+ "invalid-character-sub", # PLE2512
+ "invalid-character-zero-width-space", # PLE2515
+ "logging-too-few-args", # PLE1206
+ "logging-too-many-args", # PLE1205
+ "missing-format-string-key", # F524
+ "mixed-format-string", # F506
+ "no-method-argument", # N805
+ "no-self-argument", # N805
+ "nonexistent-operator", # B002
+ "nonlocal-without-binding", # PLE0117
+ "not-in-loop", # F701, F702
+ "notimplemented-raised", # F901
+ "return-in-init", # PLE0101
+ "return-outside-function", # F706
+ "syntax-error", # E999
+ "too-few-format-args", # F524
+ "too-many-format-args", # F522
+ "too-many-star-expressions", # F622
+ "truncated-format-string", # F501
+ "undefined-all-variable", # F822
+ "undefined-variable", # F821
+ "used-prior-global-declaration", # PLE0118
+ "yield-inside-async-function", # PLE1700
+ "yield-outside-function", # F704
+ "anomalous-backslash-in-string", # W605
+ "assert-on-string-literal", # PLW0129
+ "assert-on-tuple", # F631
+ "bad-format-string", # W1302, F
+ "bad-format-string-key", # W1300, F
+ "bare-except", # E722
+ "binary-op-exception", # PLW0711
+ "cell-var-from-loop", # B023
+ # "dangerous-default-value", # B006, ruff catches new occurrences, needs more work
+ "duplicate-except", # B014
+ "duplicate-key", # F601
+ "duplicate-string-formatting-argument", # F
+ "duplicate-value", # F
+ "eval-used", # S307
+ "exec-used", # S102
+ "expression-not-assigned", # B018
+ "f-string-without-interpolation", # F541
+ "forgotten-debug-statement", # T100
+ "format-string-without-interpolation", # F
+ # "global-statement", # PLW0603, ruff catches new occurrences, needs more work
+ "global-variable-not-assigned", # PLW0602
+ "implicit-str-concat", # ISC001
+ "import-self", # PLW0406
+ "inconsistent-quotes", # Q000
+ "invalid-envvar-default", # PLW1508
+ "keyword-arg-before-vararg", # B026
+ "logging-format-interpolation", # G
+ "logging-fstring-interpolation", # G
+ "logging-not-lazy", # G
+ "misplaced-future", # F404
+ "named-expr-without-context", # PLW0131
+ "nested-min-max", # PLW3301
+ "pointless-statement", # B018
+ "raise-missing-from", # B904
+ "redefined-builtin", # A001
+ "try-except-raise", # TRY302
+ "unused-argument", # ARG001, we don't use it
+ "unused-format-string-argument", #F507
+ "unused-format-string-key", # F504
+ "unused-import", # F401
+ "unused-variable", # F841
+ "useless-else-on-loop", # PLW0120
+ "wildcard-import", # F403
+ "bad-classmethod-argument", # N804
+ "consider-iterating-dictionary", # SIM118
+ "empty-docstring", # D419
+ "invalid-name", # N815
+ "line-too-long", # E501, disabled globally
+ "missing-class-docstring", # D101
+ "missing-final-newline", # W292
+ "missing-function-docstring", # D103
+ "missing-module-docstring", # D100
+ "multiple-imports", #E401
+ "singleton-comparison", # E711, E712
+ "subprocess-run-check", # PLW1510
+ "superfluous-parens", # UP034
+ "ungrouped-imports", # I001
+ "unidiomatic-typecheck", # E721
+ "unnecessary-direct-lambda-call", # PLC3002
+ "unnecessary-lambda-assignment", # PLC3001
+ "unnecessary-pass", # PIE790
+ "unneeded-not", # SIM208
+ "useless-import-alias", # PLC0414
+ "wrong-import-order", # I001
+ "wrong-import-position", # E402
+ "comparison-of-constants", # PLR0133
+ "comparison-with-itself", # PLR0124
+ "consider-alternative-union-syntax", # UP007
+ "consider-merging-isinstance", # PLR1701
+ "consider-using-alias", # UP006
+ "consider-using-dict-comprehension", # C402
+ "consider-using-generator", # C417
+ "consider-using-get", # SIM401
+ "consider-using-set-comprehension", # C401
+ "consider-using-sys-exit", # PLR1722
+ "consider-using-ternary", # SIM108
+ "literal-comparison", # F632
+ "property-with-parameters", # PLR0206
+ "super-with-arguments", # UP008
+ "too-many-branches", # PLR0912
+ "too-many-return-statements", # PLR0911
+ "too-many-statements", # PLR0915
+ "trailing-comma-tuple", # COM818
+ "unnecessary-comprehension", # C416
+ "use-a-generator", # C417
+ "use-dict-literal", # C406
+ "use-list-literal", # C405
+ "useless-object-inheritance", # UP004
+ "useless-return", # PLR1711
+ "no-else-break", # RET508
+ "no-else-continue", # RET507
+ "no-else-raise", # RET506
+ "no-else-return", # RET505
+ "broad-except", # BLE001
+ "protected-access", # SLF001
+ "broad-exception-raised", # TRY002
+ "consider-using-f-string", # PLC0209
+ # "no-self-use", # PLR6301 # Optional plugin, not enabled
- # Handled by mypy
- # Ref:
- "abstract-class-instantiated",
- "arguments-differ",
- "assigning-non-slot",
- "assignment-from-no-return",
- "assignment-from-none",
- "bad-exception-cause",
- "bad-format-character",
- "bad-reversed-sequence",
- "bad-super-call",
- "bad-thread-instantiation",
- "catching-non-exception",
- "comparison-with-callable",
- "deprecated-class",
- "dict-iter-missing-items",
- "format-combined-specification",
- "global-variable-undefined",
- "import-error",
- "inconsistent-mro",
- "inherit-non-class",
- "init-is-generator",
- "invalid-class-object",
- "invalid-enum-extension",
- "invalid-envvar-value",
- "invalid-format-returned",
- "invalid-hash-returned",
- "invalid-metaclass",
- "invalid-overridden-method",
- "invalid-repr-returned",
- "invalid-sequence-index",
- "invalid-slice-index",
- "invalid-slots-object",
- "invalid-slots",
- "invalid-star-assignment-target",
- "invalid-str-returned",
- "invalid-unary-operand-type",
- "invalid-unicode-codec",
- "isinstance-second-argument-not-valid-type",
- "method-hidden",
- "misplaced-format-function",
- "missing-format-argument-key",
- "missing-format-attribute",
- "missing-kwoa",
- "no-member",
- "no-value-for-parameter",
- "non-iterator-returned",
- "non-str-assignment-to-dunder-name",
- "nonlocal-and-global",
- "not-a-mapping",
- "not-an-iterable",
- "not-async-context-manager",
- "not-callable",
- "not-context-manager",
- "overridden-final-method",
- "raising-bad-type",
- "raising-non-exception",
- "redundant-keyword-arg",
- "relative-beyond-top-level",
- "self-cls-assignment",
- "signature-differs",
- "star-needs-assignment-target",
- "subclassed-final-class",
- "super-without-brackets",
- "too-many-function-args",
- "typevar-double-variance",
- "typevar-name-mismatch",
- "unbalanced-dict-unpacking",
- "unbalanced-tuple-unpacking",
- "unexpected-keyword-arg",
- "unhashable-member",
- "unpacking-non-sequence",
- "unsubscriptable-object",
- "unsupported-assignment-operation",
- "unsupported-binary-operation",
- "unsupported-delete-operation",
- "unsupported-membership-test",
- "used-before-assignment",
- "using-final-decorator-in-unsupported-version",
- "wrong-exception-operation",
+ # Handled by mypy
+ # Ref:
+ "abstract-class-instantiated",
+ "arguments-differ",
+ "assigning-non-slot",
+ "assignment-from-no-return",
+ "assignment-from-none",
+ "bad-exception-cause",
+ "bad-format-character",
+ "bad-reversed-sequence",
+ "bad-super-call",
+ "bad-thread-instantiation",
+ "catching-non-exception",
+ "comparison-with-callable",
+ "deprecated-class",
+ "dict-iter-missing-items",
+ "format-combined-specification",
+ "global-variable-undefined",
+ "import-error",
+ "inconsistent-mro",
+ "inherit-non-class",
+ "init-is-generator",
+ "invalid-class-object",
+ "invalid-enum-extension",
+ "invalid-envvar-value",
+ "invalid-format-returned",
+ "invalid-hash-returned",
+ "invalid-metaclass",
+ "invalid-overridden-method",
+ "invalid-repr-returned",
+ "invalid-sequence-index",
+ "invalid-slice-index",
+ "invalid-slots-object",
+ "invalid-slots",
+ "invalid-star-assignment-target",
+ "invalid-str-returned",
+ "invalid-unary-operand-type",
+ "invalid-unicode-codec",
+ "isinstance-second-argument-not-valid-type",
+ "method-hidden",
+ "misplaced-format-function",
+ "missing-format-argument-key",
+ "missing-format-attribute",
+ "missing-kwoa",
+ "no-member",
+ "no-value-for-parameter",
+ "non-iterator-returned",
+ "non-str-assignment-to-dunder-name",
+ "nonlocal-and-global",
+ "not-a-mapping",
+ "not-an-iterable",
+ "not-async-context-manager",
+ "not-callable",
+ "not-context-manager",
+ "overridden-final-method",
+ "raising-bad-type",
+ "raising-non-exception",
+ "redundant-keyword-arg",
+ "relative-beyond-top-level",
+ "self-cls-assignment",
+ "signature-differs",
+ "star-needs-assignment-target",
+ "subclassed-final-class",
+ "super-without-brackets",
+ "too-many-function-args",
+ "typevar-double-variance",
+ "typevar-name-mismatch",
+ "unbalanced-dict-unpacking",
+ "unbalanced-tuple-unpacking",
+ "unexpected-keyword-arg",
+ "unhashable-member",
+ "unpacking-non-sequence",
+ "unsubscriptable-object",
+ "unsupported-assignment-operation",
+ "unsupported-binary-operation",
+ "unsupported-delete-operation",
+ "unsupported-membership-test",
+ "used-before-assignment",
+ "using-final-decorator-in-unsupported-version",
+ "wrong-exception-operation",
]
enable = [
- #"useless-suppression", # temporarily every now and then to clean them up
- "use-symbolic-message-instead",
+ #"useless-suppression", # temporarily every now and then to clean them up
+ "use-symbolic-message-instead",
]
per-file-ignores = [
- # redefined-outer-name: Tests reference fixtures in the test function
- # use-implicit-booleaness-not-comparison: Tests need to validate that a list
- # or a dict is returned
- "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison",
+ # redefined-outer-name: Tests reference fixtures in the test function
+ # use-implicit-booleaness-not-comparison: Tests need to validate that a list
+ # or a dict is returned
+ "/tests/:redefined-outer-name,use-implicit-booleaness-not-comparison",
]
[tool.pylint.REPORTS]
@@ -424,7 +463,7 @@ score = false
[tool.pylint.TYPECHECK]
ignored-classes = [
- "_CountingAttr", # for attrs
+ "_CountingAttr", # for attrs
]
mixin-class-rgx = ".*[Mm]ix[Ii]n"
@@ -433,9 +472,9 @@ expected-line-ending-format = "LF"
[tool.pylint.EXCEPTIONS]
overgeneral-exceptions = [
- "builtins.BaseException",
- "builtins.Exception",
- # "homeassistant.exceptions.HomeAssistantError", # too many issues
+ "builtins.BaseException",
+ "builtins.Exception",
+ # "homeassistant.exceptions.HomeAssistantError", # too many issues
]
[tool.pylint.TYPING]
@@ -445,241 +484,200 @@ runtime-typing = false
max-line-length-suggestions = 72
[tool.pytest.ini_options]
-testpaths = [
- "tests",
-]
-norecursedirs = [
- ".git",
- "testing_config",
-]
+testpaths = ["tests"]
+norecursedirs = [".git", "testing_config"]
log_format = "%(asctime)s.%(msecs)03d %(levelname)-8s %(threadName)s %(name)s:%(filename)s:%(lineno)s %(message)s"
log_date_format = "%Y-%m-%d %H:%M:%S"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
- "error::sqlalchemy.exc.SAWarning",
+ "error::sqlalchemy.exc.SAWarning",
- # -- HomeAssistant - aiohttp
- # Overwrite web.Application to pass a custom default argument to _make_request
- "ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning",
- # Hass wraps `ClientSession.close` to emit a warning if the session is closed accidentally
- "ignore:Setting custom ClientSession.close attribute is discouraged:DeprecationWarning:homeassistant.helpers.aiohttp_client",
- # Modify app state for testing
- "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban",
+ # -- HomeAssistant - aiohttp
+ # Overwrite web.Application to pass a custom default argument to _make_request
+ "ignore:Inheritance class HomeAssistantApplication from web.Application is discouraged:DeprecationWarning",
+ # Hass wraps `ClientSession.close` to emit a warning if the session is closed accidentally
+ "ignore:Setting custom ClientSession.close attribute is discouraged:DeprecationWarning:homeassistant.helpers.aiohttp_client",
+ # Modify app state for testing
+ "ignore:Changing state of started or joined application is deprecated:DeprecationWarning:tests.components.http.test_ban",
- # -- Tests
- # Ignore custom pytest marks
- "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met",
- "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic",
- # https://github.com/rokam/sunweg/blob/3.1.0/sunweg/plant.py#L96 - v3.1.0 - 2024-10-02
- "ignore:The '(kwh_per_kwp|performance_rate)' property is deprecated and will return 0:DeprecationWarning:tests.components.sunweg.test_init",
+ # -- Tests
+ # Ignore custom pytest marks
+ "ignore:Unknown pytest.mark.disable_autouse_fixture:pytest.PytestUnknownMarkWarning:tests.components.met",
+ "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic",
- # -- design choice 3rd party
- # https://github.com/gwww/elkm1/blob/2.2.10/elkm1_lib/util.py#L8-L19
- "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util",
- # https://github.com/allenporter/ical/pull/215
- # https://github.com/allenporter/ical/blob/8.2.0/ical/util.py#L21-L23
- "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util",
- # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52
- "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client",
+ # -- DeprecationWarning already fixed in our codebase
+ # https://github.com/kurtmckee/feedparser/pull/389 - 6.0.11
+ "ignore:.*a temporary mapping .* from `updated_parsed` to `published_parsed` if `updated_parsed` doesn't exist:DeprecationWarning:feedparser.util",
- # -- Setuptools DeprecationWarnings
- # https://github.com/googleapis/google-cloud-python/issues/11184
- # https://github.com/zopefoundation/meta/issues/194
- # https://github.com/Azure/azure-sdk-for-python
- "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources",
+ # -- design choice 3rd party
+ # https://github.com/gwww/elkm1/blob/2.2.11/elkm1_lib/util.py#L8-L19
+ "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util",
+ # https://github.com/bachya/regenmaschine/blob/2024.03.0/regenmaschine/client.py#L52
+ "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client",
- # -- tracked upstream / open PRs
- # - pyOpenSSL v24.2.1
- # https://github.com/certbot/certbot/issues/9828 - v2.11.0
- # https://github.com/certbot/certbot/issues/9992
- "ignore:X509Extension support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util",
- "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:acme.crypto_util",
- "ignore:CSR support in pyOpenSSL is deprecated. You should use the APIs in cryptography:DeprecationWarning:josepy.util",
- # - other
- # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.3
- # https://github.com/foxel/python_ndms2_client/pull/8
- "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection",
+ # -- Setuptools DeprecationWarnings
+ # https://github.com/googleapis/google-cloud-python/issues/11184
+ # https://github.com/zopefoundation/meta/issues/194
+ # https://github.com/Azure/azure-sdk-for-python
+ "ignore:Deprecated call to `pkg_resources.declare_namespace\\(('azure'|'google.*'|'pywinusb'|'repoze'|'xbox'|'zope')\\)`:DeprecationWarning:pkg_resources",
- # -- fixed, waiting for release / update
- # https://github.com/bachya/aiopurpleair/pull/200 - >=2023.10.0
- "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators",
- # https://bugs.launchpad.net/beautifulsoup/+bug/2076897 - >4.12.3
- "ignore:The 'strip_cdata' option of HTMLParser\\(\\) has never done anything and will eventually be removed:DeprecationWarning:bs4.builder._lxml",
- # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0
- "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base",
- # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0
- "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat",
- # https://github.com/fwestenberg/devialet/pull/6 - >1.4.5
- "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:devialet.devialet_api",
- # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0
- "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2",
- # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0
- # https://github.com/influxdata/influxdb-client-python/pull/652
- "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point",
- # https://github.com/majuss/lupupy/pull/15 - >0.3.2
- "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm",
- # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1
- "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check",
- # https://github.com/eclipse/paho.mqtt.python/issues/653 - >=2.0.0
- # https://github.com/eclipse/paho.mqtt.python/pull/665
- "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client",
- # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0
- "ignore::DeprecationWarning:holidays",
- # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0
- "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol",
- "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol",
- # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0
- "ignore:invalid escape sequence:SyntaxWarning:.*stringcase",
+ # -- tracked upstream / open PRs
+ # https://github.com/hacf-fr/meteofrance-api/pull/688 - v1.4.0 - 2025-03-26
+ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteofrance_api.model.forecast",
- # -- fixed for Python 3.13
- # https://github.com/rhasspy/wyoming/commit/e34af30d455b6f2bb9e5cfb25fad8d276914bc54 - >=1.4.2
- "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:wyoming.audio",
+ # -- fixed, waiting for release / update
+ # https://github.com/DataDog/datadogpy/pull/290 - >=0.23.0
+ "ignore:invalid escape sequence:SyntaxWarning:.*datadog.dogstatsd.base",
+ # https://github.com/DataDog/datadogpy/pull/566/files - >=0.37.0
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:datadog.util.compat",
+ # https://github.com/httplib2/httplib2/pull/226 - >=0.21.0
+ "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:httplib2",
+ # https://github.com/influxdata/influxdb-client-python/issues/603 >=1.45.0
+ # https://github.com/influxdata/influxdb-client-python/pull/652
+ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point",
+ # https://github.com/majuss/lupupy/pull/15 - >0.3.2
+ "ignore:\"is not\" with 'str' literal. Did you mean \"!=\"?:SyntaxWarning:.*lupupy.devices.alarm",
+ # https://github.com/nextcord/nextcord/pull/1095 - >=3.0.0
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check",
+ # https://github.com/vacanza/python-holidays/discussions/1800 - >1.0.0
+ "ignore::DeprecationWarning:holidays",
+ # https://github.com/rytilahti/python-miio/pull/1809 - >=0.6.0.dev0
+ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol",
+ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol",
+ # https://github.com/rytilahti/python-miio/pull/1993 - >0.6.0.dev0
+ "ignore:functools.partial will be a method descriptor in future Python versions; wrap it in enum.member\\(\\) if you want to preserve the old behavior:FutureWarning:miio.miot_device",
+ # https://github.com/okunishinishi/python-stringcase/commit/6a5c5bbd3fe5337862abc7fd0853a0f36e18b2e1 - >1.2.0
+ "ignore:invalid escape sequence:SyntaxWarning:.*stringcase",
- # -- other
- # Locale changes might take some time to resolve upstream
- # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08
- "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud",
- # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09
- "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway",
- # https://github.com/lidatong/dataclasses-json/issues/328
- # https://github.com/lidatong/dataclasses-json/pull/351
- "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm",
- # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19
- # https://github.com/martonperei/emulated_roku
- "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku",
- # https://github.com/w1ll1am23/pyeconet/blob/v0.1.23/src/pyeconet/api.py#L38 - v0.1.23 - 2024-10-08
- "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api",
- # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22
- "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron",
- # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24
- "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils",
- # https://github.com/lextudio/pysnmp/blob/v7.1.10/pysnmp/smi/compiler.py#L23-L31 - v7.1.10 - 2024-11-04
- "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler",
- "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysmi.reader.url", # wrong stacklevel
- # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10
- "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const",
- # Wrong stacklevel
- # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 fixed in >4.12.3
- "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:html.parser",
- # New in aiohttp - v3.9.0
- "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)",
- # - SyntaxWarnings
- # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10
- "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common",
- "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common",
- # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24
- # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789
- "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera",
- # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15
- # https://github.com/koolsb/pyblackbird/pull/9 -> closed
- "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird",
- # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05
- "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i",
- # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01
- # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42
- "ignore:invalid escape sequence:SyntaxWarning:.*sanix",
- # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18
- "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty
- # - pkg_resources
- # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20
- "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast",
- # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28
- "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api",
- # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17
- "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data",
- # https://pypi.org/project/pybotvac/ - v0.0.25 - 2024-04-11
- "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version",
- # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21
- "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom",
+ # -- other
+ # Locale changes might take some time to resolve upstream
+ # https://github.com/Squachen/micloud/blob/v_0.6/micloud/micloud.py#L35 - v0.6 - 2022-12-08
+ "ignore:'locale.getdefaultlocale' is deprecated and slated for removal in Python 3.15:DeprecationWarning:micloud.micloud",
+ # https://pypi.org/project/agent-py/ - v0.0.24 - 2024-11-07
+ "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a",
+ # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 - 2023-10-09
+ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway",
+ # https://github.com/lidatong/dataclasses-json/issues/328
+ # https://github.com/lidatong/dataclasses-json/pull/351
+ "ignore:The 'default' argument to fields is deprecated. Use 'dump_default' instead:DeprecationWarning:dataclasses_json.mm",
+ # https://pypi.org/project/emulated-roku/ - v0.3.0 - 2023-12-19
+ # https://github.com/martonperei/emulated_roku
+ "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku",
+ # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16
+ "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async",
+ # https://pypi.org/project/pyeconet/ - v0.1.28 - 2025-02-15
+ # https://github.com/w1ll1am23/pyeconet/blob/v0.1.28/src/pyeconet/api.py#L38
+ "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:pyeconet.api",
+ # https://github.com/thecynic/pylutron - v0.2.16 - 2024-10-22
+ "ignore:setDaemon\\(\\) is deprecated, set the daemon attribute instead:DeprecationWarning:pylutron",
+ # https://pypi.org/project/PyMetEireann/ - v2024.11.0 - 2024-11-23
+ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann",
+ # https://github.com/pschmitt/pynuki/blob/1.6.3/pynuki/utils.py#L21 - v1.6.3 - 2024-02-24
+ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pynuki.utils",
+ # https://github.com/lextudio/pysnmp/blob/v7.1.17/pysnmp/smi/compiler.py#L23-L31 - v7.1.17 - 2025-03-19
+ "ignore:smiV1Relaxed is deprecated. Please use smi_v1_relaxed instead:DeprecationWarning:pysnmp.smi.compiler",
+ "ignore:getReadersFromUrls is deprecated. Please use get_readers_from_urls instead:DeprecationWarning:pysnmp.smi.compiler",
+ # https://github.com/Python-roborock/python-roborock/issues/305 - 2.18.0 - 2025-04-06
+ "ignore:Callback API version 1 is deprecated, update to latest version:DeprecationWarning:roborock.cloud_api",
+ # https://github.com/briis/pyweatherflowudp/blob/v1.4.5/pyweatherflowudp/const.py#L20 - v1.4.5 - 2023-10-10
+ "ignore:This function will be removed in future versions of pint:DeprecationWarning:pyweatherflowudp.const",
+ # New in aiohttp - v3.9.0
+ "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)",
+ # - SyntaxWarnings
+ # https://pypi.org/project/aprslib/ - v0.7.2 - 2022-07-10
+ "ignore:invalid escape sequence:SyntaxWarning:.*aprslib.parsing.common",
+ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aprslib.parsing.common",
+ # https://pypi.org/project/panasonic-viera/ - v0.4.2 - 2024-04-24
+ # https://github.com/florianholzapfel/panasonic-viera/blob/0.4.2/panasonic_viera/__init__.py#L789
+ "ignore:invalid escape sequence:SyntaxWarning:.*panasonic_viera",
+ # https://pypi.org/project/pyblackbird/ - v0.6 - 2023-03-15
+ # https://github.com/koolsb/pyblackbird/pull/9 -> closed
+ "ignore:invalid escape sequence:SyntaxWarning:.*pyblackbird",
+ # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05
+ "ignore:invalid escape sequence:SyntaxWarning:.*pyws66i",
+ # https://pypi.org/project/sanix/ - v1.0.6 - 2024-05-01
+ # https://github.com/tomaszsluszniak/sanix_py/blob/v1.0.6/sanix/__init__.py#L42
+ "ignore:invalid escape sequence:SyntaxWarning:.*sanix",
+ # https://pypi.org/project/sleekxmppfs/ - v1.4.1 - 2022-08-18
+ "ignore:invalid escape sequence:SyntaxWarning:.*sleekxmppfs.thirdparty.mini_dateutil", # codespell:ignore thirdparty
+ # - pkg_resources
+ # https://pypi.org/project/aiomusiccast/ - v0.14.8 - 2023-03-20
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:aiomusiccast",
+ # https://pypi.org/project/habitipy/ - v0.3.3 - 2024-10-28
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:habitipy.api",
+ # https://github.com/eavanvalkenburg/pysiaalarm/blob/v3.1.1/src/pysiaalarm/data/data.py#L7 - v3.1.1 - 2023-04-17
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pysiaalarm.data.data",
+ # https://pypi.org/project/pybotvac/ - v0.0.26 - 2025-02-26
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pybotvac.version",
+ # https://github.com/home-assistant-ecosystem/python-mystrom/blob/2.2.0/pymystrom/__init__.py#L10 - v2.2.0 - 2023-05-21
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pymystrom",
- # -- Python 3.13
- # HomeAssistant
- "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.assist_pipeline.websocket_api",
- "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:homeassistant.components.hddtemp.sensor",
- # https://pypi.org/project/nextcord/ - v2.6.0 - 2023-09-23
- # https://github.com/nextcord/nextcord/issues/1174
- # https://github.com/nextcord/nextcord/blob/v2.6.1/nextcord/player.py#L5
- "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nextcord.player",
- # https://pypi.org/project/SpeechRecognition/ - v3.11.0 - 2024-05-05
- # https://github.com/Uberi/speech_recognition/blob/3.11.0/speech_recognition/__init__.py#L7
- "ignore:'aifc' is deprecated and slated for removal in Python 3.13:DeprecationWarning:speech_recognition",
- # https://pypi.org/project/voip-utils/ - v0.2.0 - 2024-09-06
- # https://github.com/home-assistant-libs/voip-utils/blob/0.2.0/voip_utils/rtp_audio.py#L3
- "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:voip_utils.rtp_audio",
+ # -- New in Python 3.13
+ # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11
+ # https://github.com/kurtmckee/feedparser/issues/481
+ "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html",
+ # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib
+ "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition",
+ "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor",
+ "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection",
+ "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad",
+ "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i",
- # -- Python 3.13 - unmaintained projects, last release about 2+ years
- # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10
- "ignore:'audioop' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pydub.utils",
- # https://github.com/heathbar/plum-lightpad-python/issues/7 - v0.0.11 - 2018-10-16
- "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:plumlightpad.lightpad",
- # https://pypi.org/project/pyws66i/ - v1.1 - 2022-04-05
- # https://github.com/ssaenger/pyws66i/blob/v1.1/pyws66i/__init__.py#L2
- "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:pyws66i",
+ # -- Websockets 14.1
+ # https://websockets.readthedocs.io/en/stable/howto/upgrade.html
+ "ignore:websockets.legacy is deprecated:DeprecationWarning:websockets.legacy",
+ # https://github.com/bluecurrent/HomeAssistantAPI
+ "ignore:websockets.client.connect is deprecated:DeprecationWarning:bluecurrent_api.websocket",
+ "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:bluecurrent_api.websocket",
+ "ignore:websockets.exceptions.InvalidStatusCode is deprecated:DeprecationWarning:bluecurrent_api.websocket",
+ # https://github.com/graphql-python/gql
+ "ignore:websockets.client.WebSocketClientProtocol is deprecated:DeprecationWarning:gql.transport.websockets_base",
- # -- New in Python 3.13
- # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11
- # https://github.com/kurtmckee/feedparser/issues/481
- "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html",
- # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib
- "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition",
- "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor",
- "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:ndms2_client.connection",
- "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:plumlightpad.lightpad",
- "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:pyws66i",
-
- # -- unmaintained projects, last release about 2+ years
- # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04
- "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a",
- # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27
- "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms",
- # https://pypi.org/project/alarmdecoder/ - v1.13.11 - 2021-06-01
- "ignore:invalid escape sequence:SyntaxWarning:.*alarmdecoder",
- # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12
- "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv",
- "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models",
- # https://pypi.org/project/foobot_async/ - v1.0.1 - 2024-08-16
- "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async",
- # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28
- "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig",
- # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived)
- "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol",
- # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark`
- # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05
- # https://github.com/vaidik/commentjson/issues/51
- # https://github.com/vaidik/commentjson/pull/52
- # Fixed upstream, commentjson depends on old version and seems to be unmaintained
- "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils",
- # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21
- "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session",
- # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived)
- "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client",
- # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16
- "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder",
- # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08
- "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils",
- # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19
- "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight",
- # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16
- "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery",
- "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)",
- # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05
- "ignore:invalid escape sequence:SyntaxWarning:.*ppadb",
- # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10
- "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils",
- # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19
- "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss",
- # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16
- "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann",
- # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21
- "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils",
- # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19
- "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_",
- "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_",
- # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25
- "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants",
- # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10
- "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp",
+ # -- unmaintained projects, last release about 2+ years
+ # https://pypi.org/project/aiomodernforms/ - v0.1.8 - 2021-06-27
+ "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms",
+ # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12
+ "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv",
+ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models",
+ # https://pypi.org/project/enocean/ - v0.50.1 (installed) -> v0.60.1 - 2021-06-18
+ "ignore:It looks like you're using an HTML parser to parse an XML document:UserWarning:enocean.protocol.eep",
+ # https://pypi.org/project/httpsig/ - v1.3.0 - 2018-11-28
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:httpsig",
+ # https://pypi.org/project/influxdb/ - v5.3.2 - 2024-04-18 (archived)
+ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol",
+ # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark`
+ # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05
+ # https://github.com/vaidik/commentjson/issues/51
+ # https://github.com/vaidik/commentjson/pull/52
+ # Fixed upstream, commentjson depends on old version and seems to be unmaintained
+ "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils",
+ # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21
+ "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session",
+ # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived)
+ "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client",
+ # https://pypi.org/project/opuslib/ - v3.0.1 - 2018-01-16
+ "ignore:\"is not\" with 'int' literal. Did you mean \"!=\"?:SyntaxWarning:.*opuslib.api.decoder",
+ # https://pypi.org/project/pilight/ - v0.1.1 - 2016-10-19
+ "ignore:pkg_resources is deprecated as an API:DeprecationWarning:pilight",
+ # https://pypi.org/project/plumlightpad/ - v0.0.11 - 2018-10-16
+ "ignore:invalid escape sequence:SyntaxWarning:.*plumlightpad.plumdiscovery",
+ "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*plumlightpad.(lightpad|logicalload)",
+ # https://pypi.org/project/pure-python-adb/ - v0.3.0.dev0 - 2020-08-05
+ "ignore:invalid escape sequence:SyntaxWarning:.*ppadb",
+ # https://pypi.org/project/pydub/ - v0.25.1 - 2021-03-10
+ "ignore:invalid escape sequence:SyntaxWarning:.*pydub.utils",
+ # https://pypi.org/project/pyiss/ - v1.0.1 - 2016-12-19
+ "ignore:\"is\" with 'int' literal. Did you mean \"==\"?:SyntaxWarning:.*pyiss",
+ # https://pypi.org/project/PyPasser/ - v0.0.5 - 2021-10-21
+ "ignore:invalid escape sequence:SyntaxWarning:.*pypasser.utils",
+ # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19
+ "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_",
+ "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_",
+ # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25
+ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants",
+ # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10
+ "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp",
]
[tool.coverage.run]
@@ -687,177 +685,175 @@ source = ["homeassistant"]
[tool.coverage.report]
exclude_lines = [
- # Have to re-enable the standard pragma
- "pragma: no cover",
- # Don't complain about missing debug-only code:
- "def __repr__",
- # Don't complain if tests don't hit defensive assertion code:
- "raise AssertionError",
- "raise NotImplementedError",
- # TYPE_CHECKING and @overload blocks are never executed during pytest run
- "if TYPE_CHECKING:",
- "@overload",
+ # Have to re-enable the standard pragma
+ "pragma: no cover",
+ # Don't complain about missing debug-only code:
+ "def __repr__",
+ # Don't complain if tests don't hit defensive assertion code:
+ "raise AssertionError",
+ "raise NotImplementedError",
+ # TYPE_CHECKING and @overload blocks are never executed during pytest run
+ "if TYPE_CHECKING:",
+ "@overload",
]
[tool.ruff]
-required-version = ">=0.9.1"
+required-version = ">=0.11.0"
[tool.ruff.lint]
select = [
- "A001", # Variable {name} is shadowing a Python builtin
- "ASYNC210", # Async functions should not call blocking HTTP methods
- "ASYNC220", # Async functions should not create subprocesses with blocking methods
- "ASYNC221", # Async functions should not run processes with blocking methods
- "ASYNC222", # Async functions should not wait on processes with blocking methods
- "ASYNC230", # Async functions should not open files with blocking methods like open
- "ASYNC251", # Async functions should not call time.sleep
- "B002", # Python does not support the unary prefix increment
- "B005", # Using .strip() with multi-character strings is misleading
- "B007", # Loop control variable {name} not used within loop body
- "B014", # Exception handler with duplicate exception
- "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it.
- "B017", # pytest.raises(BaseException) should be considered evil
- "B018", # Found useless attribute access. Either assign it to a variable or remove it.
- "B023", # Function definition does not bind loop variable {name}
- "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties
- "B026", # Star-arg unpacking after a keyword argument is strongly discouraged
- "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)?
- "B035", # Dictionary comprehension uses static key
- "B904", # Use raise from to specify exception cause
- "B905", # zip() without an explicit strict= parameter
- "BLE",
- "C", # complexity
- "COM818", # Trailing comma on bare tuple prohibited
- "D", # docstrings
- "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
- "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
- "E", # pycodestyle
- "F", # pyflakes/autoflake
- "F541", # f-string without any placeholders
- "FLY", # flynt
- "FURB", # refurb
- "G", # flake8-logging-format
- "I", # isort
- "INP", # flake8-no-pep420
- "ISC", # flake8-implicit-str-concat
- "ICN001", # import concentions; {name} should be imported as {asname}
- "LOG", # flake8-logging
- "N804", # First argument of a class method should be named cls
- "N805", # First argument of a method should be named self
- "N815", # Variable {name} in class scope should not be mixedCase
- "PERF", # Perflint
- "PGH", # pygrep-hooks
- "PIE", # flake8-pie
- "PL", # pylint
- "PT", # flake8-pytest-style
- "PTH", # flake8-pathlib
- "PYI", # flake8-pyi
- "RET", # flake8-return
- "RSE", # flake8-raise
- "RUF005", # Consider iterable unpacking instead of concatenation
- "RUF006", # Store a reference to the return value of asyncio.create_task
- "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs
- "RUF008", # Do not use mutable default values for dataclass attributes
- "RUF010", # Use explicit conversion flag
- "RUF013", # PEP 484 prohibits implicit Optional
- "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer
- "RUF017", # Avoid quadratic list summation
- "RUF018", # Avoid assignment expressions in assert statements
- "RUF019", # Unnecessary key check before dictionary access
- "RUF020", # {never_like} | T is equivalent to T
- "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear
- "RUF022", # Sort __all__
- "RUF023", # Sort __slots__
- "RUF024", # Do not pass mutable objects as values to dict.fromkeys
- "RUF026", # default_factory is a positional-only argument to defaultdict
- "RUF030", # print() call in assert statement is likely unintentional
- "RUF032", # Decimal() called with float literal argument
- "RUF033", # __post_init__ method with argument defaults
- "RUF034", # Useless if-else condition
- "RUF100", # Unused `noqa` directive
- "RUF101", # noqa directives that use redirected rule codes
- "RUF200", # Failed to parse pyproject.toml: {message}
- "S102", # Use of exec detected
- "S103", # bad-file-permissions
- "S108", # hardcoded-temp-file
- "S306", # suspicious-mktemp-usage
- "S307", # suspicious-eval-usage
- "S313", # suspicious-xmlc-element-tree-usage
- "S314", # suspicious-xml-element-tree-usage
- "S315", # suspicious-xml-expat-reader-usage
- "S316", # suspicious-xml-expat-builder-usage
- "S317", # suspicious-xml-sax-usage
- "S318", # suspicious-xml-mini-dom-usage
- "S319", # suspicious-xml-pull-dom-usage
- "S320", # suspicious-xmle-tree-usage
- "S601", # paramiko-call
- "S602", # subprocess-popen-with-shell-equals-true
- "S604", # call-with-shell-equals-true
- "S608", # hardcoded-sql-expression
- "S609", # unix-command-wildcard-injection
- "SIM", # flake8-simplify
- "SLF", # flake8-self
- "SLOT", # flake8-slots
- "T100", # Trace found: {name} used
- "T20", # flake8-print
- "TC", # flake8-type-checking
- "TID", # Tidy imports
- "TRY", # tryceratops
- "UP", # pyupgrade
- "UP031", # Use format specifiers instead of percent format
- "UP032", # Use f-string instead of `format` call
- "W", # pycodestyle
+ "A001", # Variable {name} is shadowing a Python builtin
+ "ASYNC", # flake8-async
+ "B002", # Python does not support the unary prefix increment
+ "B005", # Using .strip() with multi-character strings is misleading
+ "B007", # Loop control variable {name} not used within loop body
+ "B014", # Exception handler with duplicate exception
+ "B015", # Pointless comparison. Did you mean to assign a value? Otherwise, prepend assert or remove it.
+ "B017", # pytest.raises(BaseException) should be considered evil
+ "B018", # Found useless attribute access. Either assign it to a variable or remove it.
+ "B023", # Function definition does not bind loop variable {name}
+ "B024", # `{name}` is an abstract base class, but it has no abstract methods or properties
+ "B026", # Star-arg unpacking after a keyword argument is strongly discouraged
+ "B032", # Possible unintentional type annotation (using :). Did you mean to assign (using =)?
+ "B035", # Dictionary comprehension uses static key
+ "B904", # Use raise from to specify exception cause
+ "B905", # zip() without an explicit strict= parameter
+ "BLE",
+ "C", # complexity
+ "COM818", # Trailing comma on bare tuple prohibited
+ "D", # docstrings
+ "DTZ003", # Use datetime.now(tz=) instead of datetime.utcnow()
+ "DTZ004", # Use datetime.fromtimestamp(ts, tz=) instead of datetime.utcfromtimestamp(ts)
+ "E", # pycodestyle
+ "F", # pyflakes/autoflake
+ "F541", # f-string without any placeholders
+ "FLY", # flynt
+ "FURB", # refurb
+ "G", # flake8-logging-format
+ "I", # isort
+ "INP", # flake8-no-pep420
+ "ISC", # flake8-implicit-str-concat
+ "ICN001", # import concentions; {name} should be imported as {asname}
+ "LOG", # flake8-logging
+ "N804", # First argument of a class method should be named cls
+ "N805", # First argument of a method should be named self
+ "N815", # Variable {name} in class scope should not be mixedCase
+ "PERF", # Perflint
+ "PGH", # pygrep-hooks
+ "PIE", # flake8-pie
+ "PL", # pylint
+ "PT", # flake8-pytest-style
+ "PTH", # flake8-pathlib
+ "PYI", # flake8-pyi
+ "RET", # flake8-return
+ "RSE", # flake8-raise
+ "RUF005", # Consider iterable unpacking instead of concatenation
+ "RUF006", # Store a reference to the return value of asyncio.create_task
+ "RUF007", # Prefer itertools.pairwise() over zip() when iterating over successive pairs
+ "RUF008", # Do not use mutable default values for dataclass attributes
+ "RUF010", # Use explicit conversion flag
+ "RUF013", # PEP 484 prohibits implicit Optional
+ "RUF016", # Slice in indexed access to type {value_type} uses type {index_type} instead of an integer
+ "RUF017", # Avoid quadratic list summation
+ "RUF018", # Avoid assignment expressions in assert statements
+ "RUF019", # Unnecessary key check before dictionary access
+ "RUF020", # {never_like} | T is equivalent to T
+ "RUF021", # Parenthesize a and b expressions when chaining and and or together, to make the precedence clear
+ "RUF022", # Sort __all__
+ "RUF023", # Sort __slots__
+ "RUF024", # Do not pass mutable objects as values to dict.fromkeys
+ "RUF026", # default_factory is a positional-only argument to defaultdict
+ "RUF030", # print() call in assert statement is likely unintentional
+ "RUF032", # Decimal() called with float literal argument
+ "RUF033", # __post_init__ method with argument defaults
+ "RUF034", # Useless if-else condition
+ "RUF100", # Unused `noqa` directive
+ "RUF101", # noqa directives that use redirected rule codes
+ "RUF200", # Failed to parse pyproject.toml: {message}
+ "S102", # Use of exec detected
+ "S103", # bad-file-permissions
+ "S108", # hardcoded-temp-file
+ "S306", # suspicious-mktemp-usage
+ "S307", # suspicious-eval-usage
+ "S313", # suspicious-xmlc-element-tree-usage
+ "S314", # suspicious-xml-element-tree-usage
+ "S315", # suspicious-xml-expat-reader-usage
+ "S316", # suspicious-xml-expat-builder-usage
+ "S317", # suspicious-xml-sax-usage
+ "S318", # suspicious-xml-mini-dom-usage
+ "S319", # suspicious-xml-pull-dom-usage
+ "S601", # paramiko-call
+ "S602", # subprocess-popen-with-shell-equals-true
+ "S604", # call-with-shell-equals-true
+ "S608", # hardcoded-sql-expression
+ "S609", # unix-command-wildcard-injection
+ "SIM", # flake8-simplify
+ "SLF", # flake8-self
+ "SLOT", # flake8-slots
+ "T100", # Trace found: {name} used
+ "T20", # flake8-print
+ "TC", # flake8-type-checking
+ "TID", # Tidy imports
+ "TRY", # tryceratops
+ "UP", # pyupgrade
+ "UP031", # Use format specifiers instead of percent format
+ "UP032", # Use f-string instead of `format` call
+ "W", # pycodestyle
]
ignore = [
- "D202", # No blank lines allowed after function docstring
- "D203", # 1 blank line required before class docstring
- "D213", # Multi-line docstring summary should start at the second line
- "D406", # Section name should end with a newline
- "D407", # Section name underlining
- "E501", # line too long
+ "ASYNC109", # Async function definition with a `timeout` parameter Use `asyncio.timeout` instead
+ "ASYNC110", # Use `asyncio.Event` instead of awaiting `asyncio.sleep` in a `while` loop
+ "D202", # No blank lines allowed after function docstring
+ "D203", # 1 blank line required before class docstring
+ "D213", # Multi-line docstring summary should start at the second line
+ "D406", # Section name should end with a newline
+ "D407", # Section name underlining
+ "E501", # line too long
- "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives
- "PLR0911", # Too many return statements ({returns} > {max_returns})
- "PLR0912", # Too many branches ({branches} > {max_branches})
- "PLR0913", # Too many arguments to function call ({c_args} > {max_args})
- "PLR0915", # Too many statements ({statements} > {max_statements})
- "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
- "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
- "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception
- "PT018", # Assertion should be broken down into multiple parts
- "RUF001", # String contains ambiguous unicode character.
- "RUF002", # Docstring contains ambiguous unicode character.
- "RUF003", # Comment contains ambiguous unicode character.
- "RUF015", # Prefer next(...) over single element slice
- "SIM102", # Use a single if statement instead of nested if statements
- "SIM103", # Return the condition {condition} directly
- "SIM108", # Use ternary operator {contents} instead of if-else-block
- "SIM115", # Use context handler for opening files
+ "PLC1901", # {existing} can be simplified to {replacement} as an empty string is falsey; too many false positives
+ "PLR0911", # Too many return statements ({returns} > {max_returns})
+ "PLR0912", # Too many branches ({branches} > {max_branches})
+ "PLR0913", # Too many arguments to function call ({c_args} > {max_args})
+ "PLR0915", # Too many statements ({statements} > {max_statements})
+ "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable
+ "PLW2901", # Outer {outer_kind} variable {name} overwritten by inner {inner_kind} target
+ "PT011", # pytest.raises({exception}) is too broad, set the `match` parameter or use a more specific exception
+ "PT018", # Assertion should be broken down into multiple parts
+ "RUF001", # String contains ambiguous unicode character.
+ "RUF002", # Docstring contains ambiguous unicode character.
+ "RUF003", # Comment contains ambiguous unicode character.
+ "RUF015", # Prefer next(...) over single element slice
+ "SIM102", # Use a single if statement instead of nested if statements
+ "SIM103", # Return the condition {condition} directly
+ "SIM108", # Use ternary operator {contents} instead of if-else-block
+ "SIM115", # Use context handler for opening files
- # Moving imports into type-checking blocks can mess with pytest.patch()
- "TC001", # Move application import {} into a type-checking block
- "TC002", # Move third-party import {} into a type-checking block
- "TC003", # Move standard library import {} into a type-checking block
+ # Moving imports into type-checking blocks can mess with pytest.patch()
+ "TC001", # Move application import {} into a type-checking block
+ "TC002", # Move third-party import {} into a type-checking block
+ "TC003", # Move standard library import {} into a type-checking block
+ # Quotes for typing.cast generally not necessary, only for performance critical paths
+ "TC006", # Add quotes to type expression in typing.cast()
- "TRY003", # Avoid specifying long messages outside the exception class
- "TRY400", # Use `logging.exception` instead of `logging.error`
- # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923
- "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)`
+ "TRY003", # Avoid specifying long messages outside the exception class
+ "TRY400", # Use `logging.exception` instead of `logging.error`
+ # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923
+ "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)`
- # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
- "W191",
- "E111",
- "E114",
- "E117",
- "D206",
- "D300",
- "Q",
- "COM812",
- "COM819",
+ # May conflict with the formatter, https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
+ "W191",
+ "E111",
+ "E114",
+ "E117",
+ "D206",
+ "D300",
+ "Q",
+ "COM812",
+ "COM819",
- # Disabled because ruff does not understand type of __all__ generated by a function
- "PLE0605"
+ # Disabled because ruff does not understand type of __all__ generated by a function
+ "PLE0605",
]
[tool.ruff.lint.flake8-import-conventions.extend-aliases]
@@ -933,9 +929,7 @@ mark-parentheses = false
[tool.ruff.lint.isort]
force-sort-within-sections = true
-known-first-party = [
- "homeassistant",
-]
+known-first-party = ["homeassistant"]
combine-as-imports = true
split-on-trailing-comma = false
diff --git a/requirements.txt b/requirements.txt
index f0ff3b8054a..bfc330650e4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,14 +4,15 @@
# Home Assistant Core
aiodns==3.2.0
-aiohasupervisor==0.3.0
-aiohttp==3.11.12
+aiohasupervisor==0.3.1b1
+aiohttp==3.11.16
aiohttp_cors==0.7.0
-aiohttp-fast-zlib==0.2.2
-aiohttp-asyncmdnsresolver==0.1.0
+aiohttp-fast-zlib==0.2.3
+aiohttp-asyncmdnsresolver==0.1.1
aiozoneinfo==0.2.3
+annotatedyaml==0.4.5
astral==2.2
-async-interrupt==1.2.1
+async-interrupt==1.2.2
attrs==25.1.0
atomicwrites-homeassistant==1.4.1
audioop-lts==0.2.1
@@ -20,35 +21,43 @@ bcrypt==4.2.0
certifi>=2021.5.30
ciso8601==2.3.2
cronsim==2.6
-fnv-hash-fast==1.2.2
-hass-nabucasa==0.89.0
+fnv-hash-fast==1.4.0
+ha-ffmpeg==3.2.2
+hass-nabucasa==0.94.0
+hassil==2.2.3
httpx==0.28.1
home-assistant-bluetooth==1.13.1
+home-assistant-intents==2025.3.28
ifaddr==0.2.0
-Jinja2==3.1.5
+Jinja2==3.1.6
lru-dict==1.3.0
+mutagen==1.47.0
+numpy==2.2.2
PyJWT==2.10.1
-cryptography==44.0.0
-Pillow==11.1.0
-propcache==0.2.1
+cryptography==44.0.1
+Pillow==11.2.1
+propcache==0.3.1
pyOpenSSL==25.0.0
-orjson==3.10.12
+orjson==3.10.16
packaging>=23.1
psutil-home-assistant==0.0.1
+pymicro-vad==1.0.1
+pyspeex-noise==1.0.2
python-slugify==8.0.4
+PyTurboJPEG==1.7.5
PyYAML==6.0.2
requests==2.32.3
-securetar==2025.1.4
-SQLAlchemy==2.0.38
+securetar==2025.2.1
+SQLAlchemy==2.0.40
standard-aifc==3.13.0
standard-telnetlib==3.13.0
-typing-extensions>=4.12.2,<5.0
-ulid-transform==1.2.0
+typing-extensions>=4.13.0,<5.0
+ulid-transform==1.4.0
urllib3>=1.26.5,<2
-uv==0.5.27
+uv==0.6.10
voluptuous==0.15.2
voluptuous-serialize==2.6.0
voluptuous-openapi==0.0.6
-yarl==1.18.3
+yarl==1.20.0
webrtc-models==0.3.0
-zeroconf==0.143.0
+zeroconf==0.146.5
diff --git a/requirements_all.txt b/requirements_all.txt
index ce5d60c37cf..f34ab4a2d55 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -19,10 +19,10 @@ DoorBirdPy==3.0.8
HAP-python==4.9.2
# homeassistant.components.tasmota
-HATasmota==0.9.2
+HATasmota==0.10.0
# homeassistant.components.mastodon
-Mastodon.py==1.8.1
+Mastodon.py==2.0.1
# homeassistant.components.doods
# homeassistant.components.generic
@@ -33,7 +33,7 @@ Mastodon.py==1.8.1
# homeassistant.components.seven_segments
# homeassistant.components.sighthound
# homeassistant.components.tensorflow
-Pillow==11.1.0
+Pillow==11.2.1
# homeassistant.components.plex
PlexAPI==4.15.16
@@ -45,7 +45,7 @@ ProgettiHWSW==0.1.3
# PyBluez==0.22
# homeassistant.components.cast
-PyChromecast==14.0.5
+PyChromecast==14.0.7
# homeassistant.components.flick_electric
PyFlick==1.1.3
@@ -54,10 +54,10 @@ PyFlick==1.1.3
PyFlume==0.6.5
# homeassistant.components.fronius
-PyFronius==0.7.3
+PyFronius==0.7.7
# homeassistant.components.pyload
-PyLoadAPI==1.3.2
+PyLoadAPI==1.4.2
# homeassistant.components.met_eireann
PyMetEireann==2024.11.0
@@ -70,7 +70,7 @@ PyMetno==0.13.0
PyMicroBot==0.0.17
# homeassistant.components.nina
-PyNINA==0.3.4
+PyNINA==0.3.5
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
@@ -84,7 +84,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
-PySwitchbot==0.56.0
+PySwitchbot==0.60.0
# homeassistant.components.switchmate
PySwitchmate==0.5.1
@@ -100,7 +100,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.7.5
# homeassistant.components.vicare
-PyViCare==2.42.0
+PyViCare==2.44.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -116,7 +116,7 @@ RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
-SQLAlchemy==2.0.38
+SQLAlchemy==2.0.40
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
@@ -131,7 +131,7 @@ TwitterAPI==2.7.12
WSDiscovery==2.1.2
# homeassistant.components.accuweather
-accuweather==4.0.0
+accuweather==4.2.0
# homeassistant.components.adax
adax==0.4.0
@@ -140,7 +140,7 @@ adax==0.4.0
adb-shell[async]==0.4.4
# homeassistant.components.alarmdecoder
-adext==0.4.3
+adext==0.4.4
# homeassistant.components.adguard
adguardhome==0.7.0
@@ -179,10 +179,10 @@ aioacaia==0.1.14
aioairq==0.4.4
# homeassistant.components.airzone_cloud
-aioairzone-cloud==0.6.10
+aioairzone-cloud==0.6.11
# homeassistant.components.airzone
-aioairzone==0.9.9
+aioairzone==1.0.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -201,7 +201,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2025.1.1
+aioautomower==2025.4.0
# homeassistant.components.azure_devops
aioazuredevops==2.2.1
@@ -210,22 +210,22 @@ aioazuredevops==2.2.1
aiobafi6==0.9.0
# homeassistant.components.aws
-aiobotocore==2.13.1
+aiobotocore==2.21.1
# homeassistant.components.comelit
-aiocomelit==0.10.1
+aiocomelit==0.11.3
# homeassistant.components.dhcp
-aiodhcpwatcher==1.1.0
+aiodhcpwatcher==1.1.1
# homeassistant.components.dhcp
-aiodiscover==2.2.2
+aiodiscover==2.6.1
# homeassistant.components.dnsip
aiodns==3.2.0
# homeassistant.components.duke_energy
-aiodukeenergy==0.2.2
+aiodukeenergy==0.3.0
# homeassistant.components.eafm
aioeafm==0.1.2
@@ -234,7 +234,7 @@ aioeafm==0.1.2
aioeagle==1.1.0
# homeassistant.components.ecowitt
-aioecowitt==2024.2.1
+aioecowitt==2025.3.1
# homeassistant.components.co2signal
aioelectricitymaps==0.4.0
@@ -243,7 +243,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==29.0.0
+aioesphomeapi==30.0.1
# homeassistant.components.flo
aioflo==2021.11.0
@@ -258,22 +258,22 @@ aiogithubapi==24.6.0
aioguardian==2022.07.0
# homeassistant.components.harmony
-aioharmony==0.4.1
+aioharmony==0.5.2
# homeassistant.components.hassio
-aiohasupervisor==0.3.0
+aiohasupervisor==0.3.1b1
# homeassistant.components.home_connect
-aiohomeconnect==0.12.3
+aiohomeconnect==0.17.0
# homeassistant.components.homekit_controller
-aiohomekit==3.2.7
+aiohomekit==3.2.13
# homeassistant.components.mcp_server
aiohttp_sse==2.2.0
# homeassistant.components.hue
-aiohue==4.7.3
+aiohue==4.7.4
# homeassistant.components.imap
aioimaplib==2.0.1
@@ -291,7 +291,7 @@ aiolifx-effects==0.3.2
aiolifx-themes==0.6.4
# homeassistant.components.lifx
-aiolifx==1.1.2
+aiolifx==1.1.4
# homeassistant.components.lookin
aiolookin==1.0.0
@@ -362,7 +362,7 @@ aioridwell==2024.01.0
aioruckus==0.42
# homeassistant.components.russound_rio
-aiorussound==4.4.0
+aiorussound==4.5.0
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -371,7 +371,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==12.4.2
+aioshelly==13.4.1
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -386,7 +386,7 @@ aiosolaredge==0.2.0
aiosteamist==1.0.1
# homeassistant.components.cambridge_audio
-aiostreammagic==2.10.0
+aiostreammagic==2.11.0
# homeassistant.components.switcher_kis
aioswitcher==6.0.0
@@ -404,7 +404,7 @@ aiotedee==0.2.20
aiotractive==0.6.0
# homeassistant.components.unifi
-aiounifi==81
+aiounifi==83
# homeassistant.components.usb
aiousbwatcher==1.1.1
@@ -421,17 +421,20 @@ aiowaqi==3.1.0
# homeassistant.components.watttime
aiowatttime==0.1.1
+# homeassistant.components.webdav
+aiowebdav2==0.4.5
+
# homeassistant.components.webostv
-aiowebostv==0.6.1
+aiowebostv==0.7.3
# homeassistant.components.withings
-aiowithings==3.1.5
+aiowithings==3.1.6
# homeassistant.components.yandex_transport
aioymaps==1.2.5
# homeassistant.components.airgradient
-airgradient==0.9.1
+airgradient==0.9.2
# homeassistant.components.airly
airly==1.1.0
@@ -461,7 +464,7 @@ amcrest==1.9.8
androidtv[async]==0.0.75
# homeassistant.components.androidtv_remote
-androidtvremote2==0.1.2
+androidtvremote2==0.2.1
# homeassistant.components.anel_pwrctrl
anel-pwrctrl-homeassistant==0.0.1.dev2
@@ -473,10 +476,10 @@ anova-wifi==0.17.0
anthemav==1.4.1
# homeassistant.components.anthropic
-anthropic==0.44.0
+anthropic==0.47.2
# homeassistant.components.mcp_server
-anyio==4.8.0
+anyio==4.9.0
# homeassistant.components.weatherkit
apple_weatherkit==1.1.3
@@ -488,7 +491,7 @@ apprise==1.9.1
aprslib==0.7.2
# homeassistant.components.apsystems
-apsystems-ez1==2.4.0
+apsystems-ez1==2.5.0
# homeassistant.components.aqualogic
aqualogic==2.6
@@ -497,7 +500,7 @@ aqualogic==2.6
aranet4==2.5.1
# homeassistant.components.arcam_fmj
-arcam-fmj==1.5.2
+arcam-fmj==1.8.1
# homeassistant.components.arris_tg2492lg
arris-tg2492lg==2.2.0
@@ -511,7 +514,7 @@ asmog==0.0.6
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
-async-upnp-client==0.43.0
+async-upnp-client==0.44.0
# homeassistant.components.arve
asyncarve==0.1.1
@@ -554,7 +557,7 @@ av==13.1.0
axis==64
# homeassistant.components.fujitsu_fglair
-ayla-iot-unofficial==1.4.5
+ayla-iot-unofficial==1.4.7
# homeassistant.components.azure_event_hub
azure-eventhub==5.11.1
@@ -568,6 +571,9 @@ azure-kusto-ingest==4.5.1
# homeassistant.components.azure_service_bus
azure-servicebus==7.10.0
+# homeassistant.components.azure_storage
+azure-storage-blob==12.24.0
+
# homeassistant.components.holiday
babel==2.15.0
@@ -584,7 +590,7 @@ batinfo==0.4.2
# beacontools[scan]==2.1.0
# homeassistant.components.scrape
-beautifulsoup4==4.12.3
+beautifulsoup4==4.13.3
# homeassistant.components.beewi_smartclim
# beewi-smartclim==0.0.10
@@ -597,10 +603,10 @@ bizkaibus==0.1.1
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
-bleak-esphome==2.7.1
+bleak-esphome==2.13.1
# homeassistant.components.bluetooth
-bleak-retry-connector==3.8.1
+bleak-retry-connector==3.9.0
# homeassistant.components.bluetooth
bleak==0.22.3
@@ -621,36 +627,38 @@ bluecurrent-api==1.2.3
bluemaestro-ble==0.2.3
# homeassistant.components.decora
-# homeassistant.components.zengge
# bluepy==1.3.0
# homeassistant.components.bluetooth
bluetooth-adapters==0.21.4
# homeassistant.components.bluetooth
-bluetooth-auto-recovery==1.4.2
+bluetooth-auto-recovery==1.4.5
# homeassistant.components.bluetooth
# homeassistant.components.ld2410_ble
# homeassistant.components.led_ble
# homeassistant.components.private_ble_device
-bluetooth-data-tools==1.23.4
+bluetooth-data-tools==1.27.0
# homeassistant.components.bond
bond-async==0.2.1
+# homeassistant.components.bosch_alarm
+bosch-alarm-mode2==0.4.6
+
# homeassistant.components.bosch_shc
boschshcpy==0.2.91
# homeassistant.components.amazon_polly
# homeassistant.components.route53
-boto3==1.34.131
+boto3==1.37.1
# homeassistant.components.aws
-botocore==1.34.131
+botocore==1.37.1
# homeassistant.components.bring
-bring-api==1.0.2
+bring-api==1.1.0
# homeassistant.components.broadlink
broadlink==0.19.0
@@ -680,7 +688,7 @@ btsmarthub-devicelist==0.2.3
buienradar==1.0.6
# homeassistant.components.dhcp
-cached-ipaddress==0.8.0
+cached-ipaddress==0.10.0
# homeassistant.components.caldav
caldav==1.3.9
@@ -701,7 +709,7 @@ coinbase-advanced-py==1.2.2
coinbase==2.1.0
# homeassistant.scripts.check_config
-colorlog==6.8.2
+colorlog==6.9.0
# homeassistant.components.color_extractor
colorthief==0.2.1
@@ -738,10 +746,10 @@ datadog==0.15.0
datapoint==0.9.9
# homeassistant.components.bluetooth
-dbus-fast==2.33.0
+dbus-fast==2.43.0
# homeassistant.components.debugpy
-debugpy==1.8.11
+debugpy==1.8.13
# homeassistant.components.decora_wifi
# decora-wifi==1.4
@@ -750,7 +758,7 @@ debugpy==1.8.11
# decora==0.6
# homeassistant.components.ecovacs
-deebot-client==12.0.0
+deebot-client==12.5.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -773,7 +781,7 @@ devialet==1.5.7
devolo-home-control-api==0.18.3
# homeassistant.components.devolo_home_network
-devolo-plc-api==1.4.1
+devolo-plc-api==1.5.1
# homeassistant.components.chacon_dio
dio-chacon-wifi-api==1.2.1
@@ -794,7 +802,7 @@ dremel3dpy==2.1.1
dropmqttapi==1.0.3
# homeassistant.components.dsmr
-dsmr-parser==1.4.2
+dsmr-parser==1.4.3
# homeassistant.components.dwd_weather_warnings
dwdwfsapi==1.0.7
@@ -821,7 +829,7 @@ ebusdpy==0.0.17
ecoaliface==0.4.0
# homeassistant.components.eheimdigital
-eheimdigital==1.0.6
+eheimdigital==1.1.0
# homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14
@@ -863,7 +871,7 @@ enocean==0.50
enturclient==0.2.4
# homeassistant.components.environment_canada
-env-canada==0.7.2
+env-canada==0.10.1
# homeassistant.components.season
ephem==4.1.6
@@ -881,7 +889,7 @@ epson-projector==0.5.1
eq3btsmart==1.4.1
# homeassistant.components.esphome
-esphome-dashboard-api==1.2.3
+esphome-dashboard-api==1.3.0
# homeassistant.components.netgear_lte
eternalegypt==0.0.16
@@ -893,7 +901,7 @@ eufylife-ble-client==0.1.8
# evdev==1.6.1
# homeassistant.components.evohome
-evohome-async==1.0.2
+evohome-async==1.0.5
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -936,17 +944,17 @@ flexit_bacnet==2.2.3
flipr-api==1.6.1
# homeassistant.components.flux_led
-flux-led==1.1.3
+flux-led==1.2.0
# homeassistant.components.homekit
# homeassistant.components.recorder
-fnv-hash-fast==1.2.2
+fnv-hash-fast==1.4.0
# homeassistant.components.foobot
foobot_async==1.0.0
# homeassistant.components.forecast_solar
-forecast-solar==4.0.0
+forecast-solar==4.1.0
# homeassistant.components.fortios
fortiosapi==1.0.5
@@ -962,16 +970,16 @@ freesms==0.2.0
fritzconnection[qr]==1.14.0
# homeassistant.components.fyta
-fyta_cli==0.7.0
+fyta_cli==0.7.2
# homeassistant.components.google_translate
gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
-gardena-bluetooth==1.5.0
+gardena-bluetooth==1.6.0
# homeassistant.components.google_assistant_sdk
-gassist-text==0.0.11
+gassist-text==0.0.12
# homeassistant.components.google
gcal-sync==7.0.0
@@ -980,7 +988,7 @@ gcal-sync==7.0.0
geniushub-client==0.7.1
# homeassistant.components.geocaching
-geocachingapi==0.2.1
+geocachingapi==0.3.0
# homeassistant.components.aprs
geopy==2.3.0
@@ -1002,7 +1010,7 @@ georss-qld-bushfire-alert-client==0.8
getmac==0.9.5
# homeassistant.components.gios
-gios==5.0.0
+gios==6.0.0
# homeassistant.components.gitter
gitterpy==0.1.7
@@ -1024,19 +1032,19 @@ goodwe==0.3.6
google-api-python-client==2.71.0
# homeassistant.components.google_pubsub
-google-cloud-pubsub==2.23.0
+google-cloud-pubsub==2.29.0
# homeassistant.components.google_cloud
-google-cloud-speech==2.27.0
+google-cloud-speech==2.31.1
# homeassistant.components.google_cloud
-google-cloud-texttospeech==2.17.2
+google-cloud-texttospeech==2.25.1
# homeassistant.components.google_generative_ai_conversation
-google-generativeai==0.8.2
+google-genai==1.7.0
# homeassistant.components.nest
-google-nest-sdm==7.1.3
+google-nest-sdm==7.1.4
# homeassistant.components.google_photos
google-photos-library-api==0.12.1
@@ -1052,10 +1060,10 @@ goslide-api==0.7.0
gotailwind==0.3.0
# homeassistant.components.govee_ble
-govee-ble==0.43.0
+govee-ble==0.43.1
# homeassistant.components.govee_light_local
-govee-local-api==2.0.1
+govee-local-api==2.1.0
# homeassistant.components.remote_rpi_gpio
gpiozero==1.6.2
@@ -1076,7 +1084,7 @@ greenwavereality==0.5.1
gridnet==5.0.1
# homeassistant.components.growatt_server
-growattServer==1.5.0
+growattServer==1.6.0
# homeassistant.components.google_sheets
gspread==5.5.0
@@ -1088,7 +1096,7 @@ gstreamer-player==1.1.2
guppy3==3.1.5
# homeassistant.components.iaqualink
-h2==4.1.0
+h2==4.2.0
# homeassistant.components.ffmpeg
ha-ffmpeg==3.2.2
@@ -1099,14 +1107,17 @@ ha-iotawattpy==0.1.2
# homeassistant.components.philips_js
ha-philipsjs==3.2.2
+# homeassistant.components.homeassistant_hardware
+ha-silabs-firmware-client==0.2.0
+
# homeassistant.components.habitica
-habiticalib==0.3.5
+habiticalib==0.3.7
# homeassistant.components.bluetooth
-habluetooth==3.21.1
+habluetooth==3.39.0
# homeassistant.components.cloud
-hass-nabucasa==0.89.0
+hass-nabucasa==0.94.0
# homeassistant.components.splunk
hass-splunk==0.1.1
@@ -1115,7 +1126,7 @@ hass-splunk==0.1.1
hassil==2.2.3
# homeassistant.components.jewish_calendar
-hdate==0.11.1
+hdate[astral]==1.0.3
# homeassistant.components.heatmiser
heatmiserV3==2.0.3
@@ -1143,16 +1154,16 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
-holidays==0.66
+holidays==0.70
# homeassistant.components.frontend
-home-assistant-frontend==20250205.0
+home-assistant-frontend==20250411.0
# homeassistant.components.conversation
-home-assistant-intents==2025.2.5
+home-assistant-intents==2025.3.28
# homeassistant.components.homematicip_cloud
-homematicip==1.1.7
+homematicip==2.0.0
# homeassistant.components.horizon
horimote==0.4.1
@@ -1173,7 +1184,7 @@ hyperion-py==0.7.5
iammeter==0.2.1
# homeassistant.components.iaqualink
-iaqualink==0.5.0
+iaqualink==0.5.3
# homeassistant.components.ibeacon
ibeacon-ble==1.2.0
@@ -1184,7 +1195,8 @@ ibmiotf==0.3.4
# homeassistant.components.google
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
-ical==8.3.0
+# homeassistant.components.remote_calendar
+ical==9.1.0
# homeassistant.components.caldav
icalendar==6.1.0
@@ -1207,8 +1219,11 @@ igloohome-api==0.1.0
# homeassistant.components.ihc
ihcsdk==2.8.5
+# homeassistant.components.imeon_inverter
+imeon_inverter_api==0.3.12
+
# homeassistant.components.imgw_pib
-imgw_pib==1.0.9
+imgw_pib==1.0.10
# homeassistant.components.incomfort
incomfort-client==0.6.7
@@ -1220,7 +1235,7 @@ influxdb-client==1.24.0
influxdb==5.3.1
# homeassistant.components.inkbird
-inkbird-ble==0.5.8
+inkbird-ble==0.13.0
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.5.0
@@ -1275,7 +1290,7 @@ kiwiki-client==0.1.1
knocki==0.4.2
# homeassistant.components.knx
-knx-frontend==2025.1.30.194235
+knx-frontend==2025.3.8.214559
# homeassistant.components.konnected
konnected==1.2.0
@@ -1302,7 +1317,7 @@ ld2410-ble==0.1.1
leaone-ble==0.1.0
# homeassistant.components.led_ble
-led-ble==1.1.6
+led-ble==1.1.7
# homeassistant.components.lektrico
lektricowifi==0.0.43
@@ -1338,7 +1353,7 @@ linear-garage-door==0.2.9
linode-api==4.1.9b1
# homeassistant.components.livisi
-livisi==0.0.24
+livisi==0.0.25
# homeassistant.components.google_maps
locationsharinglib==5.0.1
@@ -1372,7 +1387,7 @@ mbddns==0.1.2
# homeassistant.components.mcp
# homeassistant.components.mcp_server
-mcp==1.1.2
+mcp==1.5.0
# homeassistant.components.minecraft_server
mcstatus==11.1.1
@@ -1393,7 +1408,7 @@ messagebird==1.2.0
meteoalertapi==0.3.1
# homeassistant.components.meteo_france
-meteofrance-api==1.3.0
+meteofrance-api==1.4.0
# homeassistant.components.mfi
mficlient==0.5.0
@@ -1417,7 +1432,7 @@ minio==7.1.12
moat-ble==0.1.1
# homeassistant.components.moehlenhoff_alpha2
-moehlenhoff-alpha2==1.3.1
+moehlenhoff-alpha2==1.4.0
# homeassistant.components.monzo
monzopy==1.4.2
@@ -1426,7 +1441,7 @@ monzopy==1.4.2
mopeka-iot-ble==0.8.0
# homeassistant.components.motion_blinds
-motionblinds==0.6.25
+motionblinds==0.6.26
# homeassistant.components.motionblinds_ble
motionblindsble==0.1.3
@@ -1441,7 +1456,7 @@ mozart-api==4.1.1.116.4
mullvad-api==1.0.0
# homeassistant.components.music_assistant
-music-assistant-client==1.0.8
+music-assistant-client==1.2.0
# homeassistant.components.tts
mutagen==1.47.0
@@ -1471,13 +1486,13 @@ netdata==1.3.0
netmap==0.7.0.2
# homeassistant.components.nam
-nettigo-air-monitor==4.0.0
+nettigo-air-monitor==4.1.0
# homeassistant.components.neurio_energy
neurio==0.3.1
# homeassistant.components.nexia
-nexia==2.0.9
+nexia==2.7.0
# homeassistant.components.nextcloud
nextcloudmonitor==1.5.1
@@ -1492,7 +1507,7 @@ nextdns==4.0.0
nhc==0.4.10
# homeassistant.components.nibe_heatpump
-nibe==2.14.0
+nibe==2.17.0
# homeassistant.components.nice_go
nice-go==1.0.1
@@ -1547,7 +1562,7 @@ odp-amsterdam==6.0.2
oemthermostat==1.1.1
# homeassistant.components.ohme
-ohme==1.2.9
+ohme==1.5.1
# homeassistant.components.ollama
ollama==0.4.7
@@ -1559,7 +1574,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
-onedrive-personal-sdk==0.0.9
+onedrive-personal-sdk==0.0.13
# homeassistant.components.onvif
onvif-zeep-async==3.2.5
@@ -1571,7 +1586,7 @@ open-garage==0.2.0
open-meteo==0.3.2
# homeassistant.components.openai_conversation
-openai==1.61.0
+openai==1.68.2
# homeassistant.components.openerz
openerz-api==0.3.0
@@ -1595,7 +1610,7 @@ openwrt-luci-rpc==1.1.17
openwrt-ubus-rpc==0.0.2
# homeassistant.components.opower
-opower==0.8.9
+opower==0.11.1
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1663,7 +1678,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
-plugwise==1.7.1
+plugwise==1.7.3
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1698,7 +1713,7 @@ proxmoxer==2.0.1
psutil-home-assistant==0.0.1
# homeassistant.components.systemmonitor
-psutil==6.1.1
+psutil==7.0.0
# homeassistant.components.pulseaudio_loopback
pulsectl==23.5.2
@@ -1710,7 +1725,7 @@ pushbullet.py==0.11.0
pushover_complete==1.1.1
# homeassistant.components.pvoutput
-pvo==2.2.0
+pvo==2.2.1
# homeassistant.components.aosmith
py-aosmith==1.0.12
@@ -1724,6 +1739,9 @@ py-ccm15==0.0.9
# homeassistant.components.cpuspeed
py-cpuinfo==9.0.0
+# homeassistant.components.pterodactyl
+py-dactyl==2.0.4
+
# homeassistant.components.dormakaba_dkey
py-dormakaba-dkey==1.0.5
@@ -1749,7 +1767,7 @@ py-schluter==0.1.7
py-sucks==0.9.10
# homeassistant.components.synology_dsm
-py-synologydsm-api==2.6.2
+py-synologydsm-api==2.7.1
# homeassistant.components.atome
pyAtome==0.1.1
@@ -1773,7 +1791,7 @@ pyEmby==1.10
pyHik==0.3.2
# homeassistant.components.homee
-pyHomee==1.2.5
+pyHomee==1.2.8
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1
@@ -1807,7 +1825,7 @@ pyairnow==1.2.1
pyairvisual==2023.08.1
# homeassistant.components.aprilaire
-pyaprilaire==0.7.7
+pyaprilaire==0.8.1
# homeassistant.components.asuswrt
pyasuswrt==0.1.21
@@ -1825,7 +1843,7 @@ pyatv==0.16.0
pyaussiebb==0.1.5
# homeassistant.components.balboa
-pybalboa==1.0.2
+pybalboa==1.1.3
# homeassistant.components.bbox
pybbox==0.0.5-alpha
@@ -1834,10 +1852,10 @@ pybbox==0.0.5-alpha
pyblackbird==0.6
# homeassistant.components.bluesound
-pyblu==2.0.0
+pyblu==2.0.1
# homeassistant.components.neato
-pybotvac==0.0.25
+pybotvac==0.0.26
# homeassistant.components.braviatv
pybravia==0.3.4
@@ -1876,7 +1894,7 @@ pycsspeechtts==1.0.8
# pycups==2.0.4
# homeassistant.components.daikin
-pydaikin==2.13.8
+pydaikin==2.15.0
# homeassistant.components.danfoss_air
pydanfossair==0.1.0
@@ -1885,7 +1903,7 @@ pydanfossair==0.1.0
pydeako==0.6.0
# homeassistant.components.deconz
-pydeconz==118
+pydeconz==120
# homeassistant.components.delijn
pydelijn==1.1.0
@@ -1900,10 +1918,10 @@ pydiscovergy==3.0.2
pydoods==1.0.2
# homeassistant.components.hydrawise
-pydrawise==2025.2.0
+pydrawise==2025.3.0
# homeassistant.components.android_ip_webcam
-pydroid-ipcam==2.0.0
+pydroid-ipcam==3.0.0
# homeassistant.components.ebox
pyebox==1.1.4
@@ -1912,7 +1930,7 @@ pyebox==1.1.4
pyecoforest==0.4.0
# homeassistant.components.econet
-pyeconet==0.1.26
+pyeconet==0.1.28
# homeassistant.components.ista_ecotrend
pyecotrend-ista==3.3.1
@@ -1933,13 +1951,13 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
-pyenphase==1.23.1
+pyenphase==1.25.5
# homeassistant.components.envisalink
pyenvisalink==4.7
# homeassistant.components.ephember
-pyephember==0.3.1
+pyephember2==0.4.12
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1951,7 +1969,7 @@ pyevilgenius==2.0.0
pyezviz==0.2.1.2
# homeassistant.components.fibaro
-pyfibaro==0.8.0
+pyfibaro==0.8.2
# homeassistant.components.fido
pyfido==2.1.2
@@ -1972,7 +1990,7 @@ pyforked-daapd==0.1.14
pyfreedompro==1.1.0
# homeassistant.components.fritzbox
-pyfritzhome==0.6.14
+pyfritzhome==0.6.17
# homeassistant.components.ifttt
pyfttt==0.3
@@ -1990,10 +2008,10 @@ pygti==0.9.4
pyhaversion==22.8.0
# homeassistant.components.heos
-pyheos==1.0.1
+pyheos==1.0.5
# homeassistant.components.hive
-pyhive-integration==1.0.1
+pyhive-integration==1.0.2
# homeassistant.components.homematic
pyhomematic==0.1.77
@@ -2014,7 +2032,7 @@ pyinsteon==1.6.3
pyintesishome==1.8.0
# homeassistant.components.ipma
-pyipma==3.0.8
+pyipma==3.0.9
# homeassistant.components.ipp
pyipp==0.17.0
@@ -2032,7 +2050,7 @@ pyiskra==0.1.15
pyiss==1.0.1
# homeassistant.components.isy994
-pyisy==3.1.14
+pyisy==3.4.0
# homeassistant.components.itach
pyitachip2ir==0.0.7
@@ -2062,7 +2080,7 @@ pykoplenti==1.3.0
pykrakenapi==0.1.8
# homeassistant.components.kulersky
-pykulersky==0.5.2
+pykulersky==0.5.8
# homeassistant.components.kwb
pykwb==0.0.8
@@ -2071,7 +2089,7 @@ pykwb==0.0.8
pylacrosse==0.4
# homeassistant.components.lamarzocco
-pylamarzocco==1.4.6
+pylamarzocco==2.0.0b1
# homeassistant.components.lastfm
pylast==5.1.0
@@ -2092,10 +2110,10 @@ pylitejet==0.6.3
pylitterbot==2024.0.0
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.23.0
+pylutron-caseta==0.24.0
# homeassistant.components.lutron
-pylutron==0.2.16
+pylutron==0.2.18
# homeassistant.components.mailgun
pymailgunner==1.4
@@ -2115,6 +2133,9 @@ pymeteoclimatic==0.1.0
# homeassistant.components.assist_pipeline
pymicro-vad==1.0.1
+# homeassistant.components.miele
+pymiele==0.3.4
+
# homeassistant.components.xiaomi_tv
pymitv==1.4.3
@@ -2134,7 +2155,7 @@ pymsteams==0.1.12
pymysensors==0.24.0
# homeassistant.components.iron_os
-pynecil==4.0.1
+pynecil==4.1.0
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -2173,7 +2194,7 @@ pyombi==0.1.10
pyopenuv==2023.02.0
# homeassistant.components.openweathermap
-pyopenweathermap==0.2.1
+pyopenweathermap==0.2.2
# homeassistant.components.opnsense
pyopnsense==0.4.0
@@ -2193,7 +2214,7 @@ pyotgw==2.2.2
pyotp==2.8.0
# homeassistant.components.overkiz
-pyoverkiz==1.16.0
+pyoverkiz==1.17.0
# homeassistant.components.onewire
pyownet==0.10.0.post1
@@ -2207,6 +2228,9 @@ pypca==0.0.7
# homeassistant.components.lcn
pypck==0.8.5
+# homeassistant.components.pglab
+pypglab==0.0.5
+
# homeassistant.components.pjlink
pypjlink2==1.2.1
@@ -2220,7 +2244,7 @@ pypoint==3.0.0
pyprof2calltree==1.4.5
# homeassistant.components.prosegur
-pyprosegur==0.0.9
+pyprosegur==0.0.14
# homeassistant.components.prusalink
pyprusalink==2.1.1
@@ -2235,7 +2259,7 @@ pyqvrpro==0.52
pyqwikswitch==0.93
# homeassistant.components.nmbs
-pyrail==0.0.3
+pyrail==0.4.1
# homeassistant.components.rainbird
pyrainbird==6.0.1
@@ -2247,7 +2271,7 @@ pyrecswitch==1.0.2
pyrepetierng==0.1.0
# homeassistant.components.risco
-pyrisco==0.6.5
+pyrisco==0.6.7
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.6
@@ -2256,7 +2280,7 @@ pyrituals==0.0.6
pyroute2==0.7.5
# homeassistant.components.rympro
-pyrympro==0.0.8
+pyrympro==0.0.9
# homeassistant.components.sabnzbd
pysabnzbd==1.1.1
@@ -2271,7 +2295,7 @@ pyschlage==2024.11.0
pysensibo==1.1.0
# homeassistant.components.serial
-pyserial-asyncio-fast==0.14
+pyserial-asyncio-fast==0.16
# homeassistant.components.acer_projector
# homeassistant.components.crownstone
@@ -2283,7 +2307,7 @@ pyserial==3.5
pysesame2==1.0.1
# homeassistant.components.seventeentrack
-pyseventeentrack==1.0.1
+pyseventeentrack==1.0.2
# homeassistant.components.sia
pysiaalarm==3.1.1
@@ -2301,22 +2325,19 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
-pysmartapp==0.3.5
-
-# homeassistant.components.smartthings
-pysmartthings==0.7.8
+pysmartthings==3.0.4
# homeassistant.components.smarty
-pysmarty2==0.10.1
+pysmarty2==0.10.2
# homeassistant.components.smhi
-pysmhi==1.0.0
+pysmhi==1.0.2
# homeassistant.components.edl21
pysml==0.0.12
# homeassistant.components.smlight
-pysmlight==0.2.3
+pysmlight==0.2.4
# homeassistant.components.snmp
pysnmp==6.2.6
@@ -2334,13 +2355,13 @@ pyspcwebgw==0.7.0
pyspeex-noise==1.0.2
# homeassistant.components.squeezebox
-pysqueezebox==0.11.1
+pysqueezebox==0.12.0
# homeassistant.components.stiebel_eltron
pystiebeleltron==0.0.1.dev2
# homeassistant.components.suez_water
-pysuezV2==2.0.3
+pysuezV2==2.0.4
# homeassistant.components.switchbee
pyswitchbee==1.8.3
@@ -2412,10 +2433,10 @@ python-join-api==0.0.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.10.1
+python-kasa[speedups]==0.10.2
# homeassistant.components.linkplay
-python-linkplay==0.1.3
+python-linkplay==0.2.3
# homeassistant.components.lirc
# python-lirc==1.2.3
@@ -2443,10 +2464,10 @@ python-opensky==1.0.1
python-otbr-api==2.7.0
# homeassistant.components.overseerr
-python-overseerr==0.7.0
+python-overseerr==0.7.1
# homeassistant.components.picnic
-python-picnic-api==1.1.0
+python-picnic-api2==1.2.4
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
@@ -2455,19 +2476,22 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
-python-roborock==2.11.1
+python-roborock==2.16.1
# homeassistant.components.smarttub
-python-smarttub==0.0.38
+python-smarttub==0.0.39
+
+# homeassistant.components.snoo
+python-snoo==0.6.5
# homeassistant.components.songpal
python-songpal==0.16.2
# homeassistant.components.tado
-python-tado==0.18.6
+python-tado==0.18.11
# homeassistant.components.technove
-python-technove==1.3.1
+python-technove==2.0.0
# homeassistant.components.telegram_bot
python-telegram-bot[socks]==21.5
@@ -2485,7 +2509,7 @@ pytile==2024.12.0
pytomorrowio==0.3.6
# homeassistant.components.touchline
-pytouchline==0.7
+pytouchline_extended==0.4.5
# homeassistant.components.touchline_sl
pytouchlinesl==0.3.0
@@ -2519,7 +2543,7 @@ pyvera==0.3.15
pyversasense==0.0.6
# homeassistant.components.vesync
-pyvesync==2.1.17
+pyvesync==2.1.18
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2546,7 +2570,7 @@ pywemo==1.4.0
pywilight==0.0.74
# homeassistant.components.wiz
-pywizlight==0.5.14
+pywizlight==0.6.2
# homeassistant.components.wmspro
pywmspro==0.2.1
@@ -2567,10 +2591,10 @@ pyzbar==0.1.7
pyzerproc==0.4.8
# homeassistant.components.qbittorrent
-qbittorrent-api==2024.2.59
+qbittorrent-api==2024.9.67
# homeassistant.components.qbus
-qbusmqttapi==1.2.4
+qbusmqttapi==1.3.0
# homeassistant.components.qingping
qingping-ble==0.10.0
@@ -2609,7 +2633,7 @@ renault-api==0.2.9
renson-endura-delta==1.7.2
# homeassistant.components.reolink
-reolink-aio==0.11.10
+reolink-aio==0.13.2
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@@ -2678,14 +2702,14 @@ screenlogicpy==0.10.0
scsgate==0.1.0
# homeassistant.components.backup
-securetar==2025.1.4
+securetar==2025.2.1
# homeassistant.components.sendgrid
sendgrid==6.8.2
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
-sense-energy==0.13.4
+sense-energy==0.13.7
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@@ -2693,20 +2717,26 @@ sensirion-ble==0.1.1
# homeassistant.components.sensorpro
sensorpro-ble==0.5.3
+# homeassistant.components.sensorpush_cloud
+sensorpush-api==2.1.2
+
# homeassistant.components.sensorpush
sensorpush-ble==1.7.1
+# homeassistant.components.sensorpush_cloud
+sensorpush-ha==1.3.2
+
# homeassistant.components.sensoterra
sensoterra==2.0.1
# homeassistant.components.sentry
-sentry-sdk==1.40.3
+sentry-sdk==1.45.1
# homeassistant.components.sfr_box
sfrbox-api==0.0.11
# homeassistant.components.sharkiq
-sharkiq==1.0.2
+sharkiq==1.1.0
# homeassistant.components.aquostv
sharp_aquos_rc==0.3.2
@@ -2745,7 +2775,7 @@ smart-meter-texas==0.5.5
snapcast==2.3.6
# homeassistant.components.sonos
-soco==0.30.8
+soco==0.30.9
# homeassistant.components.solaredge_local
solaredge-local==0.2.3
@@ -2793,7 +2823,7 @@ statsd==3.2.1
steamodd==4.21
# homeassistant.components.stookwijzer
-stookwijzer==1.5.1
+stookwijzer==1.6.1
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -2806,9 +2836,6 @@ stringcase==1.2.0
# homeassistant.components.subaru
subarulink==0.7.13
-# homeassistant.components.sunweg
-sunweg==3.0.2
-
# homeassistant.components.surepetcare
surepy==0.9.0
@@ -2857,7 +2884,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
-tesla-fleet-api==0.9.8
+tesla-fleet-api==1.0.17
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -2866,7 +2893,7 @@ tesla-powerwall==0.5.2
tesla-wall-connector==1.0.2
# homeassistant.components.teslemetry
-teslemetry-stream==0.6.10
+teslemetry-stream==0.7.1
# homeassistant.components.tessie
tessie-api==0.1.1
@@ -2875,7 +2902,7 @@ tessie-api==0.1.1
# tf-models-official==2.5.0
# homeassistant.components.thermobeacon
-thermobeacon-ble==0.7.0
+thermobeacon-ble==0.8.1
# homeassistant.components.thermopro
thermopro-ble==0.11.0
@@ -2884,7 +2911,7 @@ thermopro-ble==0.11.0
thingspeak==1.0.0
# homeassistant.components.lg_thinq
-thinqconnect==1.0.4
+thinqconnect==1.0.5
# homeassistant.components.tikteck
tikteck==0.4
@@ -2911,7 +2938,7 @@ total-connect-client==2025.1.4
tp-connected==0.0.4
# homeassistant.components.tplink_omada
-tplink-omada-client==1.4.3
+tplink-omada-client==1.4.4
# homeassistant.components.transmission
transmission-rpc==7.0.3
@@ -2944,7 +2971,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==7.5.1
+uiprotect==7.5.3
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2959,10 +2986,10 @@ unifi_ap==0.0.2
unifiled==0.11
# homeassistant.components.homeassistant_hardware
-universal-silabs-flasher==0.0.25
+universal-silabs-flasher==0.0.30
# homeassistant.components.upb
-upb-lib==0.6.0
+upb-lib==0.6.1
# homeassistant.components.upcloud
upcloud-api==2.6.0
@@ -2970,7 +2997,7 @@ upcloud-api==2.6.0
# homeassistant.components.huawei_lte
# homeassistant.components.syncthru
# homeassistant.components.zwave_me
-url-normalize==1.4.3
+url-normalize==2.2.0
# homeassistant.components.uvc
uvcclient==0.12.1
@@ -2985,7 +3012,7 @@ vallox-websocket-api==5.3.0
vehicle==2.2.2
# homeassistant.components.velbus
-velbus-aio==2025.1.1
+velbus-aio==2025.3.1
# homeassistant.components.venstar
venstarcolortouch==0.19
@@ -3019,7 +3046,7 @@ vultr==0.1.2
wakeonlan==2.1.0
# homeassistant.components.wallbox
-wallbox==0.7.0
+wallbox==0.8.0
# homeassistant.components.folder_watcher
watchdog==6.0.0
@@ -3031,7 +3058,7 @@ waterfurnace==1.1.0
watergate-local-api==2024.4.1
# homeassistant.components.weatherflow_cloud
-weatherflow4py==1.0.6
+weatherflow4py==1.3.1
# homeassistant.components.cisco_webex_teams
webexpythonsdk==2.0.1
@@ -3043,10 +3070,10 @@ webio-api==0.1.11
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
-weheat==2025.1.15
+weheat==2025.3.7
# homeassistant.components.whirlpool
-whirlpool-sixth-sense==0.18.12
+whirlpool-sixth-sense==0.20.0
# homeassistant.components.whois
whois==0.9.27
@@ -3061,7 +3088,7 @@ wirelesstagpy==0.8.1
wled==0.21.0
# homeassistant.components.wolflink
-wolf-comm==0.0.15
+wolf-comm==0.0.23
# homeassistant.components.wyoming
wyoming==1.5.4
@@ -3070,13 +3097,13 @@ wyoming==1.5.4
xbox-webapi==2.1.0
# homeassistant.components.xiaomi_ble
-xiaomi-ble==0.33.0
+xiaomi-ble==0.37.0
# homeassistant.components.knx
-xknx==3.5.0
+xknx==3.6.0
# homeassistant.components.knx
-xknxproject==3.8.1
+xknxproject==3.8.2
# homeassistant.components.fritz
# homeassistant.components.rest
@@ -3107,7 +3134,7 @@ yeelight==0.7.16
yeelightsunflower==0.0.10
# homeassistant.components.yolink
-yolink-api==0.4.7
+yolink-api==0.4.9
# homeassistant.components.youless
youless-api==2.2.0
@@ -3116,7 +3143,7 @@ youless-api==2.2.0
youtubeaio==1.1.5
# homeassistant.components.media_extractor
-yt-dlp[default]==2025.01.26
+yt-dlp[default]==2025.03.26
# homeassistant.components.zabbix
zabbix-utils==2.0.2
@@ -3124,17 +3151,14 @@ zabbix-utils==2.0.2
# homeassistant.components.zamg
zamg==0.3.6
-# homeassistant.components.zengge
-zengge==0.2
-
# homeassistant.components.zeroconf
-zeroconf==0.143.0
+zeroconf==0.146.5
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.48
+zha==0.0.56
# homeassistant.components.zhong_hong
zhong-hong-hvac==1.0.13
@@ -3146,7 +3170,7 @@ ziggo-mediabox-xl==1.1.0
zm-py==0.5.4
# homeassistant.components.zwave_js
-zwave-js-server-python==0.60.0
+zwave-js-server-python==0.62.0
# homeassistant.components.zwave_me
zwave-me-ws==0.4.3
diff --git a/requirements_test.txt b/requirements_test.txt
index 2731114043b..80be991cfcd 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -7,18 +7,19 @@
-c homeassistant/package_constraints.txt
-r requirements_test_pre_commit.txt
-astroid==3.3.8
-coverage==7.6.10
+astroid==3.3.9
+coverage==7.6.12
freezegun==1.5.1
+go2rtc-client==0.1.2
license-expression==30.4.1
mock-open==1.4.0
-mypy-dev==1.16.0a2
+mypy-dev==1.16.0a8
pre-commit==4.0.0
-pydantic==2.10.6
-pylint==3.3.4
+pydantic==2.11.3
+pylint==3.3.6
pylint-per-file-ignores==1.4.0
-pipdeptree==2.25.0
-pytest-asyncio==0.25.3
+pipdeptree==2.25.1
+pytest-asyncio==0.26.0
pytest-aiohttp==1.1.0
pytest-cov==6.0.0
pytest-freezer==0.4.9
@@ -29,26 +30,24 @@ pytest-timeout==2.3.1
pytest-unordered==0.6.1
pytest-picked==0.5.1
pytest-xdist==3.6.1
-pytest==8.3.4
+pytest==8.3.5
requests-mock==1.12.1
respx==0.22.0
syrupy==4.8.1
tqdm==4.67.1
-types-aiofiles==24.1.0.20241221
+types-aiofiles==24.1.0.20250326
types-atomicwrites==1.4.5.1
-types-croniter==5.0.1.20241205
-types-beautifulsoup4==4.12.0.20250204
+types-croniter==6.0.0.20250411
types-caldav==1.3.0.20241107
types-chardet==0.1.5
-types-decorator==5.1.8.20250121
+types-decorator==5.2.0.20250324
types-pexpect==4.9.0.20241208
-types-pillow==10.2.0.20240822
-types-protobuf==5.29.1.20241207
-types-psutil==6.1.0.20241221
-types-pyserial==3.5.0.20250130
+types-protobuf==5.29.1.20250403
+types-psutil==7.0.0.20250401
+types-pyserial==3.5.0.20250326
types-python-dateutil==2.9.0.20241206
types-python-slugify==8.0.2.20240310
-types-pytz==2025.1.0.20250204
-types-PyYAML==6.0.12.20241230
+types-pytz==2025.2.0.20250326
+types-PyYAML==6.0.12.20250402
types-requests==2.31.0.3
types-xmltodict==0.13.0.3
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index b13a2d677e6..82d6baed915 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -19,10 +19,10 @@ DoorBirdPy==3.0.8
HAP-python==4.9.2
# homeassistant.components.tasmota
-HATasmota==0.9.2
+HATasmota==0.10.0
# homeassistant.components.mastodon
-Mastodon.py==1.8.1
+Mastodon.py==2.0.1
# homeassistant.components.doods
# homeassistant.components.generic
@@ -33,7 +33,7 @@ Mastodon.py==1.8.1
# homeassistant.components.seven_segments
# homeassistant.components.sighthound
# homeassistant.components.tensorflow
-Pillow==11.1.0
+Pillow==11.2.1
# homeassistant.components.plex
PlexAPI==4.15.16
@@ -42,7 +42,7 @@ PlexAPI==4.15.16
ProgettiHWSW==0.1.3
# homeassistant.components.cast
-PyChromecast==14.0.5
+PyChromecast==14.0.7
# homeassistant.components.flick_electric
PyFlick==1.1.3
@@ -51,10 +51,10 @@ PyFlick==1.1.3
PyFlume==0.6.5
# homeassistant.components.fronius
-PyFronius==0.7.3
+PyFronius==0.7.7
# homeassistant.components.pyload
-PyLoadAPI==1.3.2
+PyLoadAPI==1.4.2
# homeassistant.components.met_eireann
PyMetEireann==2024.11.0
@@ -67,7 +67,7 @@ PyMetno==0.13.0
PyMicroBot==0.0.17
# homeassistant.components.nina
-PyNINA==0.3.4
+PyNINA==0.3.5
# homeassistant.components.mobile_app
# homeassistant.components.owntracks
@@ -81,7 +81,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
-PySwitchbot==0.56.0
+PySwitchbot==0.60.0
# homeassistant.components.syncthru
PySyncThru==0.8.0
@@ -94,7 +94,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.7.5
# homeassistant.components.vicare
-PyViCare==2.42.0
+PyViCare==2.44.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -110,7 +110,7 @@ RtmAPI==0.7.2
# homeassistant.components.recorder
# homeassistant.components.sql
-SQLAlchemy==2.0.38
+SQLAlchemy==2.0.40
# homeassistant.components.tami4
Tami4EdgeAPI==3.0
@@ -119,7 +119,7 @@ Tami4EdgeAPI==3.0
WSDiscovery==2.1.2
# homeassistant.components.accuweather
-accuweather==4.0.0
+accuweather==4.2.0
# homeassistant.components.adax
adax==0.4.0
@@ -128,7 +128,7 @@ adax==0.4.0
adb-shell[async]==0.4.4
# homeassistant.components.alarmdecoder
-adext==0.4.3
+adext==0.4.4
# homeassistant.components.adguard
adguardhome==0.7.0
@@ -167,10 +167,10 @@ aioacaia==0.1.14
aioairq==0.4.4
# homeassistant.components.airzone_cloud
-aioairzone-cloud==0.6.10
+aioairzone-cloud==0.6.11
# homeassistant.components.airzone
-aioairzone==0.9.9
+aioairzone==1.0.0
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -189,7 +189,7 @@ aioaseko==1.0.0
aioasuswrt==1.4.0
# homeassistant.components.husqvarna_automower
-aioautomower==2025.1.1
+aioautomower==2025.4.0
# homeassistant.components.azure_devops
aioazuredevops==2.2.1
@@ -198,22 +198,22 @@ aioazuredevops==2.2.1
aiobafi6==0.9.0
# homeassistant.components.aws
-aiobotocore==2.13.1
+aiobotocore==2.21.1
# homeassistant.components.comelit
-aiocomelit==0.10.1
+aiocomelit==0.11.3
# homeassistant.components.dhcp
-aiodhcpwatcher==1.1.0
+aiodhcpwatcher==1.1.1
# homeassistant.components.dhcp
-aiodiscover==2.2.2
+aiodiscover==2.6.1
# homeassistant.components.dnsip
aiodns==3.2.0
# homeassistant.components.duke_energy
-aiodukeenergy==0.2.2
+aiodukeenergy==0.3.0
# homeassistant.components.eafm
aioeafm==0.1.2
@@ -222,7 +222,7 @@ aioeafm==0.1.2
aioeagle==1.1.0
# homeassistant.components.ecowitt
-aioecowitt==2024.2.1
+aioecowitt==2025.3.1
# homeassistant.components.co2signal
aioelectricitymaps==0.4.0
@@ -231,7 +231,7 @@ aioelectricitymaps==0.4.0
aioemonitor==1.0.5
# homeassistant.components.esphome
-aioesphomeapi==29.0.0
+aioesphomeapi==30.0.1
# homeassistant.components.flo
aioflo==2021.11.0
@@ -243,22 +243,22 @@ aiogithubapi==24.6.0
aioguardian==2022.07.0
# homeassistant.components.harmony
-aioharmony==0.4.1
+aioharmony==0.5.2
# homeassistant.components.hassio
-aiohasupervisor==0.3.0
+aiohasupervisor==0.3.1b1
# homeassistant.components.home_connect
-aiohomeconnect==0.12.3
+aiohomeconnect==0.17.0
# homeassistant.components.homekit_controller
-aiohomekit==3.2.7
+aiohomekit==3.2.13
# homeassistant.components.mcp_server
aiohttp_sse==2.2.0
# homeassistant.components.hue
-aiohue==4.7.3
+aiohue==4.7.4
# homeassistant.components.imap
aioimaplib==2.0.1
@@ -273,7 +273,7 @@ aiolifx-effects==0.3.2
aiolifx-themes==0.6.4
# homeassistant.components.lifx
-aiolifx==1.1.2
+aiolifx==1.1.4
# homeassistant.components.lookin
aiolookin==1.0.0
@@ -344,7 +344,7 @@ aioridwell==2024.01.0
aioruckus==0.42
# homeassistant.components.russound_rio
-aiorussound==4.4.0
+aiorussound==4.5.0
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0
@@ -353,7 +353,7 @@ aioruuvigateway==0.1.0
aiosenz==1.0.0
# homeassistant.components.shelly
-aioshelly==12.4.2
+aioshelly==13.4.1
# homeassistant.components.skybell
aioskybell==22.7.0
@@ -368,7 +368,7 @@ aiosolaredge==0.2.0
aiosteamist==1.0.1
# homeassistant.components.cambridge_audio
-aiostreammagic==2.10.0
+aiostreammagic==2.11.0
# homeassistant.components.switcher_kis
aioswitcher==6.0.0
@@ -386,7 +386,7 @@ aiotedee==0.2.20
aiotractive==0.6.0
# homeassistant.components.unifi
-aiounifi==81
+aiounifi==83
# homeassistant.components.usb
aiousbwatcher==1.1.1
@@ -403,17 +403,20 @@ aiowaqi==3.1.0
# homeassistant.components.watttime
aiowatttime==0.1.1
+# homeassistant.components.webdav
+aiowebdav2==0.4.5
+
# homeassistant.components.webostv
-aiowebostv==0.6.1
+aiowebostv==0.7.3
# homeassistant.components.withings
-aiowithings==3.1.5
+aiowithings==3.1.6
# homeassistant.components.yandex_transport
aioymaps==1.2.5
# homeassistant.components.airgradient
-airgradient==0.9.1
+airgradient==0.9.2
# homeassistant.components.airly
airly==1.1.0
@@ -437,7 +440,7 @@ amberelectric==2.0.12
androidtv[async]==0.0.75
# homeassistant.components.androidtv_remote
-androidtvremote2==0.1.2
+androidtvremote2==0.2.1
# homeassistant.components.anova
anova-wifi==0.17.0
@@ -446,10 +449,10 @@ anova-wifi==0.17.0
anthemav==1.4.1
# homeassistant.components.anthropic
-anthropic==0.44.0
+anthropic==0.47.2
# homeassistant.components.mcp_server
-anyio==4.8.0
+anyio==4.9.0
# homeassistant.components.weatherkit
apple_weatherkit==1.1.3
@@ -461,13 +464,13 @@ apprise==1.9.1
aprslib==0.7.2
# homeassistant.components.apsystems
-apsystems-ez1==2.4.0
+apsystems-ez1==2.5.0
# homeassistant.components.aranet
aranet4==2.5.1
# homeassistant.components.arcam_fmj
-arcam-fmj==1.5.2
+arcam-fmj==1.8.1
# homeassistant.components.dlna_dmr
# homeassistant.components.dlna_dms
@@ -475,7 +478,7 @@ arcam-fmj==1.5.2
# homeassistant.components.ssdp
# homeassistant.components.upnp
# homeassistant.components.yeelight
-async-upnp-client==0.43.0
+async-upnp-client==0.44.0
# homeassistant.components.arve
asyncarve==0.1.1
@@ -503,7 +506,7 @@ av==13.1.0
axis==64
# homeassistant.components.fujitsu_fglair
-ayla-iot-unofficial==1.4.5
+ayla-iot-unofficial==1.4.7
# homeassistant.components.azure_event_hub
azure-eventhub==5.11.1
@@ -514,6 +517,9 @@ azure-kusto-data[aio]==4.5.1
# homeassistant.components.azure_data_explorer
azure-kusto-ingest==4.5.1
+# homeassistant.components.azure_storage
+azure-storage-blob==12.24.0
+
# homeassistant.components.holiday
babel==2.15.0
@@ -521,17 +527,17 @@ babel==2.15.0
base36==0.1.1
# homeassistant.components.scrape
-beautifulsoup4==4.12.3
+beautifulsoup4==4.13.3
# homeassistant.components.bmw_connected_drive
bimmer-connected[china]==0.17.2
# homeassistant.components.eq3btsmart
# homeassistant.components.esphome
-bleak-esphome==2.7.1
+bleak-esphome==2.13.1
# homeassistant.components.bluetooth
-bleak-retry-connector==3.8.1
+bleak-retry-connector==3.9.0
# homeassistant.components.bluetooth
bleak==0.22.3
@@ -552,25 +558,28 @@ bluemaestro-ble==0.2.3
bluetooth-adapters==0.21.4
# homeassistant.components.bluetooth
-bluetooth-auto-recovery==1.4.2
+bluetooth-auto-recovery==1.4.5
# homeassistant.components.bluetooth
# homeassistant.components.ld2410_ble
# homeassistant.components.led_ble
# homeassistant.components.private_ble_device
-bluetooth-data-tools==1.23.4
+bluetooth-data-tools==1.27.0
# homeassistant.components.bond
bond-async==0.2.1
+# homeassistant.components.bosch_alarm
+bosch-alarm-mode2==0.4.6
+
# homeassistant.components.bosch_shc
boschshcpy==0.2.91
# homeassistant.components.aws
-botocore==1.34.131
+botocore==1.37.1
# homeassistant.components.bring
-bring-api==1.0.2
+bring-api==1.1.0
# homeassistant.components.broadlink
broadlink==0.19.0
@@ -591,7 +600,7 @@ bthome-ble==3.12.4
buienradar==1.0.6
# homeassistant.components.dhcp
-cached-ipaddress==0.8.0
+cached-ipaddress==0.10.0
# homeassistant.components.caldav
caldav==1.3.9
@@ -603,7 +612,7 @@ coinbase-advanced-py==1.2.2
coinbase==2.1.0
# homeassistant.scripts.check_config
-colorlog==6.8.2
+colorlog==6.9.0
# homeassistant.components.color_extractor
colorthief==0.2.1
@@ -634,13 +643,13 @@ datadog==0.15.0
datapoint==0.9.9
# homeassistant.components.bluetooth
-dbus-fast==2.33.0
+dbus-fast==2.43.0
# homeassistant.components.debugpy
-debugpy==1.8.11
+debugpy==1.8.13
# homeassistant.components.ecovacs
-deebot-client==12.0.0
+deebot-client==12.5.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -663,7 +672,7 @@ devialet==1.5.7
devolo-home-control-api==0.18.3
# homeassistant.components.devolo_home_network
-devolo-plc-api==1.4.1
+devolo-plc-api==1.5.1
# homeassistant.components.chacon_dio
dio-chacon-wifi-api==1.2.1
@@ -681,7 +690,7 @@ dremel3dpy==2.1.1
dropmqttapi==1.0.3
# homeassistant.components.dsmr
-dsmr-parser==1.4.2
+dsmr-parser==1.4.3
# homeassistant.components.dwd_weather_warnings
dwdwfsapi==1.0.7
@@ -699,7 +708,7 @@ eagle100==0.1.1
easyenergy==2.1.2
# homeassistant.components.eheimdigital
-eheimdigital==1.0.6
+eheimdigital==1.1.0
# homeassistant.components.electric_kiwi
electrickiwi-api==0.9.14
@@ -732,7 +741,7 @@ energyzero==2.1.1
enocean==0.50
# homeassistant.components.environment_canada
-env-canada==0.7.2
+env-canada==0.10.1
# homeassistant.components.season
ephem==4.1.6
@@ -750,7 +759,7 @@ epson-projector==0.5.1
eq3btsmart==1.4.1
# homeassistant.components.esphome
-esphome-dashboard-api==1.2.3
+esphome-dashboard-api==1.3.0
# homeassistant.components.netgear_lte
eternalegypt==0.0.16
@@ -759,7 +768,7 @@ eternalegypt==0.0.16
eufylife-ble-client==0.1.8
# homeassistant.components.evohome
-evohome-async==1.0.2
+evohome-async==1.0.5
# homeassistant.components.bryant_evolution
evolutionhttp==0.0.18
@@ -795,17 +804,17 @@ flexit_bacnet==2.2.3
flipr-api==1.6.1
# homeassistant.components.flux_led
-flux-led==1.1.3
+flux-led==1.2.0
# homeassistant.components.homekit
# homeassistant.components.recorder
-fnv-hash-fast==1.2.2
+fnv-hash-fast==1.4.0
# homeassistant.components.foobot
foobot_async==1.0.0
# homeassistant.components.forecast_solar
-forecast-solar==4.0.0
+forecast-solar==4.1.0
# homeassistant.components.freebox
freebox-api==1.2.2
@@ -815,16 +824,16 @@ freebox-api==1.2.2
fritzconnection[qr]==1.14.0
# homeassistant.components.fyta
-fyta_cli==0.7.0
+fyta_cli==0.7.2
# homeassistant.components.google_translate
gTTS==2.5.3
# homeassistant.components.gardena_bluetooth
-gardena-bluetooth==1.5.0
+gardena-bluetooth==1.6.0
# homeassistant.components.google_assistant_sdk
-gassist-text==0.0.11
+gassist-text==0.0.12
# homeassistant.components.google
gcal-sync==7.0.0
@@ -833,7 +842,7 @@ gcal-sync==7.0.0
geniushub-client==0.7.1
# homeassistant.components.geocaching
-geocachingapi==0.2.1
+geocachingapi==0.3.0
# homeassistant.components.aprs
geopy==2.3.0
@@ -855,7 +864,7 @@ georss-qld-bushfire-alert-client==0.8
getmac==0.9.5
# homeassistant.components.gios
-gios==5.0.0
+gios==6.0.0
# homeassistant.components.glances
glances-api==0.8.0
@@ -874,19 +883,19 @@ goodwe==0.3.6
google-api-python-client==2.71.0
# homeassistant.components.google_pubsub
-google-cloud-pubsub==2.23.0
+google-cloud-pubsub==2.29.0
# homeassistant.components.google_cloud
-google-cloud-speech==2.27.0
+google-cloud-speech==2.31.1
# homeassistant.components.google_cloud
-google-cloud-texttospeech==2.17.2
+google-cloud-texttospeech==2.25.1
# homeassistant.components.google_generative_ai_conversation
-google-generativeai==0.8.2
+google-genai==1.7.0
# homeassistant.components.nest
-google-nest-sdm==7.1.3
+google-nest-sdm==7.1.4
# homeassistant.components.google_photos
google-photos-library-api==0.12.1
@@ -902,10 +911,10 @@ goslide-api==0.7.0
gotailwind==0.3.0
# homeassistant.components.govee_ble
-govee-ble==0.43.0
+govee-ble==0.43.1
# homeassistant.components.govee_light_local
-govee-local-api==2.0.1
+govee-local-api==2.1.0
# homeassistant.components.gpsd
gps3==0.33.3
@@ -920,7 +929,7 @@ greeneye_monitor==3.0.3
gridnet==5.0.1
# homeassistant.components.growatt_server
-growattServer==1.5.0
+growattServer==1.6.0
# homeassistant.components.google_sheets
gspread==5.5.0
@@ -929,7 +938,7 @@ gspread==5.5.0
guppy3==3.1.5
# homeassistant.components.iaqualink
-h2==4.1.0
+h2==4.2.0
# homeassistant.components.ffmpeg
ha-ffmpeg==3.2.2
@@ -940,20 +949,23 @@ ha-iotawattpy==0.1.2
# homeassistant.components.philips_js
ha-philipsjs==3.2.2
+# homeassistant.components.homeassistant_hardware
+ha-silabs-firmware-client==0.2.0
+
# homeassistant.components.habitica
-habiticalib==0.3.5
+habiticalib==0.3.7
# homeassistant.components.bluetooth
-habluetooth==3.21.1
+habluetooth==3.39.0
# homeassistant.components.cloud
-hass-nabucasa==0.89.0
+hass-nabucasa==0.94.0
# homeassistant.components.conversation
hassil==2.2.3
# homeassistant.components.jewish_calendar
-hdate==0.11.1
+hdate[astral]==1.0.3
# homeassistant.components.here_travel_time
here-routing==1.0.1
@@ -972,16 +984,16 @@ hole==0.8.0
# homeassistant.components.holiday
# homeassistant.components.workday
-holidays==0.66
+holidays==0.70
# homeassistant.components.frontend
-home-assistant-frontend==20250205.0
+home-assistant-frontend==20250411.0
# homeassistant.components.conversation
-home-assistant-intents==2025.2.5
+home-assistant-intents==2025.3.28
# homeassistant.components.homematicip_cloud
-homematicip==1.1.7
+homematicip==2.0.0
# homeassistant.components.remember_the_milk
httplib2==0.20.4
@@ -996,7 +1008,7 @@ huum==0.7.12
hyperion-py==0.7.5
# homeassistant.components.iaqualink
-iaqualink==0.5.0
+iaqualink==0.5.3
# homeassistant.components.ibeacon
ibeacon-ble==1.2.0
@@ -1004,7 +1016,8 @@ ibeacon-ble==1.2.0
# homeassistant.components.google
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
-ical==8.3.0
+# homeassistant.components.remote_calendar
+ical==9.1.0
# homeassistant.components.caldav
icalendar==6.1.0
@@ -1021,8 +1034,11 @@ ifaddr==0.2.0
# homeassistant.components.igloohome
igloohome-api==0.1.0
+# homeassistant.components.imeon_inverter
+imeon_inverter_api==0.3.12
+
# homeassistant.components.imgw_pib
-imgw_pib==1.0.9
+imgw_pib==1.0.10
# homeassistant.components.incomfort
incomfort-client==0.6.7
@@ -1034,7 +1050,7 @@ influxdb-client==1.24.0
influxdb==5.3.1
# homeassistant.components.inkbird
-inkbird-ble==0.5.8
+inkbird-ble==0.13.0
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.5.0
@@ -1077,7 +1093,7 @@ kegtron-ble==0.4.0
knocki==0.4.2
# homeassistant.components.knx
-knx-frontend==2025.1.30.194235
+knx-frontend==2025.3.8.214559
# homeassistant.components.konnected
konnected==1.2.0
@@ -1101,7 +1117,7 @@ ld2410-ble==0.1.1
leaone-ble==0.1.0
# homeassistant.components.led_ble
-led-ble==1.1.6
+led-ble==1.1.7
# homeassistant.components.lektrico
lektricowifi==0.0.43
@@ -1122,7 +1138,7 @@ libsoundtouch==0.8
linear-garage-door==0.2.9
# homeassistant.components.livisi
-livisi==0.0.24
+livisi==0.0.25
# homeassistant.components.london_underground
london-tube-status==0.5
@@ -1150,7 +1166,7 @@ mbddns==0.1.2
# homeassistant.components.mcp
# homeassistant.components.mcp_server
-mcp==1.1.2
+mcp==1.5.0
# homeassistant.components.minecraft_server
mcstatus==11.1.1
@@ -1165,7 +1181,7 @@ medcom-ble==0.1.1
melnor-bluetooth==0.0.25
# homeassistant.components.meteo_france
-meteofrance-api==1.3.0
+meteofrance-api==1.4.0
# homeassistant.components.mfi
mficlient==0.5.0
@@ -1189,7 +1205,7 @@ minio==7.1.12
moat-ble==0.1.1
# homeassistant.components.moehlenhoff_alpha2
-moehlenhoff-alpha2==1.3.1
+moehlenhoff-alpha2==1.4.0
# homeassistant.components.monzo
monzopy==1.4.2
@@ -1198,7 +1214,7 @@ monzopy==1.4.2
mopeka-iot-ble==0.8.0
# homeassistant.components.motion_blinds
-motionblinds==0.6.25
+motionblinds==0.6.26
# homeassistant.components.motionblinds_ble
motionblindsble==0.1.3
@@ -1213,7 +1229,7 @@ mozart-api==4.1.1.116.4
mullvad-api==1.0.0
# homeassistant.components.music_assistant
-music-assistant-client==1.0.8
+music-assistant-client==1.2.0
# homeassistant.components.tts
mutagen==1.47.0
@@ -1237,10 +1253,10 @@ nessclient==1.1.2
netmap==0.7.0.2
# homeassistant.components.nam
-nettigo-air-monitor==4.0.0
+nettigo-air-monitor==4.1.0
# homeassistant.components.nexia
-nexia==2.0.9
+nexia==2.7.0
# homeassistant.components.nextcloud
nextcloudmonitor==1.5.1
@@ -1255,7 +1271,7 @@ nextdns==4.0.0
nhc==0.4.10
# homeassistant.components.nibe_heatpump
-nibe==2.14.0
+nibe==2.17.0
# homeassistant.components.nice_go
nice-go==1.0.1
@@ -1295,7 +1311,7 @@ objgraph==3.5.0
odp-amsterdam==6.0.2
# homeassistant.components.ohme
-ohme==1.2.9
+ohme==1.5.1
# homeassistant.components.ollama
ollama==0.4.7
@@ -1307,7 +1323,7 @@ omnilogic==0.4.5
ondilo==0.5.0
# homeassistant.components.onedrive
-onedrive-personal-sdk==0.0.9
+onedrive-personal-sdk==0.0.13
# homeassistant.components.onvif
onvif-zeep-async==3.2.5
@@ -1319,7 +1335,7 @@ open-garage==0.2.0
open-meteo==0.3.2
# homeassistant.components.openai_conversation
-openai==1.61.0
+openai==1.68.2
# homeassistant.components.openerz
openerz-api==0.3.0
@@ -1331,7 +1347,7 @@ openhomedevice==2.2.0
openwebifpy==4.3.1
# homeassistant.components.opower
-opower==0.8.9
+opower==0.11.1
# homeassistant.components.oralb
oralb-ble==0.17.6
@@ -1376,7 +1392,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
-plugwise==1.7.1
+plugwise==1.7.3
# homeassistant.components.plum_lightpad
plumlightpad==0.0.11
@@ -1402,7 +1418,7 @@ prometheus-client==0.21.0
psutil-home-assistant==0.0.1
# homeassistant.components.systemmonitor
-psutil==6.1.1
+psutil==7.0.0
# homeassistant.components.pushbullet
pushbullet.py==0.11.0
@@ -1411,7 +1427,7 @@ pushbullet.py==0.11.0
pushover_complete==1.1.1
# homeassistant.components.pvoutput
-pvo==2.2.0
+pvo==2.2.1
# homeassistant.components.aosmith
py-aosmith==1.0.12
@@ -1425,6 +1441,9 @@ py-ccm15==0.0.9
# homeassistant.components.cpuspeed
py-cpuinfo==9.0.0
+# homeassistant.components.pterodactyl
+py-dactyl==2.0.4
+
# homeassistant.components.dormakaba_dkey
py-dormakaba-dkey==1.0.5
@@ -1447,7 +1466,7 @@ py-nightscout==1.2.2
py-sucks==0.9.10
# homeassistant.components.synology_dsm
-py-synologydsm-api==2.6.2
+py-synologydsm-api==2.7.1
# homeassistant.components.hdmi_cec
pyCEC==0.5.2
@@ -1462,7 +1481,7 @@ pyDuotecno==2024.10.1
pyElectra==1.2.4
# homeassistant.components.homee
-pyHomee==1.2.5
+pyHomee==1.2.8
# homeassistant.components.rfxtrx
pyRFXtrx==0.31.1
@@ -1487,7 +1506,7 @@ pyairnow==1.2.1
pyairvisual==2023.08.1
# homeassistant.components.aprilaire
-pyaprilaire==0.7.7
+pyaprilaire==0.8.1
# homeassistant.components.asuswrt
pyasuswrt==0.1.21
@@ -1505,16 +1524,16 @@ pyatv==0.16.0
pyaussiebb==0.1.5
# homeassistant.components.balboa
-pybalboa==1.0.2
+pybalboa==1.1.3
# homeassistant.components.blackbird
pyblackbird==0.6
# homeassistant.components.bluesound
-pyblu==2.0.0
+pyblu==2.0.1
# homeassistant.components.neato
-pybotvac==0.0.25
+pybotvac==0.0.26
# homeassistant.components.braviatv
pybravia==0.3.4
@@ -1535,13 +1554,13 @@ pycountry==24.6.1
pycsspeechtts==1.0.8
# homeassistant.components.daikin
-pydaikin==2.13.8
+pydaikin==2.15.0
# homeassistant.components.deako
pydeako==0.6.0
# homeassistant.components.deconz
-pydeconz==118
+pydeconz==120
# homeassistant.components.dexcom
pydexcom==0.2.3
@@ -1550,16 +1569,16 @@ pydexcom==0.2.3
pydiscovergy==3.0.2
# homeassistant.components.hydrawise
-pydrawise==2025.2.0
+pydrawise==2025.3.0
# homeassistant.components.android_ip_webcam
-pydroid-ipcam==2.0.0
+pydroid-ipcam==3.0.0
# homeassistant.components.ecoforest
pyecoforest==0.4.0
# homeassistant.components.econet
-pyeconet==0.1.26
+pyeconet==0.1.28
# homeassistant.components.ista_ecotrend
pyecotrend-ista==3.3.1
@@ -1577,7 +1596,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1
# homeassistant.components.enphase_envoy
-pyenphase==1.23.1
+pyenphase==1.25.5
# homeassistant.components.everlights
pyeverlights==0.1.0
@@ -1589,7 +1608,7 @@ pyevilgenius==2.0.0
pyezviz==0.2.1.2
# homeassistant.components.fibaro
-pyfibaro==0.8.0
+pyfibaro==0.8.2
# homeassistant.components.fido
pyfido==2.1.2
@@ -1607,7 +1626,7 @@ pyforked-daapd==0.1.14
pyfreedompro==1.1.0
# homeassistant.components.fritzbox
-pyfritzhome==0.6.14
+pyfritzhome==0.6.17
# homeassistant.components.ifttt
pyfttt==0.3
@@ -1619,10 +1638,10 @@ pygti==0.9.4
pyhaversion==22.8.0
# homeassistant.components.heos
-pyheos==1.0.1
+pyheos==1.0.5
# homeassistant.components.hive
-pyhive-integration==1.0.1
+pyhive-integration==1.0.2
# homeassistant.components.homematic
pyhomematic==0.1.77
@@ -1640,7 +1659,7 @@ pyicloud==1.0.0
pyinsteon==1.6.3
# homeassistant.components.ipma
-pyipma==3.0.8
+pyipma==3.0.9
# homeassistant.components.ipp
pyipp==0.17.0
@@ -1655,7 +1674,7 @@ pyiskra==0.1.15
pyiss==1.0.1
# homeassistant.components.isy994
-pyisy==3.1.14
+pyisy==3.4.0
# homeassistant.components.ituran
pyituran==0.1.4
@@ -1682,10 +1701,10 @@ pykoplenti==1.3.0
pykrakenapi==0.1.8
# homeassistant.components.kulersky
-pykulersky==0.5.2
+pykulersky==0.5.8
# homeassistant.components.lamarzocco
-pylamarzocco==1.4.6
+pylamarzocco==2.0.0b1
# homeassistant.components.lastfm
pylast==5.1.0
@@ -1706,10 +1725,10 @@ pylitejet==0.6.3
pylitterbot==2024.0.0
# homeassistant.components.lutron_caseta
-pylutron-caseta==0.23.0
+pylutron-caseta==0.24.0
# homeassistant.components.lutron
-pylutron==0.2.16
+pylutron==0.2.18
# homeassistant.components.mailgun
pymailgunner==1.4
@@ -1726,6 +1745,9 @@ pymeteoclimatic==0.1.0
# homeassistant.components.assist_pipeline
pymicro-vad==1.0.1
+# homeassistant.components.miele
+pymiele==0.3.4
+
# homeassistant.components.mochad
pymochad==0.2.0
@@ -1739,7 +1761,7 @@ pymonoprice==0.4
pymysensors==0.24.0
# homeassistant.components.iron_os
-pynecil==4.0.1
+pynecil==4.1.0
# homeassistant.components.netgear
pynetgear==0.10.10
@@ -1772,7 +1794,7 @@ pyoctoprintapi==0.1.12
pyopenuv==2023.02.0
# homeassistant.components.openweathermap
-pyopenweathermap==0.2.1
+pyopenweathermap==0.2.2
# homeassistant.components.opnsense
pyopnsense==0.4.0
@@ -1789,7 +1811,7 @@ pyotgw==2.2.2
pyotp==2.8.0
# homeassistant.components.overkiz
-pyoverkiz==1.16.0
+pyoverkiz==1.17.0
# homeassistant.components.onewire
pyownet==0.10.0.post1
@@ -1800,6 +1822,9 @@ pypalazzetti==0.1.19
# homeassistant.components.lcn
pypck==0.8.5
+# homeassistant.components.pglab
+pypglab==0.0.5
+
# homeassistant.components.pjlink
pypjlink2==1.2.1
@@ -1813,7 +1838,7 @@ pypoint==3.0.0
pyprof2calltree==1.4.5
# homeassistant.components.prosegur
-pyprosegur==0.0.9
+pyprosegur==0.0.14
# homeassistant.components.prusalink
pyprusalink==2.1.1
@@ -1825,13 +1850,13 @@ pyps4-2ndscreen==1.3.1
pyqwikswitch==0.93
# homeassistant.components.nmbs
-pyrail==0.0.3
+pyrail==0.4.1
# homeassistant.components.rainbird
pyrainbird==6.0.1
# homeassistant.components.risco
-pyrisco==0.6.5
+pyrisco==0.6.7
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.6
@@ -1840,7 +1865,7 @@ pyrituals==0.0.6
pyroute2==0.7.5
# homeassistant.components.rympro
-pyrympro==0.0.8
+pyrympro==0.0.9
# homeassistant.components.sabnzbd
pysabnzbd==1.1.1
@@ -1858,7 +1883,7 @@ pysensibo==1.1.0
pyserial==3.5
# homeassistant.components.seventeentrack
-pyseventeentrack==1.0.1
+pyseventeentrack==1.0.2
# homeassistant.components.sia
pysiaalarm==3.1.1
@@ -1873,22 +1898,19 @@ pysma==0.7.5
pysmappee==0.2.29
# homeassistant.components.smartthings
-pysmartapp==0.3.5
-
-# homeassistant.components.smartthings
-pysmartthings==0.7.8
+pysmartthings==3.0.4
# homeassistant.components.smarty
-pysmarty2==0.10.1
+pysmarty2==0.10.2
# homeassistant.components.smhi
-pysmhi==1.0.0
+pysmhi==1.0.2
# homeassistant.components.edl21
pysml==0.0.12
# homeassistant.components.smlight
-pysmlight==0.2.3
+pysmlight==0.2.4
# homeassistant.components.snmp
pysnmp==6.2.6
@@ -1906,10 +1928,10 @@ pyspcwebgw==0.7.0
pyspeex-noise==1.0.2
# homeassistant.components.squeezebox
-pysqueezebox==0.11.1
+pysqueezebox==0.12.0
# homeassistant.components.suez_water
-pysuezV2==2.0.3
+pysuezV2==2.0.4
# homeassistant.components.switchbee
pyswitchbee==1.8.3
@@ -1951,10 +1973,10 @@ python-izone==1.2.9
python-juicenet==1.1.0
# homeassistant.components.tplink
-python-kasa[speedups]==0.10.1
+python-kasa[speedups]==0.10.2
# homeassistant.components.linkplay
-python-linkplay==0.1.3
+python-linkplay==0.2.3
# homeassistant.components.matter
python-matter-server==7.0.0
@@ -1979,28 +2001,31 @@ python-opensky==1.0.1
python-otbr-api==2.7.0
# homeassistant.components.overseerr
-python-overseerr==0.7.0
+python-overseerr==0.7.1
# homeassistant.components.picnic
-python-picnic-api==1.1.0
+python-picnic-api2==1.2.4
# homeassistant.components.rabbitair
python-rabbitair==0.0.8
# homeassistant.components.roborock
-python-roborock==2.11.1
+python-roborock==2.16.1
# homeassistant.components.smarttub
-python-smarttub==0.0.38
+python-smarttub==0.0.39
+
+# homeassistant.components.snoo
+python-snoo==0.6.5
# homeassistant.components.songpal
python-songpal==0.16.2
# homeassistant.components.tado
-python-tado==0.18.6
+python-tado==0.18.11
# homeassistant.components.technove
-python-technove==1.3.1
+python-technove==2.0.0
# homeassistant.components.telegram_bot
python-telegram-bot[socks]==21.5
@@ -2037,7 +2062,7 @@ pyuptimerobot==22.2.0
pyvera==0.3.15
# homeassistant.components.vesync
-pyvesync==2.1.17
+pyvesync==2.1.18
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2064,7 +2089,7 @@ pywemo==1.4.0
pywilight==0.0.74
# homeassistant.components.wiz
-pywizlight==0.5.14
+pywizlight==0.6.2
# homeassistant.components.wmspro
pywmspro==0.2.1
@@ -2079,10 +2104,10 @@ pyyardian==1.1.1
pyzerproc==0.4.8
# homeassistant.components.qbittorrent
-qbittorrent-api==2024.2.59
+qbittorrent-api==2024.9.67
# homeassistant.components.qbus
-qbusmqttapi==1.2.4
+qbusmqttapi==1.3.0
# homeassistant.components.qingping
qingping-ble==0.10.0
@@ -2112,7 +2137,7 @@ renault-api==0.2.9
renson-endura-delta==1.7.2
# homeassistant.components.reolink
-reolink-aio==0.11.10
+reolink-aio==0.13.2
# homeassistant.components.rflink
rflink==0.0.66
@@ -2160,11 +2185,11 @@ sanix==1.0.6
screenlogicpy==0.10.0
# homeassistant.components.backup
-securetar==2025.1.4
+securetar==2025.2.1
# homeassistant.components.emulated_kasa
# homeassistant.components.sense
-sense-energy==0.13.4
+sense-energy==0.13.7
# homeassistant.components.sensirion_ble
sensirion-ble==0.1.1
@@ -2172,20 +2197,26 @@ sensirion-ble==0.1.1
# homeassistant.components.sensorpro
sensorpro-ble==0.5.3
+# homeassistant.components.sensorpush_cloud
+sensorpush-api==2.1.2
+
# homeassistant.components.sensorpush
sensorpush-ble==1.7.1
+# homeassistant.components.sensorpush_cloud
+sensorpush-ha==1.3.2
+
# homeassistant.components.sensoterra
sensoterra==2.0.1
# homeassistant.components.sentry
-sentry-sdk==1.40.3
+sentry-sdk==1.45.1
# homeassistant.components.sfr_box
sfrbox-api==0.0.11
# homeassistant.components.sharkiq
-sharkiq==1.0.2
+sharkiq==1.1.0
# homeassistant.components.simplefin
simplefin4py==0.0.18
@@ -2212,7 +2243,7 @@ smart-meter-texas==0.5.5
snapcast==2.3.6
# homeassistant.components.sonos
-soco==0.30.8
+soco==0.30.9
# homeassistant.components.solarlog
solarlog_cli==0.4.0
@@ -2254,7 +2285,7 @@ statsd==3.2.1
steamodd==4.21
# homeassistant.components.stookwijzer
-stookwijzer==1.5.1
+stookwijzer==1.6.1
# homeassistant.components.streamlabswater
streamlabswater==1.0.1
@@ -2267,9 +2298,6 @@ stringcase==1.2.0
# homeassistant.components.subaru
subarulink==0.7.13
-# homeassistant.components.sunweg
-sunweg==3.0.2
-
# homeassistant.components.surepetcare
surepy==0.9.0
@@ -2297,7 +2325,7 @@ temperusb==1.6.1
# homeassistant.components.tesla_fleet
# homeassistant.components.teslemetry
# homeassistant.components.tessie
-tesla-fleet-api==0.9.8
+tesla-fleet-api==1.0.17
# homeassistant.components.powerwall
tesla-powerwall==0.5.2
@@ -2306,19 +2334,19 @@ tesla-powerwall==0.5.2
tesla-wall-connector==1.0.2
# homeassistant.components.teslemetry
-teslemetry-stream==0.6.10
+teslemetry-stream==0.7.1
# homeassistant.components.tessie
tessie-api==0.1.1
# homeassistant.components.thermobeacon
-thermobeacon-ble==0.7.0
+thermobeacon-ble==0.8.1
# homeassistant.components.thermopro
thermopro-ble==0.11.0
# homeassistant.components.lg_thinq
-thinqconnect==1.0.4
+thinqconnect==1.0.5
# homeassistant.components.tilt_ble
tilt-ble==0.2.3
@@ -2336,7 +2364,7 @@ toonapi==0.3.0
total-connect-client==2025.1.4
# homeassistant.components.tplink_omada
-tplink-omada-client==1.4.3
+tplink-omada-client==1.4.4
# homeassistant.components.transmission
transmission-rpc==7.0.3
@@ -2369,7 +2397,7 @@ typedmonarchmoney==0.4.4
uasiren==0.0.1
# homeassistant.components.unifiprotect
-uiprotect==7.5.1
+uiprotect==7.5.3
# homeassistant.components.landisgyr_heat_meter
ultraheat-api==0.5.7
@@ -2377,8 +2405,11 @@ ultraheat-api==0.5.7
# homeassistant.components.unifiprotect
unifi-discovery==1.2.0
+# homeassistant.components.homeassistant_hardware
+universal-silabs-flasher==0.0.30
+
# homeassistant.components.upb
-upb-lib==0.6.0
+upb-lib==0.6.1
# homeassistant.components.upcloud
upcloud-api==2.6.0
@@ -2386,7 +2417,7 @@ upcloud-api==2.6.0
# homeassistant.components.huawei_lte
# homeassistant.components.syncthru
# homeassistant.components.zwave_me
-url-normalize==1.4.3
+url-normalize==2.2.0
# homeassistant.components.uvc
uvcclient==0.12.1
@@ -2401,7 +2432,7 @@ vallox-websocket-api==5.3.0
vehicle==2.2.2
# homeassistant.components.velbus
-velbus-aio==2025.1.1
+velbus-aio==2025.3.1
# homeassistant.components.venstar
venstarcolortouch==0.19
@@ -2429,7 +2460,7 @@ vultr==0.1.2
wakeonlan==2.1.0
# homeassistant.components.wallbox
-wallbox==0.7.0
+wallbox==0.8.0
# homeassistant.components.folder_watcher
watchdog==6.0.0
@@ -2438,7 +2469,7 @@ watchdog==6.0.0
watergate-local-api==2024.4.1
# homeassistant.components.weatherflow_cloud
-weatherflow4py==1.0.6
+weatherflow4py==1.3.1
# homeassistant.components.nasweb
webio-api==0.1.11
@@ -2447,10 +2478,10 @@ webio-api==0.1.11
webmin-xmlrpc==0.0.2
# homeassistant.components.weheat
-weheat==2025.1.15
+weheat==2025.3.7
# homeassistant.components.whirlpool
-whirlpool-sixth-sense==0.18.12
+whirlpool-sixth-sense==0.20.0
# homeassistant.components.whois
whois==0.9.27
@@ -2462,7 +2493,7 @@ wiffi==1.1.2
wled==0.21.0
# homeassistant.components.wolflink
-wolf-comm==0.0.15
+wolf-comm==0.0.23
# homeassistant.components.wyoming
wyoming==1.5.4
@@ -2471,13 +2502,13 @@ wyoming==1.5.4
xbox-webapi==2.1.0
# homeassistant.components.xiaomi_ble
-xiaomi-ble==0.33.0
+xiaomi-ble==0.37.0
# homeassistant.components.knx
-xknx==3.5.0
+xknx==3.6.0
# homeassistant.components.knx
-xknxproject==3.8.1
+xknxproject==3.8.2
# homeassistant.components.fritz
# homeassistant.components.rest
@@ -2502,7 +2533,7 @@ yalexs==8.10.0
yeelight==0.7.16
# homeassistant.components.yolink
-yolink-api==0.4.7
+yolink-api==0.4.9
# homeassistant.components.youless
youless-api==2.2.0
@@ -2511,22 +2542,22 @@ youless-api==2.2.0
youtubeaio==1.1.5
# homeassistant.components.media_extractor
-yt-dlp[default]==2025.01.26
+yt-dlp[default]==2025.03.26
# homeassistant.components.zamg
zamg==0.3.6
# homeassistant.components.zeroconf
-zeroconf==0.143.0
+zeroconf==0.146.5
# homeassistant.components.zeversolar
zeversolar==0.3.2
# homeassistant.components.zha
-zha==0.0.48
+zha==0.0.56
# homeassistant.components.zwave_js
-zwave-js-server-python==0.60.0
+zwave-js-server-python==0.62.0
# homeassistant.components.zwave_me
zwave-me-ws==0.4.3
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index 1cf3d91defa..ff86915bbf3 100644
--- a/requirements_test_pre_commit.txt
+++ b/requirements_test_pre_commit.txt
@@ -1,5 +1,5 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
codespell==2.4.1
-ruff==0.9.1
+ruff==0.11.0
yamllint==1.35.1
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index fa823fa4834..b4e18ea5962 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -117,9 +117,9 @@ httplib2>=0.19.0
# gRPC is an implicit dependency that we want to make explicit so we manage
# upgrades intentionally. It is a large package to build from source and we
# want to ensure we have wheels built.
-grpcio==1.67.1
-grpcio-status==1.67.1
-grpcio-reflection==1.67.1
+grpcio==1.71.0
+grpcio-status==1.71.0
+grpcio-reflection==1.71.0
# This is a old unmaintained library and is replaced with pycryptodome
pycrypto==1000000000.0.0
@@ -139,7 +139,7 @@ uuid==1000000000.0.0
# these requirements are quite loose. As the entire stack has some outstanding issues, and
# even newer versions seem to introduce new issues, it's useful for us to pin all these
# requirements so we can directly link HA versions to these library versions.
-anyio==4.8.0
+anyio==4.9.0
h11==0.14.0
httpcore==1.0.7
@@ -159,7 +159,7 @@ multidict>=6.0.2
backoff>=2.0
# ensure pydantic version does not float since it might have breaking changes
-pydantic==2.10.6
+pydantic==2.11.3
# Required for Python 3.12.4 compatibility (#119223).
mashumaro>=3.13.1
@@ -241,6 +241,11 @@ async-timeout==4.0.3
# https://github.com/home-assistant/core/issues/122508
# https://github.com/home-assistant/core/issues/118004
aiofiles>=24.1.0
+
+# multidict < 6.4.0 has memory leaks
+# https://github.com/aio-libs/multidict/issues/1134
+# https://github.com/aio-libs/multidict/issues/1131
+multidict>=6.4.2
"""
GENERATED_MESSAGE = (
@@ -266,7 +271,8 @@ def has_tests(module: str) -> bool:
Test if exists: tests/components/hue/__init__.py
"""
path = (
- Path(module.replace(".", "/").replace("homeassistant", "tests")) / "__init__.py"
+ Path(module.replace(".", "/").replace("homeassistant", "tests", 1))
+ / "__init__.py"
)
return path.exists()
diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py
index c93d8fd4499..277696c669b 100644
--- a/script/hassfest/__main__.py
+++ b/script/hassfest/__main__.py
@@ -107,7 +107,13 @@ def get_config() -> Config:
"--plugins",
type=validate_plugins,
default=ALL_PLUGIN_NAMES,
- help="Comma-separate list of plugins to run. Valid plugin names: %(default)s",
+ help="Comma-separated list of plugins to run. Valid plugin names: %(default)s",
+ )
+ parser.add_argument(
+ "--skip-plugins",
+ type=validate_plugins,
+ default=[],
+ help=f"Comma-separated list of plugins to skip. Valid plugin names: {ALL_PLUGIN_NAMES}",
)
parser.add_argument(
"--core-path",
@@ -131,6 +137,9 @@ def get_config() -> Config:
):
raise RuntimeError("Run from Home Assistant root")
+ if parsed.skip_plugins:
+ parsed.plugins = set(parsed.plugins) - set(parsed.skip_plugins)
+
return Config(
root=parsed.core_path.absolute(),
specific_integrations=parsed.integration_path,
diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py
index f842ec61b97..1f8b7d1139b 100644
--- a/script/hassfest/config_flow.py
+++ b/script/hassfest/config_flow.py
@@ -95,7 +95,6 @@ def _populate_brand_integrations(
integration = integrations.get(domain)
if not integration or integration.integration_type in (
"entity",
- "hardware",
"system",
):
continue
@@ -171,7 +170,7 @@ def _generate_integrations(
result["integration"][domain] = metadata
else: # integration
integration = integrations[domain]
- if integration.integration_type in ("entity", "system", "hardware"):
+ if integration.integration_type in ("entity", "system"):
continue
if integration.translated_name:
diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py
index d29571eaa83..370be8d66f1 100644
--- a/script/hassfest/dependencies.py
+++ b/script/hassfest/dependencies.py
@@ -153,8 +153,6 @@ ALLOWED_USED_COMPONENTS = {
}
IGNORE_VIOLATIONS = {
- # Has same requirement, gets defaults.
- ("sql", "recorder"),
# Sharing a base class
("lutron_caseta", "lutron"),
("ffmpeg_noise", "ffmpeg_motion"),
diff --git a/script/hassfest/docker.py b/script/hassfest/docker.py
index edc47e2f9d7..4bf6c3bb0a6 100644
--- a/script/hassfest/docker.py
+++ b/script/hassfest/docker.py
@@ -26,6 +26,24 @@ ENV \
ARG QEMU_CPU
+# Home Assistant S6-Overlay
+COPY rootfs /
+
+# Needs to be redefined inside the FROM statement to be set for RUN commands
+ARG BUILD_ARCH
+# Get go2rtc binary
+RUN \
+ case "${{BUILD_ARCH}}" in \
+ "aarch64") go2rtc_suffix='arm64' ;; \
+ "armhf") go2rtc_suffix='armv6' ;; \
+ "armv7") go2rtc_suffix='arm' ;; \
+ *) go2rtc_suffix=${{BUILD_ARCH}} ;; \
+ esac \
+ && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \
+ && chmod +x /bin/go2rtc \
+ # Verify go2rtc can be executed
+ && go2rtc --version
+
# Install uv
RUN pip3 install uv=={uv}
@@ -56,24 +74,6 @@ RUN \
&& python3 -m compileall \
homeassistant/homeassistant
-# Home Assistant S6-Overlay
-COPY rootfs /
-
-# Needs to be redefined inside the FROM statement to be set for RUN commands
-ARG BUILD_ARCH
-# Get go2rtc binary
-RUN \
- case "${{BUILD_ARCH}}" in \
- "aarch64") go2rtc_suffix='arm64' ;; \
- "armhf") go2rtc_suffix='armv6' ;; \
- "armv7") go2rtc_suffix='arm' ;; \
- *) go2rtc_suffix=${{BUILD_ARCH}} ;; \
- esac \
- && curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \
- && chmod +x /bin/go2rtc \
- # Verify go2rtc can be executed
- && go2rtc --version
-
WORKDIR /config
"""
diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile
index 5598c839257..bfdb61096b6 100644
--- a/script/hassfest/docker/Dockerfile
+++ b/script/hassfest/docker/Dockerfile
@@ -14,7 +14,7 @@ WORKDIR "/github/workspace"
COPY . /usr/src/homeassistant
# Uv is only needed during build
-RUN --mount=from=ghcr.io/astral-sh/uv:0.5.27,source=/uv,target=/bin/uv \
+RUN --mount=from=ghcr.io/astral-sh/uv:0.6.10,source=/uv,target=/bin/uv \
# Uv creates a lock file in /tmp
--mount=type=tmpfs,target=/tmp \
# Required for PyTurboJPEG
@@ -24,8 +24,8 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.27,source=/uv,target=/bin/uv \
--no-cache \
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \
-r /usr/src/homeassistant/requirements.txt \
- stdlib-list==0.10.0 pipdeptree==2.25.0 tqdm==4.67.1 ruff==0.9.1 \
- PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.2.5 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
+ stdlib-list==0.10.0 pipdeptree==2.25.1 tqdm==4.67.1 ruff==0.11.0 \
+ PyTurboJPEG==1.7.5 go2rtc-client==0.1.2 ha-ffmpeg==3.2.2 hassil==2.2.3 home-assistant-intents==2025.3.28 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
LABEL "name"="hassfest"
LABEL "maintainer"="Home Assistant "
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
index 6e9cd8bdedc..02c96930bf5 100644
--- a/script/hassfest/manifest.py
+++ b/script/hassfest/manifest.py
@@ -19,6 +19,7 @@ from voluptuous.humanize import humanize_error
from homeassistant.const import Platform
from homeassistant.helpers import config_validation as cv
+from script.util import sort_manifest as util_sort_manifest
from .model import Config, Integration, ScaledQualityScaleTiers
@@ -376,20 +377,20 @@ def validate_manifest(integration: Integration, core_components_dir: Path) -> No
validate_version(integration)
-_SORT_KEYS = {"domain": ".domain", "name": ".name"}
-
-
-def _sort_manifest_keys(key: str) -> str:
- return _SORT_KEYS.get(key, key)
-
-
def sort_manifest(integration: Integration, config: Config) -> bool:
"""Sort manifest."""
- keys = list(integration.manifest.keys())
- if (keys_sorted := sorted(keys, key=_sort_manifest_keys)) != keys:
- manifest = {key: integration.manifest[key] for key in keys_sorted}
+ if integration.manifest_path is None:
+ integration.add_error(
+ "manifest",
+ "Manifest path not set, unable to sort manifest keys",
+ )
+ return False
+
+ if util_sort_manifest(integration.manifest):
if config.action == "generate":
- integration.manifest_path.write_text(json.dumps(manifest, indent=2))
+ integration.manifest_path.write_text(
+ json.dumps(integration.manifest, indent=2) + "\n"
+ )
text = "have been sorted"
else:
text = "are not sorted correctly"
diff --git a/script/hassfest/model.py b/script/hassfest/model.py
index 08ded687096..1ca4178d9c2 100644
--- a/script/hassfest/model.py
+++ b/script/hassfest/model.py
@@ -157,8 +157,10 @@ class Integration:
@property
def core(self) -> bool:
"""Core integration."""
- return self.path.as_posix().startswith(
- self._config.core_integrations_path.as_posix()
+ return (
+ self.path.absolute()
+ .as_posix()
+ .startswith(self._config.core_integrations_path.as_posix())
)
@property
diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py
index e5eee2f4157..5885b4acb1f 100644
--- a/script/hassfest/quality_scale.py
+++ b/script/hassfest/quality_scale.py
@@ -256,7 +256,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"coinbase",
"color_extractor",
"comed_hourly_pricing",
- "comelit",
"comfoconnect",
"command_line",
"compensation",
@@ -335,7 +334,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"egardia",
"eight_sleep",
"electrasmart",
- "electric_kiwi",
"eliqonline",
"elkm1",
"elmax",
@@ -391,7 +389,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"fjaraskupan",
"fleetgo",
"flexit",
- "flexit_bacnet",
"flic",
"flick_electric",
"flipr",
@@ -515,9 +512,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"iglo",
"ign_sismologia",
"ihc",
- "imgw_pib",
"improv_ble",
- "incomfort",
"influxdb",
"inkbird",
"insteon",
@@ -707,7 +702,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"nibe_heatpump",
"nice_go",
"nightscout",
- "niko_home_control",
"nilu",
"nina",
"nissan_leaf",
@@ -814,7 +808,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"pushsafer",
"pvoutput",
"pvpc_hourly_pricing",
- "pyload",
"qbittorrent",
"qingping",
"qld_bushfire",
@@ -858,7 +851,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"risco",
"rituals_perfume_genie",
"rmvtransport",
- "roborock",
"rocketchat",
"roku",
"romy",
@@ -901,7 +893,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"sfr_box",
"sharkiq",
"shell_command",
- "shelly",
"shodan",
"shopping_list",
"sia",
@@ -925,11 +916,9 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"sma",
"smappee",
"smart_meter_texas",
- "smartthings",
"smarttub",
"smarty",
"smhi",
- "smlight",
"sms",
"smtp",
"snapcast",
@@ -981,7 +970,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"switcher_kis",
"switchmate",
"syncthing",
- "syncthru",
"synology_chat",
"synology_dsm",
"synology_srm",
@@ -1071,7 +1059,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"upcloud",
"upnp",
"uptime",
- "uptimerobot",
"usb",
"usgs_earthquakes_feed",
"utility_meter",
@@ -1092,7 +1079,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"vizio",
"vlc",
"vlc_telnet",
- "vodafone_station",
"voicerss",
"voip",
"volkszaehler",
@@ -1114,7 +1100,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"weatherkit",
"webmin",
"wemo",
- "whirlpool",
"whois",
"wiffi",
"wilight",
@@ -1286,7 +1271,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"brottsplatskartan",
"browser",
"brunt",
- "bring",
"bryant_evolution",
"bsblan",
"bt_home_hub_5",
@@ -1317,7 +1301,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"coinbase",
"color_extractor",
"comed_hourly_pricing",
- "comelit",
"comfoconnect",
"command_line",
"compensation",
@@ -1396,7 +1379,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"egardia",
"eight_sleep",
"electrasmart",
- "electric_kiwi",
"elevenlabs",
"eliqonline",
"elkm1",
@@ -1456,7 +1438,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"fjaraskupan",
"fleetgo",
"flexit",
- "flexit_bacnet",
"flic",
"flick_electric",
"flipr",
@@ -1536,7 +1517,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"gstreamer",
"gtfs",
"guardian",
- "habitica",
"harman_kardon_avr",
"harmony",
"hassio",
@@ -1585,9 +1565,7 @@ INTEGRATIONS_WITHOUT_SCALE = [
"ign_sismologia",
"ihc",
"imap",
- "imgw_pib",
"improv_ble",
- "incomfort",
"influxdb",
"inkbird",
"insteon",
@@ -1595,7 +1573,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"intellifire",
"intesishome",
"ios",
- "iron_os",
"iotawatt",
"iotty",
"iperf3",
@@ -1728,7 +1705,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"mikrotik",
"mill",
"min_max",
- "minecraft_server",
"minio",
"mjpeg",
"moat",
@@ -1897,7 +1873,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"pushsafer",
"pvoutput",
"pvpc_hourly_pricing",
- "pyload",
"qbittorrent",
"qingping",
"qld_bushfire",
@@ -1942,7 +1917,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"risco",
"rituals_perfume_genie",
"rmvtransport",
- "roborock",
"rocketchat",
"roku",
"romy",
@@ -1972,7 +1946,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"season",
"sendgrid",
"sense",
- "sensibo",
"sensirion_ble",
"sensorpro",
"sensorpush",
@@ -2011,11 +1984,9 @@ INTEGRATIONS_WITHOUT_SCALE = [
"sma",
"smappee",
"smart_meter_texas",
- "smartthings",
"smarttub",
"smarty",
"smhi",
- "smlight",
"sms",
"smtp",
"snapcast",
@@ -2173,7 +2144,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"velux",
"venstar",
"vera",
- "velbus",
"verisure",
"versasense",
"version",
@@ -2185,7 +2155,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"vizio",
"vlc",
"vlc_telnet",
- "vodafone_station",
"voicerss",
"voip",
"volkszaehler",
diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py
index b3d397dbd55..f4c05f504ca 100644
--- a/script/hassfest/translations.py
+++ b/script/hassfest/translations.py
@@ -29,6 +29,7 @@ ALLOW_NAME_TRANSLATION = {
"cert_expiry",
"cpuspeed",
"emulated_roku",
+ "energenie_power_sockets",
"faa_delays",
"garages_amsterdam",
"generic",
@@ -40,6 +41,7 @@ ALLOW_NAME_TRANSLATION = {
"local_ip",
"local_todo",
"nmap_tracker",
+ "remote_calendar",
"rpi_power",
"swiss_public_transport",
"waze_travel_time",
@@ -185,6 +187,8 @@ def gen_data_entry_schema(
vol.Optional("abort"): {str: translation_value_validator},
vol.Optional("progress"): {str: translation_value_validator},
vol.Optional("create_entry"): {str: translation_value_validator},
+ vol.Optional("initiate_flow"): {str: translation_value_validator},
+ vol.Optional("entry_type"): translation_value_validator,
}
if flow_title == REQUIRED:
schema[vol.Required("title")] = translation_value_validator
@@ -285,6 +289,15 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
"user" if integration.integration_type == "helper" else None
),
),
+ vol.Optional("config_subentries"): cv.schema_with_slug_keys(
+ gen_data_entry_schema(
+ config=config,
+ integration=integration,
+ flow_title=REMOVED,
+ require_step_title=False,
+ ),
+ slug_validator=vol.Any("_", cv.slug),
+ ),
vol.Optional("options"): gen_data_entry_schema(
config=config,
integration=integration,
diff --git a/script/licenses.py b/script/licenses.py
index aa15a58f3bd..ab8ab62eb1d 100644
--- a/script/licenses.py
+++ b/script/licenses.py
@@ -88,6 +88,7 @@ OSI_APPROVED_LICENSES_SPDX = {
"MPL-1.1",
"MPL-2.0",
"PSF-2.0",
+ "Python-2.0",
"Unlicense",
"Zlib",
"ZPL-2.1",
@@ -180,7 +181,6 @@ EXCEPTIONS = {
"PyMicroBot", # https://github.com/spycle/pyMicroBot/pull/3
"PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16
"PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201
- "aioecowitt", # https://github.com/home-assistant-libs/aioecowitt/pull/180
"chacha20poly1305", # LGPL
"commentjson", # https://github.com/vaidik/commentjson/pull/55
"crownstone-cloud", # https://github.com/crownstone/crownstone-lib-python-cloud/pull/5
@@ -191,6 +191,7 @@ EXCEPTIONS = {
"enocean", # https://github.com/kipe/enocean/pull/142
"imutils", # https://github.com/PyImageSearch/imutils/pull/292
"iso4217", # Public domain
+ "jaraco.itertools", # MIT - https://github.com/jaraco/jaraco.itertools/issues/21
"kiwiki_client", # https://github.com/c7h/kiwiki_client/pull/6
"ld2410-ble", # https://github.com/930913/ld2410-ble/pull/7
"maxcube-api", # https://github.com/uebelack/python-maxcube-api/pull/48
diff --git a/script/quality_scale_summary.py b/script/quality_scale_summary.py
new file mode 100644
index 00000000000..b93eab81451
--- /dev/null
+++ b/script/quality_scale_summary.py
@@ -0,0 +1,89 @@
+"""Generate a summary of integration quality scales.
+
+Run with python3 -m script.quality_scale_summary
+Data collected at https://docs.google.com/spreadsheets/d/1xEiwovRJyPohAv8S4ad2LAB-0A38s1HWmzHng8v-4NI
+"""
+
+import csv
+from pathlib import Path
+import sys
+
+from homeassistant.const import __version__ as current_version
+from homeassistant.util.json import load_json
+
+COMPONENTS_DIR = Path("homeassistant/components")
+
+
+def generate_quality_scale_summary() -> list[str, int]:
+ """Generate a summary of integration quality scales."""
+ quality_scales = {
+ "virtual": 0,
+ "unknown": 0,
+ "legacy": 0,
+ "internal": 0,
+ "bronze": 0,
+ "silver": 0,
+ "gold": 0,
+ "platinum": 0,
+ }
+
+ for manifest_path in COMPONENTS_DIR.glob("*/manifest.json"):
+ manifest = load_json(manifest_path)
+
+ if manifest.get("integration_type") == "virtual":
+ quality_scales["virtual"] += 1
+ elif quality_scale := manifest.get("quality_scale"):
+ quality_scales[quality_scale] += 1
+ else:
+ quality_scales["unknown"] += 1
+
+ return quality_scales
+
+
+def output_csv(quality_scales: dict[str, int], print_header: bool) -> None:
+ """Output the quality scale summary as CSV."""
+ writer = csv.writer(sys.stdout)
+ if print_header:
+ writer.writerow(
+ [
+ "Version",
+ "Total",
+ "Virtual",
+ "Unknown",
+ "Legacy",
+ "Internal",
+ "Bronze",
+ "Silver",
+ "Gold",
+ "Platinum",
+ ]
+ )
+
+ # Calculate total
+ total = sum(quality_scales.values())
+
+ # Write the summary
+ writer.writerow(
+ [
+ current_version,
+ total,
+ quality_scales["virtual"],
+ quality_scales["unknown"],
+ quality_scales["legacy"],
+ quality_scales["internal"],
+ quality_scales["bronze"],
+ quality_scales["silver"],
+ quality_scales["gold"],
+ quality_scales["platinum"],
+ ]
+ )
+
+
+def main() -> None:
+ """Run the script."""
+ quality_scales = generate_quality_scale_summary()
+ output_csv(quality_scales, "--header" in sys.argv)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py
index 93c787df50f..243ea9507f7 100644
--- a/script/scaffold/__main__.py
+++ b/script/scaffold/__main__.py
@@ -8,6 +8,7 @@ import sys
from script.util import valid_integration
from . import docs, error, gather_info, generate
+from .model import Info
TEMPLATES = [
p.name for p in (Path(__file__).parent / "templates").glob("*") if p.is_dir()
@@ -28,6 +29,40 @@ def get_arguments() -> argparse.Namespace:
return parser.parse_args()
+def run_process(name: str, cmd: list[str], info: Info) -> None:
+ """Run a sub process and handle the result.
+
+ :param name: The name of the sub process used in reporting.
+ :param cmd: The sub process arguments.
+ :param info: The Info object.
+ :raises subprocess.CalledProcessError: If the subprocess failed.
+
+ If the sub process was successful print a success message, otherwise
+ print an error message and raise a subprocess.CalledProcessError.
+ """
+ print(f"Command: {' '.join(cmd)}")
+ print()
+ result: subprocess.CompletedProcess = subprocess.run(cmd, check=False)
+ if result.returncode == 0:
+ print()
+ print(f"Completed {name} successfully.")
+ print()
+ return
+
+ print()
+ print(f"Fatal Error: {name} failed with exit code {result.returncode}")
+ print()
+ if info.is_new:
+ print("This is a bug, please report an issue!")
+ else:
+ print(
+ "This may be an existing issue with your integration,",
+ "if so fix and run `script.scaffold` again,",
+ "otherwise please report an issue.",
+ )
+ result.check_returncode()
+
+
def main() -> int:
"""Scaffold an integration."""
if not Path("requirements_all.txt").is_file():
@@ -64,20 +99,32 @@ def main() -> int:
if args.template != "integration":
generate.generate(args.template, info)
- pipe_null = {} if args.develop else {"stdout": subprocess.DEVNULL}
-
+ # Always output sub commands as the output will contain useful information if a command fails.
print("Running hassfest to pick up new information.")
- subprocess.run(["python", "-m", "script.hassfest"], **pipe_null, check=True)
- print()
+ run_process(
+ "hassfest",
+ [
+ "python",
+ "-m",
+ "script.hassfest",
+ "--integration-path",
+ str(info.integration_dir),
+ "--skip-plugins",
+ "quality_scale", # Skip quality scale as it will fail for newly generated integrations.
+ ],
+ info,
+ )
print("Running gen_requirements_all to pick up new information.")
- subprocess.run(
- ["python", "-m", "script.gen_requirements_all"], **pipe_null, check=True
+ run_process(
+ "gen_requirements_all",
+ ["python", "-m", "script.gen_requirements_all"],
+ info,
)
- print()
- print("Running script/translations_develop to pick up new translation strings.")
- subprocess.run(
+ print("Running translations to pick up new translation strings.")
+ run_process(
+ "translations",
[
"python",
"-m",
@@ -86,15 +133,13 @@ def main() -> int:
"--integration",
info.domain,
],
- **pipe_null,
- check=True,
+ info,
)
- print()
if args.develop:
print("Running tests")
- print(f"$ python3 -b -m pytest -vvv tests/components/{info.domain}")
- subprocess.run(
+ run_process(
+ "pytest",
[
"python3",
"-b",
@@ -103,9 +148,8 @@ def main() -> int:
"-vvv",
f"tests/components/{info.domain}",
],
- check=True,
+ info,
)
- print()
docs.print_relevant_docs(args.template, info)
@@ -115,6 +159,8 @@ def main() -> int:
if __name__ == "__main__":
try:
sys.exit(main())
+ except subprocess.CalledProcessError as err:
+ sys.exit(err.returncode)
except error.ExitApp as err:
print()
print(f"Fatal Error: {err.reason}")
diff --git a/script/scaffold/model.py b/script/scaffold/model.py
index 3b5a5e50fe4..e3a7be210ab 100644
--- a/script/scaffold/model.py
+++ b/script/scaffold/model.py
@@ -4,9 +4,12 @@ from __future__ import annotations
import json
from pathlib import Path
+from typing import Any
import attr
+from script.util import sort_manifest
+
from .const import COMPONENT_DIR, TESTS_DIR
@@ -44,16 +47,19 @@ class Info:
"""Path to the manifest."""
return COMPONENT_DIR / self.domain / "manifest.json"
- def manifest(self) -> dict:
+ def manifest(self) -> dict[str, Any]:
"""Return integration manifest."""
return json.loads(self.manifest_path.read_text())
def update_manifest(self, **kwargs) -> None:
"""Update the integration manifest."""
print(f"Updating {self.domain} manifest: {kwargs}")
- self.manifest_path.write_text(
- json.dumps({**self.manifest(), **kwargs}, indent=2) + "\n"
- )
+
+ # Sort keys in manifest so we don't trigger hassfest errors.
+ manifest: dict[str, Any] = {**self.manifest(), **kwargs}
+ sort_manifest(manifest)
+
+ self.manifest_path.write_text(json.dumps(manifest, indent=2) + "\n")
@property
def strings_path(self) -> Path:
diff --git a/script/scaffold/templates/config_flow_helper/integration/sensor.py b/script/scaffold/templates/config_flow_helper/integration/sensor.py
index 741b2e85eb2..9c00dd568eb 100644
--- a/script/scaffold/templates/config_flow_helper/integration/sensor.py
+++ b/script/scaffold/templates/config_flow_helper/integration/sensor.py
@@ -7,13 +7,13 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize NEW_NAME config entry."""
registry = er.async_get(hass)
diff --git a/script/scaffold/templates/integration/integration/manifest.json b/script/scaffold/templates/integration/integration/manifest.json
index 7235500391d..15bc84a9b5e 100644
--- a/script/scaffold/templates/integration/integration/manifest.json
+++ b/script/scaffold/templates/integration/integration/manifest.json
@@ -7,6 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/NEW_DOMAIN",
"homekit": {},
"iot_class": "IOT_CLASS",
+ "quality_scale": "bronze",
"requirements": [],
"ssdp": [],
"zeroconf": []
diff --git a/script/util.py b/script/util.py
index b7c37c72102..c9fada38c80 100644
--- a/script/util.py
+++ b/script/util.py
@@ -1,6 +1,7 @@
"""Utility functions for the scaffold script."""
import argparse
+from typing import Any
from .const import COMPONENT_DIR
@@ -13,3 +14,23 @@ def valid_integration(integration):
)
return integration
+
+
+_MANIFEST_SORT_KEYS = {"domain": ".domain", "name": ".name"}
+
+
+def _sort_manifest_keys(key: str) -> str:
+ """Sort manifest keys."""
+ return _MANIFEST_SORT_KEYS.get(key, key)
+
+
+def sort_manifest(manifest: dict[str, Any]) -> bool:
+ """Sort manifest."""
+ keys = list(manifest)
+ if (keys_sorted := sorted(keys, key=_sort_manifest_keys)) != keys:
+ sorted_manifest = {key: manifest[key] for key in keys_sorted}
+ manifest.clear()
+ manifest.update(sorted_manifest)
+ return True
+
+ return False
diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py
index dd2ce65b480..42a5ba80643 100644
--- a/tests/auth/providers/test_homeassistant.py
+++ b/tests/auth/providers/test_homeassistant.py
@@ -19,18 +19,18 @@ from homeassistant.setup import async_setup_component
@pytest.fixture
-def data(hass: HomeAssistant) -> hass_auth.Data:
+async def data(hass: HomeAssistant) -> hass_auth.Data:
"""Create a loaded data class."""
data = hass_auth.Data(hass)
- hass.loop.run_until_complete(data.async_load())
+ await data.async_load()
return data
@pytest.fixture
-def legacy_data(hass: HomeAssistant) -> hass_auth.Data:
+async def legacy_data(hass: HomeAssistant) -> hass_auth.Data:
"""Create a loaded legacy data class."""
data = hass_auth.Data(hass)
- hass.loop.run_until_complete(data.async_load())
+ await data.async_load()
data.is_legacy = True
return data
diff --git a/tests/common.py b/tests/common.py
index 0315ee6d845..f426d2aebd2 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -29,6 +29,7 @@ from typing import Any, Literal, NoReturn
from unittest.mock import AsyncMock, Mock, patch
from aiohttp.test_utils import unused_port as get_test_instance_port # noqa: F401
+from annotatedyaml import load_yaml_dict, loader as yaml_loader
import pytest
from syrupy import SnapshotAssertion
import voluptuous as vol
@@ -86,7 +87,10 @@ from homeassistant.helpers.dispatcher import (
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_platform import AddEntitiesCallback
+from homeassistant.helpers.entity_platform import (
+ AddConfigEntryEntitiesCallback,
+ AddEntitiesCallback,
+)
from homeassistant.helpers.json import JSONEncoder, _orjson_default_encoder, json_dumps
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import dt as dt_util, ulid as ulid_util
@@ -106,7 +110,6 @@ from homeassistant.util.json import (
)
from homeassistant.util.signal_type import SignalType
from homeassistant.util.unit_system import METRIC_SYSTEM
-from homeassistant.util.yaml import load_yaml_dict, loader as yaml_loader
from .testing_config.custom_components.test_constant_deprecation import (
import_deprecated_constant,
@@ -407,6 +410,25 @@ def async_mock_intent(hass: HomeAssistant, intent_typ: str) -> list[intent.Inten
return intents
+class MockMqttReasonCode:
+ """Class to fake a MQTT ReasonCode."""
+
+ value: int
+ is_failure: bool
+
+ def __init__(
+ self, value: int = 0, is_failure: bool = False, name: str = "Success"
+ ) -> None:
+ """Initialize the mock reason code."""
+ self.value = value
+ self.is_failure = is_failure
+ self._name = name
+
+ def getName(self) -> str:
+ """Return the name of the reason code."""
+ return self._name
+
+
@callback
def async_fire_mqtt_message(
hass: HomeAssistant,
@@ -1004,6 +1026,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
reason=None,
source=config_entries.SOURCE_USER,
state=None,
+ subentries_data=None,
title="Mock Title",
unique_id=None,
version=1,
@@ -1020,6 +1043,7 @@ class MockConfigEntry(config_entries.ConfigEntry):
"options": options or {},
"pref_disable_new_entities": pref_disable_new_entities,
"pref_disable_polling": pref_disable_polling,
+ "subentries_data": subentries_data or (),
"title": title,
"unique_id": unique_id,
"version": version,
@@ -1092,6 +1116,28 @@ class MockConfigEntry(config_entries.ConfigEntry):
},
)
+ async def start_subentry_reconfigure_flow(
+ self,
+ hass: HomeAssistant,
+ subentry_flow_type: str,
+ subentry_id: str,
+ *,
+ show_advanced_options: bool = False,
+ ) -> ConfigFlowResult:
+ """Start a subentry reconfiguration flow."""
+ if self.entry_id not in hass.config_entries._entries:
+ raise ValueError(
+ "Config entry must be added to hass to start reconfiguration flow"
+ )
+ return await hass.config_entries.subentries.async_init(
+ (self.entry_id, subentry_flow_type),
+ context={
+ "source": config_entries.SOURCE_RECONFIGURE,
+ "subentry_id": subentry_id,
+ "show_advanced_options": show_advanced_options,
+ },
+ )
+
async def start_reauth_flow(
hass: HomeAssistant,
@@ -1789,7 +1835,7 @@ def setup_test_component_platform(
async def _async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
- async_add_entities: AddEntitiesCallback,
+ async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a test component platform."""
async_add_entities(entities)
@@ -1821,23 +1867,6 @@ async def snapshot_platform(
assert state == snapshot(name=f"{entity_entry.entity_id}-state")
-def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None:
- """Reset translation cache for specified components.
-
- Use this if you are mocking a core component (for example via
- mock_integration), to ensure that the mocked translations are not
- persisted in the shared session cache.
- """
- translations_cache = translation._async_get_translations_cache(hass)
- for loaded_components in translations_cache.cache_data.loaded.values():
- for component_to_unload in components:
- loaded_components.discard(component_to_unload)
- for loaded_categories in translations_cache.cache_data.cache.values():
- for loaded_components in loaded_categories.values():
- for component_to_unload in components:
- loaded_components.pop(component_to_unload, None)
-
-
@lru_cache
def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]:
"""Load quality scale for integration."""
diff --git a/tests/components/acaia/snapshots/test_binary_sensor.ambr b/tests/components/acaia/snapshots/test_binary_sensor.ambr
index 113b5f1501e..a9c52c052a3 100644
--- a/tests/components/acaia/snapshots/test_binary_sensor.ambr
+++ b/tests/components/acaia/snapshots/test_binary_sensor.ambr
@@ -6,6 +6,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
diff --git a/tests/components/acaia/snapshots/test_button.ambr b/tests/components/acaia/snapshots/test_button.ambr
index cd91ca1a17a..11827c0997f 100644
--- a/tests/components/acaia/snapshots/test_button.ambr
+++ b/tests/components/acaia/snapshots/test_button.ambr
@@ -6,6 +6,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -52,6 +53,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -98,6 +100,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
diff --git a/tests/components/acaia/snapshots/test_init.ambr b/tests/components/acaia/snapshots/test_init.ambr
index 7011b20f68c..c7a11cb58df 100644
--- a/tests/components/acaia/snapshots/test_init.ambr
+++ b/tests/components/acaia/snapshots/test_init.ambr
@@ -3,6 +3,7 @@
DeviceRegistryEntrySnapshot({
'area_id': 'kitchen',
'config_entries': ,
+ 'config_entries_subentries': ,
'configuration_url': None,
'connections': set({
tuple(
diff --git a/tests/components/acaia/snapshots/test_sensor.ambr b/tests/components/acaia/snapshots/test_sensor.ambr
index c3c8ce966ee..9214db4f102 100644
--- a/tests/components/acaia/snapshots/test_sensor.ambr
+++ b/tests/components/acaia/snapshots/test_sensor.ambr
@@ -8,6 +8,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -59,6 +60,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -113,6 +115,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
diff --git a/tests/components/accuweather/snapshots/test_sensor.ambr b/tests/components/accuweather/snapshots/test_sensor.ambr
index 3468d638bc0..cbd2e14207e 100644
--- a/tests/components/accuweather/snapshots/test_sensor.ambr
+++ b/tests/components/accuweather/snapshots/test_sensor.ambr
@@ -7,14 +7,14 @@
'capabilities': dict({
'options': list([
'good',
- 'hazardous',
- 'high',
- 'low',
'moderate',
'unhealthy',
+ 'very_unhealthy',
+ 'hazardous',
]),
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -49,11 +49,10 @@
'friendly_name': 'Home Air quality day 0',
'options': list([
'good',
- 'hazardous',
- 'high',
- 'low',
'moderate',
'unhealthy',
+ 'very_unhealthy',
+ 'hazardous',
]),
}),
'context': ,
@@ -72,14 +71,14 @@
'capabilities': dict({
'options': list([
'good',
- 'hazardous',
- 'high',
- 'low',
'moderate',
'unhealthy',
+ 'very_unhealthy',
+ 'hazardous',
]),
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -114,11 +113,10 @@
'friendly_name': 'Home Air quality day 1',
'options': list([
'good',
- 'hazardous',
- 'high',
- 'low',
'moderate',
'unhealthy',
+ 'very_unhealthy',
+ 'hazardous',
]),
}),
'context': ,
@@ -137,14 +135,14 @@
'capabilities': dict({
'options': list([
'good',
- 'hazardous',
- 'high',
- 'low',
'moderate',
'unhealthy',
+ 'very_unhealthy',
+ 'hazardous',
]),
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -179,11 +177,10 @@
'friendly_name': 'Home Air quality day 2',
'options': list([
'good',
- 'hazardous',
- 'high',
- 'low',
'moderate',
'unhealthy',
+ 'very_unhealthy',
+ 'hazardous',
]),
}),
'context': ,
@@ -202,14 +199,14 @@
'capabilities': dict({
'options': list([
'good',
- 'hazardous',
- 'high',
- 'low',
'moderate',
'unhealthy',
+ 'very_unhealthy',
+ 'hazardous',
]),
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -244,11 +241,10 @@
'friendly_name': 'Home Air quality day 3',
'options': list([
'good',
- 'hazardous',
- 'high',
- 'low',
'moderate',
'unhealthy',
+ 'very_unhealthy',
+ 'hazardous',
]),
}),
'context': ,
@@ -267,14 +263,14 @@
'capabilities': dict({
'options': list([
'good',
- 'hazardous',
- 'high',
- 'low',
'moderate',
'unhealthy',
+ 'very_unhealthy',
+ 'hazardous',
]),
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -309,11 +305,10 @@
'friendly_name': 'Home Air quality day 4',
'options': list([
'good',
- 'hazardous',
- 'high',
- 'low',
'moderate',
'unhealthy',
+ 'very_unhealthy',
+ 'hazardous',
]),
}),
'context': ,
@@ -333,6 +328,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -385,6 +381,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -440,6 +437,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -489,6 +487,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -537,6 +536,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -585,6 +585,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -633,6 +634,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -681,6 +683,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -729,6 +732,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -777,6 +781,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -825,6 +830,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -873,6 +879,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -921,6 +928,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -969,6 +977,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1016,6 +1025,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1063,6 +1073,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1110,6 +1121,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1157,6 +1169,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1204,6 +1217,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1251,6 +1265,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1298,6 +1313,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1345,6 +1361,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1392,6 +1409,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1441,6 +1459,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1491,6 +1510,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1540,6 +1560,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1589,6 +1610,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1638,6 +1660,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1687,6 +1710,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1736,6 +1760,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1784,6 +1809,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1832,6 +1858,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1880,6 +1907,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1928,6 +1956,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -1978,6 +2007,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2028,6 +2058,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2077,6 +2108,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2126,6 +2158,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2175,6 +2208,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2224,6 +2258,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2275,6 +2310,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2328,6 +2364,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2387,6 +2424,7 @@
]),
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2440,6 +2478,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2489,6 +2528,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2538,6 +2578,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2587,6 +2628,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2636,6 +2678,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2687,6 +2730,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2737,6 +2781,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2786,6 +2831,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2835,6 +2881,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2884,6 +2931,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2933,6 +2981,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -2982,6 +3031,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -3031,6 +3081,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -3080,6 +3131,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -3129,6 +3181,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -3178,6 +3231,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -3229,6 +3283,7 @@
'state_class': ,
}),
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -3279,6 +3334,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id': ,
+ 'config_subentry_id': ,
'device_class': None,
'device_id': ,
'disabled_by': None,
@@ -3328,6 +3384,7 @@
'area_id': None,
'capabilities': None,
'config_entry_id':